Skip to main content
VARTA

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

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, 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 — 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.

Open a session

POST /v1/sessions

Equivalent to POST /v1/agents/{agent_id}/load 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.

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 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 for the replay semantics.

Errors: invalid_request when agent_id is missing from the body or context isn't an object, workflow_not_found when agent_id is malformed, not_found when it's well-formed but doesn't belong to your tenant.

Submit a turn

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.

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.

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

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, 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.

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.

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

End a session

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.

curl -X POST "$VARTA_BASE_URL/sessions/se_9021/end" \
  -H "Authorization: Bearer $VARTA_API_KEY"
{
  "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.

Errors: session_not_found when session_id is malformed, not_found when it's well-formed but doesn't belong to your tenant.

Get session detail

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.

curl "$VARTA_BASE_URL/sessions/se_9021" \
  -H "Authorization: Bearer $VARTA_API_KEY"
{
  "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, 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 when session_id is malformed, not_found when it's well-formed but doesn't belong to your tenant.