Track the mood of a call
Follow mood, escalation and resolution as the call is transcribed.
By the time a call review flags an angry customer, the customer has already left. This guide turns the call into text as it happens and streams it to Sage2, which keeps three answers up to date: how the customer feels, whether they want someone else, and whether the problem is solved.
A supervisor can step in during the call, not after it.
- Scale
- Yes/No
- Stream
The transcript shows up here.
Mood
The score shows up with the first update.
- Wants someone else?…
- Problem solved?…
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 websockets.asyncio.client import connect
URL = "wss://sage.levanto.ai/v1/stream"
HEADERS = {"Authorization": f"Bearer {os.environ['SAGE_API_KEY']}"}
# In production, each line comes from your speech-to-text as the call goes on.
CALL = [
"Agent: Thanks for calling Northbrew, how can I help?",
"Customer: Hi. My machine has been broken for two weeks and this is my fourth call.",
"Agent: I'm sorry about that. Let me pull up your case.",
"Customer: You said that last time. Honestly, I just want a person who can fix this.",
"Agent: I can send a replacement today, free, with next-day delivery.",
"Customer: Oh. OK, that actually helps. Thank you.",
]
MOOD = {
"id": "mood",
"kind": "scale",
"instructions": "How is the customer feeling right now?",
"levels": [
{"level": 1, "description": "Furious"},
{"level": 2, "description": "Annoyed"},
{"level": 3, "description": "Neutral"},
{"level": 4, "description": "Pleased"},
{"level": 5, "description": "Delighted"},
],
}
FLAGS = [
{
"id": "wants_supervisor",
"kind": "yesno",
"instructions": "Is the customer asking for someone else to handle this right now?",
},
{
"id": "resolved",
"kind": "yesno",
"instructions": "Has the customer's problem been solved to their satisfaction?",
},
]
QUESTIONS = [MOOD] + FLAGS
async def feed(ws):
for line in CALL:
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":
mood = frame["answers"]["mood"]["result"]["expectation"]
print(f"mood {mood:.1f}/5", {q: frame["answers"][q]["result"].get("answer") for q in ("wants_supervisor", "resolved")})
elif frame["type"] == "error":
print("error:", frame.get("code"), frame["detail"])
await feeder
asyncio.run(main())
How it works
- Speech to text
Turn the call into text
Sage2 reads text. Run the call through any speech-to-text service first. The live demo above uses OpenAI and sends a few seconds of audio at a time.
Label each speaker. Most call platforms record each side on its own channel. Transcribe each one and start its lines with
Agent:orCustomer:. The questions can then tell the two apart.Runs with the helper from Get the code.
# In production, each line comes from your speech-to-text as the call goes on. CALL = [ "Agent: Thanks for calling Northbrew, how can I help?", "Customer: Hi. My machine has been broken for two weeks and this is my fourth call.", "Agent: I'm sorry about that. Let me pull up your case.", "Customer: You said that last time. Honestly, I just want a person who can fix this.", "Agent: I can send a replacement today, free, with next-day delivery.", "Customer: Oh. OK, that actually helps. Thank you.", ] A Scale question scores the mood against your own levels, from 1 (Furious) to 5 (Delighted). Each level has a
description.The result's
expectationis the expected level, for example2.4.confidence, from 0 to 1, says how sure Sage2 is of that score, separate from the score itself. A low value means the customer is hard to read so far: wait for more of the call before you act on the mood.Runs with the helper from Get the code.
MOOD = { "id": "mood", "kind": "scale", "instructions": "How is the customer feeling right now?", "levels": [ {"level": 1, "description": "Furious"}, {"level": 2, "description": "Annoyed"}, {"level": 3, "description": "Neutral"}, {"level": 4, "description": "Pleased"}, {"level": 5, "description": "Delighted"}, ], }Two Yes/No questions:
wants_supervisor: is the customer asking for someone else right now?resolved: is the problem solved to their satisfaction?
Each returns
answer(yes,no, ornullwhen Sage2 isn't sure) andprobability, the chance of yes.Runs with the helper from Get the code.
FLAGS = [ { "id": "wants_supervisor", "kind": "yesno", "instructions": "Is the customer asking for someone else to handle this right now?", }, { "id": "resolved", "kind": "yesno", "instructions": "Has the customer's problem been solved to their satisfaction?", }, ] QUESTIONS = [MOOD] + FLAGSOpen one
/v1/streamsession per call. Sendstartwith the questions,appendeach transcribed line, andclosewhen the call ends.Every
decisionframe carries all three answers inanswers.changedlists the ones that moved. Anerrorframe says what went wrong indetail. Open the stream from your server to keep the key private.A run of the sample call printed this. Lines that land while Sage2 is deciding fold into the next update, so your run may show fewer or more lines.
mood 2.9/5 {'wants_supervisor': 'no', 'resolved': None}mood 1.7/5 {'wants_supervisor': 'no', 'resolved': 'no'}mood 1.9/5 {'wants_supervisor': 'no', 'resolved': 'no'}mood 1.9/5 {'wants_supervisor': 'yes', 'resolved': 'yes'}
Runs with the helper from Get the code.
async def feed(ws): for line in CALL: 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": mood = frame["answers"]["mood"]["result"]["expectation"] print(f"mood {mood:.1f}/5", {q: frame["answers"][q]["result"].get("answer") for q in ("wants_supervisor", "resolved")}) elif frame["type"] == "error": print("error:", frame.get("code"), frame["detail"]) await feeder asyncio.run(main())
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.
resolvedcan turn yes early, at the agent's offer, before the customer agrees. Wait until it holds before you close the case.