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 withauth_mode: "enforce". On a default instance that setting is"audit"(backend/app/core/config.py:26), and in audit moderequire_tenantreturns 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/sessionsEquivalent 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}/turnSend 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 publishstatusandended_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}/endCloses 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.