Skip to content

Read a live sales chat

Know when a customer is about to buy, or walk, as each message lands.

A sales chat changes direction with every message: a rival offer, a trade-in, a partner who has to agree. This guide streams the chat to Sage2 and gets updated answers as each message lands: will they buy, what's holding them back, and what was offered.

Your salespeople see the deal move while it's happening, not in tomorrow's report.

  • Yes/No
  • Choice
  • Stream
  • Extract

A customer is about to walk into Riviera Motors.

  • Will they buy?…
  • Shopping around?…
  • Needs someone's OK?…
  • Main concern…
Car
…
Rival dealer
…
Rival offer
…
Our price
…
Trade-in
…

Get the code

Set SAGE_API_KEY (get a free key), then run it. Python: pip install websockets. TypeScript: Node 22+. Save as recipe.mts and run npx tsx recipe.mts.

A null answer is not an error. Sage2 isn't sure, and each step below says what to do with it. Limits, errors and retries: Limits and errors.

import asyncio, json, os
from urllib.request import Request, urlopen
from websockets.asyncio.client import connect

URL = "wss://sage.levanto.ai/v1/stream"
HEADERS = {"Authorization": f"Bearer {os.environ['SAGE_API_KEY']}"}

SIGNALS = [
    {
        "id": "buying",
        "kind": "yesno",
        "instructions": "Will the customer buy the car?",
    },
    {
        "id": "shopping_around",
        "kind": "yesno",
        "instructions": "Is the customer considering an offer from another dealer?",
    },
    {
        "id": "needs_approval",
        "kind": "yesno",
        "instructions": "Does the customer still need someone else's approval before deciding?",
    },
]

CONCERN = {
    "id": "concern",
    "kind": "choice",
    "instructions": "What is the customer's main concern right now?",
    "options": [
        {"option": "price"},
        {"option": "trade-in value"},
        {"option": "reliability"},
        {"option": "timing"},
        {"option": "none"},
    ],
}

QUESTIONS = SIGNALS + [CONCERN]

CHAT = [
    "Hannah: Hi Priya, thanks for coming in. Still looking at the Aurora C5?",
    "Priya: Yes, the two-year-old grey one. You have it at 31,500 euros, right?",
    "Hannah: That's right. It has 28,000 km and one owner.",
    "Priya: Northgate Auto offered me the same model for 29,900 yesterday.",
    "Hannah: I can look at that. Are you trading anything in?",
    "Priya: My old hatchback. Your colleague said 4,000, which feels low.",
    "Hannah: If I go to 30,200 and 4,800 for the trade-in, could we close today?",
    "Priya: I'd have to run it past my husband first. But that's much better.",
    "Priya: He says yes! Can I pay the deposit now?",
]

async def feed(ws):
    for line in CHAT:  # in production: each message as it arrives
        await ws.send(json.dumps({"type": "append", "text": line + "\n"}))
        await asyncio.sleep(1)
    await ws.send(json.dumps({"type": "close"}))

async def main():
    async with connect(URL, additional_headers=HEADERS) as ws:
        await ws.send(json.dumps({"type": "start", "questions": QUESTIONS}))
        feeder = asyncio.create_task(feed(ws))
        async for raw in ws:  # ends on a normal close; any other close raises
            frame = json.loads(raw)
            if frame["type"] == "decision":
                for qid in frame["changed"]:
                    r = frame["answers"][qid]["result"]
                    print(qid, r.get("answer", r.get("chosen")), r["probability"])
            elif frame["type"] == "error":
                print("error:", frame.get("code"), frame["detail"])
        await feeder

asyncio.run(main())

FIELDS = {
    "car": "the car the customer wants",
    "rival_dealer": "another dealer the customer mentions",
    "rival_offer": {"type": "money", "definition": "the price another dealer offered"},
    "final_price": {
        "type": "money",
        "definition": "the latest price the salesperson offered",
    },
    "trade_in": {"type": "money", "definition": "the latest trade-in value offered"},
}

def extract(content, fields):
    body = json.dumps({"content": content, "extract": fields}).encode()
    req = Request("https://sage.levanto.ai/v1/extract", data=body, method="POST",
                  headers={**HEADERS, "Content-Type": "application/json"})
    with urlopen(req, timeout=30) as r:
        return json.load(r)["values"]

print(extract("\n".join(CHAT), FIELDS))

How it works

  1. Yes/No

    Ask what you want to know

    Yes/No docs

    Write each signal as a Yes/No question with an id and plain instructions:

    • buying: will the customer buy?
    • shopping_around: are they weighing another dealer's offer?
    • needs_approval: does someone else still have to agree?

    Runs with the helper from Get the code.

    SIGNALS = [
        {
            "id": "buying",
            "kind": "yesno",
            "instructions": "Will the customer buy the car?",
        },
        {
            "id": "shopping_around",
            "kind": "yesno",
            "instructions": "Is the customer considering an offer from another dealer?",
        },
        {
            "id": "needs_approval",
            "kind": "yesno",
            "instructions": "Does the customer still need someone else's approval before deciding?",
        },
    ]
  2. Choice

    Track the main concern

    Choice docs

    A Choice question picks the customer's main concern from your options: price, trade-in value, reliability, timing, or none.

    All four questions go into one list. You send the list once at the start of the session.

    Runs with the helper from Get the code.

    CONCERN = {
        "id": "concern",
        "kind": "choice",
        "instructions": "What is the customer's main concern right now?",
        "options": [
            {"option": "price"},
            {"option": "trade-in value"},
            {"option": "reliability"},
            {"option": "timing"},
            {"option": "none"},
        ],
    }
    
    QUESTIONS = SIGNALS + [CONCERN]
  3. Stream

    Stream the chat

    Stream docs

    A stream is a WebSocket session at /v1/stream:

    • start opens it with your questions. Sage2 replies ready.
    • append adds each new message as text. Send it as soon as the message arrives.
    • decision frames come back whenever an answer changes. answers holds every answer, and changed lists the ones that moved.
    • close ends the session.
    • error frames say what went wrong in detail. A fatal one closes the session, for example a bad key or a used-up quota.

    Sage2 keeps the conversation on its side. You send only the new message. Open the stream from your server with the key in the Authorization header. Anyone can read a key in a public web page.

    Runs with the helper from Get the code.

    CHAT = [
        "Hannah: Hi Priya, thanks for coming in. Still looking at the Aurora C5?",
        "Priya: Yes, the two-year-old grey one. You have it at 31,500 euros, right?",
        "Hannah: That's right. It has 28,000 km and one owner.",
        "Priya: Northgate Auto offered me the same model for 29,900 yesterday.",
        "Hannah: I can look at that. Are you trading anything in?",
        "Priya: My old hatchback. Your colleague said 4,000, which feels low.",
        "Hannah: If I go to 30,200 and 4,800 for the trade-in, could we close today?",
        "Priya: I'd have to run it past my husband first. But that's much better.",
        "Priya: He says yes! Can I pay the deposit now?",
    ]
    
    async def feed(ws):
        for line in CHAT:  # in production: each message as it arrives
            await ws.send(json.dumps({"type": "append", "text": line + "\n"}))
            await asyncio.sleep(1)
        await ws.send(json.dumps({"type": "close"}))
    
    async def main():
        async with connect(URL, additional_headers=HEADERS) as ws:
            await ws.send(json.dumps({"type": "start", "questions": QUESTIONS}))
            feeder = asyncio.create_task(feed(ws))
            async for raw in ws:  # ends on a normal close; any other close raises
                frame = json.loads(raw)
                if frame["type"] == "decision":
                    for qid in frame["changed"]:
                        r = frame["answers"][qid]["result"]
                        print(qid, r.get("answer", r.get("chosen")), r["probability"])
                elif frame["type"] == "error":
                    print("error:", frame.get("code"), frame["detail"])
            await feeder
    
    asyncio.run(main())
  4. Extract

    Pull out the deal's numbers

    Extract docs

    For the figures, send the chat to /v1/extract: the car, the rival dealer and their offer, the latest price and the trade-in value. rival_offer, final_price and trade_in have type money. They come back as {amount, currency}.

    currency is null when the chat doesn't name one next to the number, as here. Set your own default.

    Run it after the chat, or whenever a decision frame says something changed. It is a plain HTTPS call, so it needs nothing beyond the stream's setup.

    Runs with the helper from Get the code.

    FIELDS = {
        "car": "the car the customer wants",
        "rival_dealer": "another dealer the customer mentions",
        "rival_offer": {"type": "money", "definition": "the price another dealer offered"},
        "final_price": {
            "type": "money",
            "definition": "the latest price the salesperson offered",
        },
        "trade_in": {"type": "money", "definition": "the latest trade-in value offered"},
    }
    
    def extract(content, fields):
        body = json.dumps({"content": content, "extract": fields}).encode()
        req = Request("https://sage.levanto.ai/v1/extract", data=body, method="POST",
                      headers={**HEADERS, "Content-Type": "application/json"})
        with urlopen(req, timeout=30) as r:
            return json.load(r)["values"]
    
    print(extract("\n".join(CHAT), FIELDS))
    Example Sage2 output
    {
      "values": {
        "car": "Aurora C5",
        "rival_dealer": "Northgate Auto",
        "rival_offer": {
          "amount": 29900,
          "currency": null
        },
        "final_price": {
          "amount": 30200,
          "currency": null
        },
        "trade_in": {
          "amount": 4800,
          "currency": null
        }
      },
      "results": {
        "car": {
          "value": "Aurora C5",
          "text": "Aurora C5",
          "start": 61,
          "end": 70,
          "probability": 0.9682,
          "probabilities": [
            {
              "value": "Aurora C5",
              "text": "Aurora C5",
              "start": 61,
              "end": 70,
              "probability": 0.9682
            },
            {
              "value": "grey",
              "text": "grey",
              "start": 101,
              "end": 105,
              "probability": 0.0309
            }
          ]
        },
        "rival_dealer": {
          "value": "Northgate Auto",
          "text": "Northgate Auto",
          "start": 208,
          "end": 222,
          "probability": 0.8711,
          "probabilities": [
            {
              "value": "Northgate Auto",
              "text": "Northgate Auto",
              "start": 208,
              "end": 222,
              "probability": 0.8711
            },
            {
              "value": null,
              "probability": 0.1289
            }
          ]
        },
        "…": "one entry per field"
      },
      "meta": {
        "model": "levanto-sage-2",
        "usage": {
          "input_tokens": 400,
          "output_tokens": 581
        },
        "latency_ms": 88.9
      }
    }

Before you ship

  1. Pay only for changes. Leave emit on changes (the default). Sage2 sends a frame, and bills output, only when an answer moves.
  2. Keep questions short. You pay for them on every update.
  3. Act on a trend. Answers can flip as the conversation moves. Alert when an answer holds for two or three updates.
  4. Reconnect on draining. Open a new session and send start with the text so far in content.
  5. Long pauses: send ping. A session closes after 300 seconds of silence.
  6. Long conversations: up to 16,384 tokens of text plus questions per session.
  7. Fast and small. An update usually lands in about 0.1 s. It reads only the new text plus your questions: a few hundred tokens.

Sage / Levanto

Start free.

Sign in or create an account.

Continue with Google
or with email

We’ll email you a 6-digit code.