DEV Community

Harpreet Singh Seehra
Harpreet Singh Seehra

Posted on

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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."}
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

ngrok http 5000
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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."}
Enter fullscreen mode Exit fullscreen mode

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."}]
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)