Skip to content

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

  1. 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: or Customer:. 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.",
    ]
  2. Scale

    Score the mood

    Scale docs

    A Scale question scores the mood against your own levels, from 1 (Furious) to 5 (Delighted). Each level has a description.

    The result's expectation is the expected level, for example 2.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"},
        ],
    }
  3. Yes/No

    Flag escalation and resolution

    Yes/No docs

    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, or null when Sage2 isn't sure) and probability, 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] + FLAGS
  4. Stream

    Stream the transcript

    Stream docs

    Open one /v1/stream session per call. Send start with the questions, append each transcribed line, and close when the call ends.

    Every decision frame carries all three answers in answers. changed lists the ones that moved. An error frame says what went wrong in detail. 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

  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.
  8. resolved can turn yes early, at the agent's offer, before the customer agrees. Wait until it holds before you close the case.

Sage / Levanto

Start free.

Sign in or create an account.

Continue with Google
or with email

We’ll email you a 6-digit code.