Skip to main content

Allow cookies so we can count page views, see which pages and campaigns bring people here, and measure our ads on Meta, Google and LinkedIn. Cookie policy

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.