# How do I hold a text conversation with a VARTA agent, without telephony?

A session is the headless conversation runtime — open one, submit the caller's transcribed text one turn at a time, and get back the agent's reply, its audio, the decision trace and the turn's cost. No phone call is involved, so it works over any media transport you already have.


A session is a running conversation, independent of how the audio gets to and from
the caller. You open one, then submit turns: each turn is the caller's transcribed
text in, and the agent's reply — text, pre-synthesised audio, which layer of the
engine decided the reply, and what that turn cost — out. Nothing here places a phone
call; that's [`POST /v1/calls`](/docs/api/calls), which uses this same runtime
underneath.

> **Tenant isolation on this page applies in enforce mode.** The scoping described
> below — "your tenant", and an id belonging to another tenant reported as
> [`not_found`](/docs/errors/not_found) — is enforced only when the instance runs with
> `auth_mode: "enforce"`. On a **default instance that setting is `"audit"`**
> (`backend/app/core/config.py:26`), and in audit mode `require_tenant` returns the
> resource without comparing tenants at all
> (`backend/app/security/ownership.py:51-52`). Object-level isolation is then
> *observed but not enforced*: a well-formed id belonging to another tenant resolves
> normally instead of 404ing. See [Authentication](/docs/authentication).

## Open a session

```varta-endpoint
POST /v1/sessions
```

Equivalent to [`POST /v1/agents/{agent_id}/load`](/docs/api/agents) with the agent
named in the body instead of the URL — use this form when the agent id is dynamic at
the call site. Takes the same body and returns the same shape: `agent_id` moves from
the path into the body, everything else (`context`, `customer_name`, `customer_phone`,
`title`, all optional) is identical.

```bash
curl -X POST "$VARTA_BASE_URL/sessions" \
  -H "Authorization: Bearer $VARTA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"agent_id": "ag_412", "customer_name": "Rahul"}'
```

See [Load an agent](/docs/api/agents#load-an-agent) for the response shape, including
which fields of its `opening.clips` are documented.

Like every state-changing call under `/v1`, this endpoint honours an
`Idempotency-Key` header — see [Idempotency](/docs/idempotency) for the replay
semantics.

**Errors:** [`invalid_request`](/docs/errors/invalid_request) when `agent_id` is
missing from the body or `context` isn't an object,
[`workflow_not_found`](/docs/errors/workflow_not_found) when `agent_id` is malformed,
[`not_found`](/docs/errors/not_found) when it's well-formed but doesn't belong to
your tenant.

## Submit a turn

```varta-endpoint
POST /v1/sessions/{session_id}/turn
```

Send the caller's transcribed text; get back the agent's reply and everything that
went into producing it.

```bash
curl -X POST "$VARTA_BASE_URL/sessions/se_9021/turn" \
  -H "Authorization: Bearer $VARTA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text": "haan boliye", "stt_confidence": 0.94, "stt_provider": "deepgram"}'
```

`text` is required. `stt_confidence`, `stt_provider`, `stt_ms` and `silence_ms` are
optional telemetry your speech recogniser can supply — VARTA doesn't do speech
recognition itself here, it consumes text you already transcribed.

```json
{
  "session_id": "se_9021",
  "text": "Aapka order kal deliver ho jayega.",
  "spoken_text": "Aapka order kal deliver ho jayega.",
  "audio_url": "https://.../turn_4.mp3",
  "clips": [],
  "cache_hit": true,
  "ended": false,
  "state": {
    "cursor": "delivery_date",
    "step_done": true,
    "slots": {},
    "step_progress": {
      "done": 2,
      "total": 5,
      "pct": 40,
      "steps": { "greet": "done", "delivery_date": "done", "confirm": "pending" }
    }
  },
  "trace": {
    "layer": "l1_exact_match",
    "llm_used": false,
    "steps": [],
    "latency_ms": { "total": 210, "llm": null, "stt": null, "tts": 40, "classifier": 12 }
  },
  "cost": { "llm_inr": 0, "tts_inr": 0, "stt_inr": 0, "total_inr": 0 },
  "session_totals": {
    "llm_inr": 0.04, "tts_inr": 0.01, "stt_inr": 0, "total_inr": 0.05,
    "turns": 4, "llm_calls": 1, "cache_hit_percent": 75, "tts_chars": 210,
    "llm_input_tokens": 812, "llm_output_tokens": 96
  }
}
```

`cache_hit` tells you whether this turn's audio came from the pre-synthesis cache
(free, instant) or a live TTS call. `trace.layer` names which decision layer of the
engine produced the reply (a keyword match, a rule, the LLM) — useful for debugging
why the agent said what it said, not a stable enum you should branch on. `cost` is
this turn's spend; `session_totals` is the running total for the whole session so far.

### What `clips` carries

Every clip carries the same three base fields: `text`, `audio_url` and
`livekit_wav_url`. Beyond those, a clip may carry extra fields depending on which
engine path built it — treat them as optional and key off their presence, not their
absence:

| Field | Appears on | Source |
| --- | --- | --- |
| `is_filler: true` | a prepended echo-acknowledgement clip | `backend/app/varta/l6_expression.py:183` |
| `is_readback: true` | a prepended confirmation-readback clip | `backend/app/routers/sessions.py:4406` |
| `is_digit`, `is_confirm_prompt` | the per-digit clips and the trailing confirm prompt of a digit readback (a phone number or pincode spoken back one digit at a time) | `backend/app/varta/helpers.py:644-654` |
| `intent_key`, `source` | a clip built by the FAQ answer path — both the answer clip and the step re-prompt chained after it | `backend/app/varta/l5_niti.py:1958-1964`, `:1971-1977` |

All of these reach the wire unchanged: the `/v1` layer passes `audio_clips` straight
through (`backend/app/api/v1/sessions.py:170`) rather than projecting a fixed field
set, so a clip shape added inside the engine appears here without this page changing.

`intent_key` is **not** an opening-clip marker. It appears on turn clips too, via the
FAQ path above — do not use its presence to decide whether you are looking at an
opening clip. `display_order` and `requires_user_input` are genuinely opening-only
(`backend/app/routers/sessions.py:1072-1073`); see
[Load an agent](/docs/api/agents#load-an-agent).

### `state.slots` is currently always empty

`slots` is published on every turn, but on this build it is **always `{}`** — there is
no configuration that makes it populate. `_slots_from_progress`
(`backend/app/api/v1/sessions.py:208-219`) emits a key only when
`step_progress.steps[<step_id>]` is a *dict* carrying a `data` member, but every
producer of that map flattens it to a status *string* first
(`backend/app/varta/helpers.py:769`, `backend/app/routers/sessions.py:2578`, `:2729`,
`:2894`). The dict branch is therefore unreachable. Do not build against this field.

What does carry the value today, on the published `/v1` surface: nothing structured.
`state.step_progress.steps[<step_id>] == "done"` tells you a step finished capturing
*something* but not what, and the agent's own reply (`text`) usually echoes the value
back in prose. The structured capture does exist internally — the engine keeps it on
`steps_map[<step_id>]["data"]` — but the only endpoint that publishes it is the
studio's internal transcript export (`collected_data`,
`backend/app/routers/sessions.py:965-969`), which is not part of the `/v1` contract
and is not documented here. If you need captured values structured, treat that as a
gap to raise with the operator of the instance, not as a field to poll for.

### `ended` is not the goodbye signal

`ended` reflects `call_ended` on the internal turn result — but the engine deliberately
holds the line open for a grace window after the agent says goodbye, so that a caller
who adds "wait, one more thing" is still heard. `_apply_close_grace`
(`backend/app/varta/__init__.py:697-699`) flips `call_ended` **back to `false`** on that
turn and sets `pending_close` and `close_grace_ms` instead — and `_shape_turn` does not
publish either of those two fields.

The practical consequence: **the agent can speak its closing line on a turn where
`ended` is still `false`**, and the session closes only on the following turn (or when
the grace window lapses). Treating `ended` as the sole end-of-conversation signal will
make you play a goodbye and then keep listening.

There is no published field that marks the goodbye turn. `pending_close` and
`close_grace_ms` are dropped by the `/v1` shaping layer, and
`GET /v1/sessions/{session_id}` (below) does not publish an `ended_at`
or a status of its own either (`backend/app/core/state.py:236-295` — check the field
list; it is cost, latency and turns only). Two things you *can* rely on:

- For a session opened through [`POST /v1/calls`](/docs/api/calls),
  `GET /v1/calls/{call_id}` does publish `status` and `ended_at`
  (`backend/app/api/v1/calls.py:186-189`). Poll that.
- For a bare session, the next turn you submit returns `ended: true`, or — once the
  session is actually closed —
  [`session_already_ended`](/docs/errors/session_already_ended).

Otherwise, drive the close from your side: call
`POST /v1/sessions/{session_id}/end` (above) when your own UI is done. That
finalises the ledger regardless of where the engine's grace window had got to.

This endpoint also honours an `Idempotency-Key` header, like every state-changing
call under `/v1` — see [Idempotency](/docs/idempotency).

**Errors:** [`invalid_request`](/docs/errors/invalid_request) when `text` is missing
or empty, [`session_already_ended`](/docs/errors/session_already_ended) when the
session was already closed with `POST /v1/sessions/{session_id}/end`,
[`session_not_found`](/docs/errors/session_not_found) when `session_id` is malformed,
[`not_found`](/docs/errors/not_found) when it's well-formed but doesn't belong to
your tenant.

## End a session

```varta-endpoint
POST /v1/sessions/{session_id}/end
```

Closes the session and finalises its cost ledger. Call this once you're done with the
conversation — costs stop accruing to session totals, and the structured end-of-call
summary (step outcomes, tool calls, overall outcome, and whatever the instance's
summariser was able to extract) becomes available. Note this is a *generated*
summary, not the `state.slots` map described above — that one is always empty.

```bash
curl -X POST "$VARTA_BASE_URL/sessions/se_9021/end" \
  -H "Authorization: Bearer $VARTA_API_KEY"
```

```json
{
  "session_id": "se_9021",
  "ended": true,
  "summary": { "...": "structured end-of-call summary" },
  "cost": { "llm_inr": 0.04, "tts_inr": 0.01, "stt_inr": 0, "total_inr": 0.05 }
}
```

`summary` is never `null`. Building it is best-effort and a failure there does not
fail the request — by the time you call `/end` the caller has already hung up — but
the failure is coerced to an **empty object**, not a null:
`(result or {}).get("summary") or {}` (`backend/app/api/v1/sessions.py:97`). So check
for `{}`, not for `null`: an empty `summary` means the summary build failed or
produced nothing, and there is no separate error to read.

This endpoint also honours an `Idempotency-Key` header, like every state-changing
call under `/v1` — see [Idempotency](/docs/idempotency).

**Errors:** [`session_not_found`](/docs/errors/session_not_found) when `session_id`
is malformed, [`not_found`](/docs/errors/not_found) when it's well-formed but doesn't
belong to your tenant.

## Get session detail

```varta-endpoint
GET /v1/sessions/{session_id}
```

Turn history and running totals for one session — safe to poll mid-call.

This endpoint returns `state.session_response`
(`backend/app/core/state.py:236-295`): cost breakdown, token and character counts,
cache/latency aggregates and the full `turns` array. It does **not** return captured
slots — there is no such key in the response, and none of the `slots` caveats above
are worked around here. It also carries no `ended_at` or status field.

```bash
curl "$VARTA_BASE_URL/sessions/se_9021" \
  -H "Authorization: Bearer $VARTA_API_KEY"
```

```json
{
  "session_id": 9021,
  "cache_hits": 3,
  "live_tts_calls": 1,
  "llm_calls": 1,
  "total_cost_inr": 0.05,
  "llm_cost_inr": 0.04,
  "llm_input_tokens": 812,
  "llm_output_tokens": 96,
  "llm_cached_tokens": 0,
  "tts_cost_inr": 0.01,
  "tts_chars": 210,
  "stt_cost_inr": 0,
  "stt_seconds": 0,
  "cache_hit_percent": 75,
  "live_tts_percent": 25,
  "avg_latency_ms": 340,
  "total_latency_ms": 1360,
  "total_silence_ms": 900,
  "turns": []
}
```

`llm_input_tokens` / `llm_output_tokens` / `llm_cached_tokens` are prompt, completion
and cached-prompt token counts for the whole session; `tts_chars` is characters sent
to speech synthesis; `stt_seconds` is audio-seconds metered by the speech-recognition
proxy (silence included) — all running totals, same accounting as the per-turn
`session_totals` block on [Submit a turn](#submit-a-turn), just for the whole session
rather than one turn.

Two things worth knowing before you rely on this shape. First, `session_id` here is
the bare internal integer (`9021`), not the `se_`-prefixed id every other endpoint on
this page uses — an inconsistency in the current implementation, not a documented
alternate format; don't parse it as a string. Second, `turns` is the session's raw
internal turn list, not the flattened `{text, audio_url, trace, cost, ...}` shape that
`POST /v1/sessions/{session_id}/turn` returns — treat entries in it as
implementation detail rather than a contract.

**Errors:** [`session_not_found`](/docs/errors/session_not_found) when `session_id`
is malformed, [`not_found`](/docs/errors/not_found) when it's well-formed but doesn't
belong to your tenant.
