Voice AI

Practice Negotiations with an AI Phone Agent — Real-Time Roleplay with Telnyx Voice AI

Imagine picking up your phone, dialing a number, and practicing a salary negotiation against an AI that plays the hiring manager — one that pushes back on your first offer, counters with budget constraints, and then scores your technique after you hang up. No scheduling a mock interview, no paying a coach, no awkward roleplay with a colleague.

This is the AI Negotiation Practice Phone — a 110-line Python app built with Telnyx Call Control and AI Inference. Three scenarios (salary, sales deal, vendor contract), voice-driven conversation, and a structured performance score delivered after every call. The AI stays in character, adapts to your approach, and gives you actionable feedback.

In this walkthrough, you'll build it from scratch. Clone the repo, configure a phone number, and start practicing in minutes.

What You'll Build

A phone number anyone can call to practice negotiations:

  • Caller dials in — Telnyx answers and offers a menu
  • Scenario selection — press 1 for salary negotiation, 2 for sales deal, 3 for vendor contract
  • AI opens — the AI plays the opposing role (hiring manager, enterprise buyer, or vendor account manager) and makes an opening position
  • Live negotiation — caller speaks naturally, AI responds in character, pushes back, counters, and adapts
  • Post-call scoring — on hangup, the AI scores the negotiation across 5 dimensions and returns structured JSON
  • Session history — every practice session is stored and accessible via GET /sessions

The whole interaction is voice-driven: Text-to-Speech reads the AI's lines, and the caller responds with natural speech. The model (Llama 3.3 70B via Telnyx AI Inference) handles both the real-time roleplay and the post-call evaluation.

Why This Is Interesting

Most negotiation training tools are text-based chatbots or static video courses. Neither captures the pressure of a live conversation — the pauses, the pushback, the moment you have to think on your feet. This one puts you on a real phone call with an AI that has a budget, a role, and a hidden constraint (like "max 15% discount" or "budget is $155K with flexibility to $165K").

The scoring system makes it more than a conversation. After you hang up, the AI evaluates your performance across five dimensions — anchoring, concession strategy, active listening, creativity, and confidence — plus an overall score and specific strengths/improvements. You can practice the same scenario five times and track whether your technique improves.

It also demonstrates the DTMF + speech pattern: the app uses DTMF (keypad input) for scenario selection and speech recognition for the negotiation itself. This two-input pattern shows up in many real IVR workflows.

Prerequisites

The Architecture

Phone Call
    │
    ▼
Telnyx Call Control (webhook events)
    │
    ▼
Flask app (app.py, 110 lines)
    │
    ├──► call.initiated → answer the call
    ├──► call.answered → TTS menu (press 1, 2, or 3)
    ├──► call.speak.ended → gather DTMF (scenario selection)
    ├──► call.gather.ended → DTMF → pick scenario → AI opening line
    │                    └──► speech → AI inference → TTS response (negotiation loop)
    └──► call.hangup → score negotiation as JSON → store
    │
    ▼
Telnyx AI Inference (Llama 3.3 70B)
    │
    ▼
Structured score (JSON with 5 dimensions + feedback)

The app is a state machine with two phases: selection (DTMF menu) and negotiating (speech conversation). Each Telnyx webhook event drives the next action. After hangup, the full conversation is sent to the AI with a scoring prompt that returns structured JSON.

Step 1: Clone and Configure

git clone https://github.com/team-telnyx/telnyx-code-examples.git
cd telnyx-code-examples/ai-negotiation-practice-phone-python
cp .env.example .env
pip install -r requirements.txt

Edit .env with your credentials:

TELNYX_API_KEY=your_telnyx_api_key_here
TELNYX_PUBLIC_KEY=...
AI_MODEL=meta-llama/Llama-3.3-70B-Instruct
PRACTICE_NUMBER=+13105551234

Step 2: Understand the Code

Everything lives in app.py (110 lines). Here's what each piece does.

The Scenarios

The app ships with three built-in negotiation scenarios, each with a role and a hidden context:

SCENARIOS = {
    "1": {"role": "hiring manager", "context": "The candidate wants $180K. Your budget is $155K with flexibility to $165K. Push back on experience level. You can offer equity or signing bonus as alternatives."},
    "2": {"role": "enterprise buyer", "context": "You're evaluating their SaaS product at $50K/year. You have a competing offer at $35K. Your budget is $45K. Ask for volume discounts and longer payment terms."},
    "3": {"role": "vendor account manager", "context": "The client wants to reduce their contract by 40%. They're a top-10 account. You can offer 15% discount max, or restructure the deal with different terms."}
}

The context is injected into the system prompt so the AI knows its constraints — but the caller doesn't know them. You discover the other side's budget and flexibility through the conversation, just like a real negotiation.

The State Machine: Selection → Negotiating

The webhook handler has two states. In select state, it gathers DTMF digits to pick a scenario:

if call["state"] == "select":
    client.calls.actions.gather(ccid, input_type="dtmf", timeout_secs=10, min_digits=1, max_digits=1)

When the caller presses 1, 2, or 3, the app loads the scenario, builds the system prompt, asks the AI for an opening line, and switches to negotiating state:

if call["state"] == "select":
    scenario = SCENARIOS.get(digits, SCENARIOS["1"])
    call["state"] = "negotiating"
    call["scenario"] = scenario
    call["conversation"] = [{"role": "system", "content": f"You are a {scenario['role']} in a negotiation. {scenario['context']} Stay in character. Be firm but fair. Push back on their first offer. Keep responses under 2 sentences. After 6 exchanges, start wrapping up."}]
    opening = call_inference(call["conversation"] + [{"role": "user", "content": "The negotiation begins. Make your opening position."}])
    call["conversation"].append({"role": "assistant", "content": opening})
    client.calls.actions.speak(ccid, payload=opening, voice="female", language_code="en-US")

In negotiating state, it gathers speech and runs the conversation loop:

else:
    client.calls.actions.gather(ccid, input_type="speech", end_silence_timeout_secs=2, timeout_secs=20, language_code="en-US")

The conversation loop: speak → gather → infer → speak. Each turn, the AI sees the full conversation history, so it maintains context and remembers what you offered.

The Inference Helper

def call_inference(messages, max_tokens=200):
    resp = requests.post(INFERENCE_URL, headers={"Authorization": f"Bearer {TELNYX_API_KEY}", "Content-Type": "application/json"},
        json={"model": AI_MODEL, "messages": messages, "max_tokens": max_tokens, "temperature": 0.7}, timeout=15)
    resp.raise_for_status()
    return resp.json()["choices"][0]["message"]["content"]

OpenAI-compatible chat completions call against Telnyx AI Inference. Temperature 0.7 — higher than the price quote agent (0.5) because you want the negotiator to be a bit more creative and unpredictable, not perfectly consistent every time.

Post-Call Scoring

When the caller hangs up, the app sends the full conversation to the AI with a scoring prompt:

elif event_type == "call.hangup":
    call = active_calls.pop(ccid, None)
    if call and len(call.get("conversation", [])) > 3:
        score_prompt = [{"role": "system", "content": "Score this negotiation practice. Return JSON: anchoring (1-10), concession_strategy (1-10), active_listening (1-10), creativity (1-10), confidence (1-10), overall (1-10), strengths (list), improvements (list), deal_outcome (string)."},
            {"role": "user", "content": chr(10).join(f"{m['role']}: {m['content']}" for m in call["conversation"] if m["role"] != "system")}]
        try:
            score = json.loads(call_inference(score_prompt, max_tokens=400))
            sessions.append({"scenario": call.get("scenario", {}).get("role"), "score": score, "duration": int(time.time() - call["start"])})
        except Exception:
            pass

The AI returns a structured score with five dimensions, a list of strengths, a list of improvements, and the deal outcome. The app stores it alongside the scenario and call duration.

Accessing Sessions

@app.route("/sessions", methods=["GET"])
def list_sessions():
    return jsonify({"sessions": sessions[-20:]}), 200

Hit GET /sessions to see the last 20 practice sessions as JSON — track your progress over time, compare scenarios, or pipe into an analytics dashboard.

Memory Cleanup

The app includes a background thread that cleans up stale call state:

def _start_ttl_cleanup(*stores, ttl_seconds=3600, interval=300):
    def _cleanup():
        while True:
            _ttl_time.sleep(interval)
            cutoff = _ttl_time.time() - ttl_seconds
            for store in stores:
                expired = [k for k, v in store.items()
                           if isinstance(v, dict) and v.get("_ts", _ttl_time.time()) < cutoff]
                for k in expired:
                    store.pop(k, None)
    threading.Thread(target=_cleanup, daemon=True).start()

Calls that crash or get abandoned are automatically cleaned up after an hour — no memory leaks in long-running deployments.

Step 3: Run the App

Start the Flask server:

python app.py

In a separate terminal, expose your local server with ngrok:

ngrok http 5000

Copy the HTTPS URL and configure it in the Telnyx Portal:

  • Go to Call Control Applications
  • Create or edit your application
  • Set the Webhook URL to https://<your-ngrok-url>.ngrok.app/webhooks/voice

Assign your Telnyx phone number to this Call Control Application if you haven't already.

Step 4: Call and Practice

Call your Telnyx number from any phone. You'll hear:

"Negotiation Practice! Press 1 for salary negotiation, 2 for sales deal, 3 for vendor contract."

Press 1. The AI (playing a hiring manager) opens with something like:

"Thanks for calling in. I've reviewed your experience, and I have to be honest — we typically bring in candidates at $155K for this level. I know you mentioned $180K, but given the experience gap, I'm not sure I can justify that range. What would make you worth the premium?"

Negotiate. Push back. Make your case. The AI will counter, bring up constraints, and eventually either reach a deal or hold firm.

After you hang up, check your score:

curl http://localhost:5000/sessions | python3 -m json.tool

You'll see a structured score object like:

{
  "scenario": "hiring manager",
  "score": {
    "anchoring": 7,
    "concession_strategy": 5,
    "active_listening": 8,
    "creativity": 6,
    "confidence": 7,
    "overall": 6.6,
    "strengths": ["Strong opening anchor at $180K", "Asked about equity as alternative to base salary"],
    "improvements": ["Conceded too quickly on base salary", "Didn't explore signing bonus option"],
    "deal_outcome": "Settled at $162K base + $10K signing bonus"
  },
  "duration": 184
}

Customizing the Scenarios

The SCENARIOS dict is the control surface. Small changes produce very different practice sessions:

Add a real estate negotiation:

"4": {"role": "seller's agent", "context": "The house is listed at $650K. The seller will accept $620K minimum. The buyer seems interested but price-sensitive. Push for close to asking but signal flexibility on closing dates."}

Make the AI more aggressive:

call["conversation"] = [{"role": "system", "content": f"You are a {scenario['role']} in a negotiation. {scenario['context']} Stay in character. Be aggressive and push hard on the first offer. Never accept the first counter. Keep responses under 2 sentences. After 6 exchanges, start wrapping up."}]

Add multi-round scoring with trend tracking:

# After each session, compare to previous sessions in the same scenario
previous = [s for s in sessions if s.get("scenario") == call.get("scenario", {}).get("role")]
if previous:
    last_score = previous[-1]["score"]["overall"]
    new_score = score["overall"]
    score["trend"] = "improving" if new_score > last_score else "declining" if new_score < last_score else "stable"

Send the score via SMS after the call:

from telnyx import Message
score_text = f"Negotiation score: {score['overall']}/10. Strengths: {', '.join(score['strengths'][:2])}. Improvements: {', '.join(score['improvements'][:2])}."
Message.create(to=call["caller"], from_=PRACTICE_NUMBER, text=score_text)

Going to Production

This example uses in-memory storage for simplicity. For a production deployment:

  • Database — replace the in-memory sessions list with PostgreSQL or Redis so scores survive restarts
  • Authentication — add API key validation on the /sessions endpoint
  • User accounts — track scores per-user with phone number as the key
  • Scenario library — store scenarios in a database so coaches can add new ones without redeploying
  • Score history dashboard — build a simple web UI that shows score trends over time
  • Webhook verification — the app already validates Telnyx Ed25519 signatures; ensure your public key is current
  • Concurrency — run the app behind gunicorn with multiple workers
  • Error recovery — handle inference timeouts and call failures gracefully
  • Rate limiting — protect your webhook endpoint from abuse
  • Prompt tuning — test different system prompts and temperature settings for different difficulty levels

Frequently Asked Questions

Can I use a different AI model? Yes. Telnyx AI Inference is OpenAI-compatible. Set AI_MODEL in .env to any model available on the platform. Llama 3.3 70B Instruct is a good default for roleplay and evaluation.

How much does a call cost? Telnyx Call Control is priced per minute. AI Inference is priced per 1K tokens. A typical 6-exchange negotiation practice call uses ~2,000 tokens and ~3 minutes of call time — a few cents per call.

Can multiple people call at the same time? Yes. Each call gets its own entry in active_calls keyed by call_control_id. The app handles concurrent calls without interference.

What happens if the caller doesn't press a number? The DTMF gather times out after 10 seconds. The call ends without entering a negotiation.

Is the score reliable? The AI evaluates based on the conversation transcript. For high-stakes training, use it as directional feedback, not a definitive grade. The five-dimension breakdown is more useful than the overall score — it tells you what to work on.

Can I add more scenarios? Yes. Add entries to the SCENARIOS dict and update the greeting TTS to mention the new options.

Ready to build with low-latency voice AI?

Join developers building the future of real-time conversations