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
Write each signal as a Yes/No question with an
idand plaininstructions: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?", }, ]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]A stream is a WebSocket session at
/v1/stream:startopens it with yourquestions. Sage2 repliesready.appendadds each new message astext. Send it as soon as the message arrives.decisionframes come back whenever an answer changes.answersholds every answer, andchangedlists the ones that moved.closeends the session.
errorframes say what went wrong indetail. 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
Authorizationheader. 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())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_priceandtrade_inhave typemoney. They come back as{amount, currency}.currencyisnullwhen the chat doesn't name one next to the number, as here. Set your own default.Run it after the chat, or whenever a
decisionframe 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
- Pay only for changes. Leave
emitonchanges(the default). Sage2 sends a frame, and bills output, only when an answer moves. - Keep questions short. You pay for them on every update.
- Act on a trend. Answers can flip as the conversation moves. Alert when an answer holds for two or three updates.
- Reconnect on
draining. Open a new session and sendstartwith the text so far incontent. - Long pauses: send
ping. A session closes after 300 seconds of silence. - Long conversations: up to 16,384 tokens of text plus questions per session.
- 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.