# Read a live sales chat

Stream a sales chat to Sage2 and know, as each message lands, whether the customer will buy, and what was offered.

Category: Handle customer messages. Live demo: https://levanto.ai/examples/live-sales-chat

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.

## 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. Ask what you want to know

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?

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

```typescript
const 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. Track the main concern

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.

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

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

const QUESTIONS = [...SIGNALS, CONCERN];
```

### 3. Stream the chat

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.

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

```typescript
const 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 function feed(ws: WebSocket) {
  for (const line of CHAT) {  // in production: each message as it arrives
    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") {
      for (const qid of frame.changed) {
        const r = frame.answers[qid].result;
        console.log(qid, "answer" in r ? r.answer : r.chosen, r.probability);
      }
    } 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);
});
```

### 4. Pull out the deal's numbers

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.

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

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

async function extract(content: string, fields: unknown): Promise<any> {
  const r = await fetch("https://sage.levanto.ai/v1/extract", {
    method: "POST",
    headers: { ...HEADERS, "Content-Type": "application/json" },
    body: JSON.stringify({ content, extract: fields }),
    signal: AbortSignal.timeout(30_000),
  });
  if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
  return ((await r.json()) as any).values;
}

console.log(await extract(CHAT.join("\n"), FIELDS));
```

Response, abridged (a real run):

```json
{
  "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 `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.

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

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

## 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));

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

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

const QUESTIONS = [...SIGNALS, CONCERN];

const 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 function feed(ws: WebSocket) {
  for (const line of CHAT) {  // in production: each message as it arrives
    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") {
      for (const qid of frame.changed) {
        const r = frame.answers[qid].result;
        console.log(qid, "answer" in r ? r.answer : r.chosen, r.probability);
      }
    } 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);
});

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

async function extract(content: string, fields: unknown): Promise<any> {
  const r = await fetch("https://sage.levanto.ai/v1/extract", {
    method: "POST",
    headers: { ...HEADERS, "Content-Type": "application/json" },
    body: JSON.stringify({ content, extract: fields }),
    signal: AbortSignal.timeout(30_000),
  });
  if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
  return ((await r.json()) as any).values;
}

console.log(await extract(CHAT.join("\n"), FIELDS));
```

## Requests

### 1. POST /v1/extract

```bash
curl -s https://sage.levanto.ai/v1/extract \
  -H "Authorization: Bearer $SAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON'
{
  "content": "Hannah: Hi Priya, thanks for coming in. Still looking at the Aurora C5?\nPriya: Yes, the two-year-old grey one. You have it at 31,500 euros, right?\nHannah: That's right. It has 28,000 km and one owner.\nPriya: Northgate Auto offered me the same model for 29,900 yesterday.\nHannah: I can look at that. Are you trading anything in?\nPriya: My old hatchback. Your colleague said 4,000, which feels low.\nHannah: If I go to 30,200 and 4,800 for the trade-in, could we close today?\nPriya: I'd have to run it past my husband first. But that's much better.\nPriya: He says yes! Can I pay the deposit now?",
  "extract": {
    "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"
    }
  }
}
JSON
```

Response, abridged (a real run):

```json
{
  "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
  }
}
```

## Docs

- Yes/No: https://docs.levanto.ai/decision-model/yesno
- Choice: https://docs.levanto.ai/decision-model/choice
- Stream: https://docs.levanto.ai/stream
- Extract: https://docs.levanto.ai/extract
- Limits and errors: https://docs.levanto.ai/decision-model/limits
- OpenAPI: https://sage.levanto.ai/openapi.json
