# Track the mood of a call

Turn a call into text as it happens and stream it to Sage2: mood, escalation and resolution, updated live.

Category: Handle customer messages. Live demo: https://levanto.ai/examples/call-sentiment

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.

## Setup

Base URL: `https://sage.levanto.ai`. Send `Authorization: Bearer $SAGE_API_KEY` on every call. Get a key, free, at https://platform.levanto.ai. Keep the key on your server.

A `null` answer is not an error: Sage2 isn't sure. Each step says what to do with it.

## Step by step

### 1. 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.

```python
# 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.",
]
```

```typescript
// In production, each line comes from your speech-to-text as the call goes on.
const 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. Score the mood

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.

```python
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"},
    ],
}
```

```typescript
const 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. Flag escalation and resolution

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.

```python
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
```

```typescript
const 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?",
  },
];

const QUESTIONS = [MOOD, ...FLAGS];
```

### 4. Stream the transcript

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'}`

```python
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())
```

```typescript
async function feed(ws: WebSocket) {
  for (const line of CALL) {
    ws.send(JSON.stringify({ type: "append", text: line + "\n" }));
    await sleep(1000);
  }
  ws.send(JSON.stringify({ type: "close" }));
}

const ws = new WebSocket(STREAM_URL, { headers: HEADERS });
await new Promise<void>((resolve, reject) => {
  ws.addEventListener("open", () => {
    ws.send(JSON.stringify({ type: "start", questions: QUESTIONS }));
    feed(ws).catch(reject);
  });
  ws.addEventListener("message", e => {
    const frame = JSON.parse(String(e.data));
    if (frame.type === "decision") {
      const mood: number = frame.answers.mood.result.expectation;
      const flags = Object.fromEntries(["wants_supervisor", "resolved"].map(q => [q, frame.answers[q].result.answer]));
      console.log(`mood ${mood.toFixed(1)}/5`, flags);
    } else if (frame.type === "error") {
      console.log("error:", frame.code ?? null, frame.detail);
    }
  });
  // 1000 is a normal close. Any other code (a bad key, a used-up quota, a restart) is an error.
  ws.addEventListener("close", e => e.code === 1000 ? resolve() : reject(new Error(`stream closed: ${e.code} ${e.reason}`)));
  ws.addEventListener("error", reject);
});
```

## Before you ship

- **Pay only for changes.** Leave `emit` on `changes` (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 send `start` with the text so far in `content`.
- **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.
- **`resolved` can turn yes early,** at the agent's offer, before the customer agrees. Wait until it holds before you close the case.

## Full recipe (Python, `pip install websockets`)

```python
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())
```

## Full recipe (TypeScript, Node 22+, nothing to install: save as `recipe.mts`, run `npx tsx recipe.mts`)

```typescript
// Node 22+. Save as recipe.mts and run npx tsx recipe.mts
const STREAM_URL = "wss://sage.levanto.ai/v1/stream";
const HEADERS = { Authorization: `Bearer ${process.env.SAGE_API_KEY}` };
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

// In production, each line comes from your speech-to-text as the call goes on.
const 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.",
];

const 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" },
  ],
};

const 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?",
  },
];

const QUESTIONS = [MOOD, ...FLAGS];

async function feed(ws: WebSocket) {
  for (const line of CALL) {
    ws.send(JSON.stringify({ type: "append", text: line + "\n" }));
    await sleep(1000);
  }
  ws.send(JSON.stringify({ type: "close" }));
}

const ws = new WebSocket(STREAM_URL, { headers: HEADERS });
await new Promise<void>((resolve, reject) => {
  ws.addEventListener("open", () => {
    ws.send(JSON.stringify({ type: "start", questions: QUESTIONS }));
    feed(ws).catch(reject);
  });
  ws.addEventListener("message", e => {
    const frame = JSON.parse(String(e.data));
    if (frame.type === "decision") {
      const mood: number = frame.answers.mood.result.expectation;
      const flags = Object.fromEntries(["wants_supervisor", "resolved"].map(q => [q, frame.answers[q].result.answer]));
      console.log(`mood ${mood.toFixed(1)}/5`, flags);
    } else if (frame.type === "error") {
      console.log("error:", frame.code ?? null, frame.detail);
    }
  });
  // 1000 is a normal close. Any other code (a bad key, a used-up quota, a restart) is an error.
  ws.addEventListener("close", e => e.code === 1000 ? resolve() : reject(new Error(`stream closed: ${e.code} ${e.reason}`)));
  ws.addEventListener("error", reject);
});
```

## Docs

- Scale: https://docs.levanto.ai/decision-model/scale
- Yes/No: https://docs.levanto.ai/decision-model/yesno
- Stream: https://docs.levanto.ai/stream
- Limits and errors: https://docs.levanto.ai/decision-model/limits
- OpenAPI: https://sage.levanto.ai/openapi.json
