Sessions
This content is not available in your language yet.
A Session is one conversation between a caller and an Agent. This page is the exhaustive field-by-field reference for every Session operation, split into the two distinct ways to run one:
- Dashboard/JWT session flow —
startSession,sendMessage,endSession,interruptSession. Full JWT access, meant for a first-party dashboard or any client that already authenticates users directly against the platform. - SDK/embed flow —
sdkStart,refreshAvatarToken,sdkSendMessage,sdkEndSession. API-key +avatarTokenauth, meant for embedding a live agent (e.g. the<vai-avatar>web component) into a third-party site or app. This is what most runtime integrators actually use.
For the end-to-end narrative version of this flow (why each call exists, in what order), see Core API Flow. For how the AI’s reply actually arrives, see Events & Subscriptions — every message-sending mutation on this page is fire-and-forget; the reply streams back asynchronously.
Every request in this reference must include the x-huat-platform: customer header in addition to standard GraphQL headers.
Two different things named “avatarToken” — read this first
Section titled “Two different things named “avatarToken” — read this first”The word avatarToken refers to two related but distinct tokens in this API. Confusing them is the single most common integration mistake:
- The speech token (the
speechTokenmutation, and the older, deprecatedavatarTokenquery that returns the same thing) — a token scoped to the caller’s tenant, with no session, for the speech routes of the avatar service (/sttand/tts) and nothing else. It cannot send messages, read events or refresh itself. - The
avatarTokenfield returned bysdkStart— a session-scoped token, returned once when an SDK session begins. It authenticates the rest of the SDK flow (sdkSendMessage,sdkEndSession,sdkVerifyWebbyxOneIdentity,requestStudyKitExport,interruptSession,sessionEvents) in place of a JWT, viaAvatarTokenGuard. It is only valid for that one session.
Do not use the speech token in place of the session-scoped token from sdkStart: every session operation rejects it. The sdkStart token does work on the speech routes, as it always has, but starting a session just to get one is what speechToken is for.
As of 2026-09-08, the sdkStart-issued avatarToken is bound to the sessionId it was returned with — every operation listed above rejects a call whose sessionId argument doesn’t match the session the presented token was minted for, with Forbidden. Always use the sessionId/avatarToken pair together, exactly as returned from a single sdkStart call; a token from one session was never meant to be usable against another, and previously it silently was.
The SessionDto type
Section titled “The SessionDto type”Returned by session, sessions, startSession, sendMessage, and endSession:
| Field | Type | Notes |
|---|---|---|
id | Int! | |
agentId | Float! | |
agent | AgentDto | Nested Agent — see Agents. |
status | SessionStatus! | PENDING | ACTIVE | ENDED | EXPIRED. A session with no activity for 30 days becomes EXPIRED (no evaluation is run); a conversation that comes back after that simply starts a new session. |
clientExternalId | String! | Caller-supplied external identifier, echoed back. |
metadata | JSON | Caller-supplied arbitrary metadata, echoed back. |
messages | [MessageDto!]! | Full chat history, oldest first. There is no standalone chat-history query — always fetch history via session(id) { messages { ... } }. |
interruptCount | Int! | Number of times interruptSession has fired for this session. |
totalTokensInContext | Int! | Running LLM context-window token count. |
startedAt | DateTime | |
endedAt | DateTime | |
lastEventAt | DateTime | |
isTest | Boolean! | true for a “Try it” test chat on an unreleased (draft or switched-off) agent, started with startSession(input: { isTest: true }). Test chats are left out of the session lists and activity counts unless an owner asks for them (includeTests); their model and speech cost is counted like any other session. |
startedByUserId | Int | Id of the dashboard user who started this session with their sign-in (startSession, or the internal startAvatarDemoSession). null for sessions started with an API key (sdkStart), an avatarToken, a channel, a sub-agent dispatch or a regression replay, and for every session created before this field existed. Always null in sessionsByExternalCustomerId, which never exposes dashboard user ids. Read-only; never taken from client input. |
createdAt | DateTime! | |
updatedAt | DateTime! |
MessageDto fields (returned inside messages):
| Field | Type | Notes |
|---|---|---|
id | Int! | |
sessionId | Float! | |
role | MessageRole! | USER | MODEL. |
text | String! | |
turnIndex | Float! | Ordering index within the session. |
tokensIn | Float! | Estimated input tokens billed to this message — 0 for USER messages (only a MODEL reply has an LLM call behind it). Character-based estimate, not an exact tokenizer count. |
tokensOut | Float! | Estimated output tokens for this message. Same estimate caveat as tokensIn. |
costUsd | Float! | Estimated USD cost of the LLM call that produced this message — 0 for USER messages, and for a MODEL message from a workflow’s static/templated response node with no LLM call behind it. Not billing-exact. |
structuredContent | JSON | Optional payload alongside text — closed vocabulary { kind: "card" | "table" | "list", ... }, set only when a workflow’s response node has a structuredContentTemplate configured. null for every other message, and null if that template failed to render/parse/validate for this specific turn — text is always populated regardless, so treat null here as “nothing structured to show,” never as an error. Dashboard-only consumer today; no button/interactive variant yet. |
createdAt | DateTime! | |
updatedAt | DateTime! |
enum SessionStatus { ACTIVE ENDED EXPIRED PENDING}
enum MessageRole { MODEL USER}Dashboard/JWT session flow
Section titled “Dashboard/JWT session flow”These four operations require a JWT belonging to a user in the tenant that owns the target Agent/Session. Use this flow for a first-party dashboard, an admin console, or any server-side client that already has full user auth.
startSession
Section titled “startSession”Starts a new conversation Session against an Agent. Required before sendMessage or subscribing to session events. The session records the signed-in user who started it as startedByUserId, which sessionActivitySummaries(mine: true) filters on.
Auth: JWT
startSession(input: StartSessionInput!): SessionDto!Test chats. A test chat is hidden from sessions, sessionActivitySummaries and sessionCount, and its rows and counts stay out of activity totals, and it is never evaluated; its cost still counts. A member can start a session on their own unreleased draft agent. A test session opened by a member on a draft agent that has a workflow attached is refused with FORBIDDEN (“This draft changed: start a new test”) once the draft changes; the owner can keep testing.
Typed chat and voice formatting (in production since 2026-10-31). For an agent with no workflow, a session started here as a typed dashboard chat, or as a test chat, gets the agent’s persona as written, without the spoken-style rules (numbers in words, one to three short sentences) that voice sessions get. The server sets this from the way the session was started; a client cannot set it through metadata. Sessions started with startAvatarDemoSession, sdkStart, a channel or a voice call keep the spoken-style rules, so a text-only widget or Telegram agent still gets them for now. Workflow agents are unaffected.
StartSessionInput fields
| Field | Type | Required | Notes |
|---|---|---|---|
agentId | Int! | yes | Must be an Agent owned by the caller’s tenant. |
clientExternalId | String! | yes | An identifier you control (e.g. a visitor or ticket id) — echoed back on the Session, useful for correlating sessions with your own records. |
metadata | JSON | no | Arbitrary caller-supplied data, echoed back on the Session. |
isTest | Boolean | no | Marks a “Try it” test chat. Honoured only for a signed-in dashboard user (this mutation) and only when the agent is not released for everyone: it is switched off (not ACTIVE), or the caller is the workspace owner. Otherwise the flag is ignored and the session is a normal one; check isTest on the result. Never available on sdkStart or any API-key or avatarToken path. |
Request
mutation StartSession($input: StartSessionInput!) { startSession(input: $input) { id status agentId clientExternalId }}{ "input": { "agentId": 42, "clientExternalId": "visitor-8841", "metadata": { "source": "dashboard-demo" } }}POST /graphqlContent-Type: application/jsonAuthorization: Bearer <jwt>x-huat-platform: customerResponse
{ "data": { "startSession": { "id": 501, "status": "ACTIVE", "agentId": 42, "clientExternalId": "visitor-8841" } }}sendMessage
Section titled “sendMessage”Sends a user text turn into the Session. Returns immediately — this is an ack, not the AI’s reply. The reply is generated asynchronously: subscribe to sessionEvents (watch for AiResponseEvent) to see it stream live, or re-query session(id) { messages } afterward, since the reply is persisted as a message once generation completes. See Events & Subscriptions.
Auth: JWT
sendMessage(input: SendMessageInput!): SessionDto!SendMessageInput fields
| Field | Type | Required | Notes |
|---|---|---|---|
sessionId | Int! | yes | |
text | String! | yes | Up to 100,000 characters. May be empty only when attachments has at least one entry. |
clientTurnId | String | no | An identifier you supply to correlate a specific reply with the message that produced it — echoed back verbatim on the AiResponseEvent for this turn. Optional. |
attachments | [MessageAttachmentInput!] | no | Up to 10 files for this message: { url, mime, filename, kind }. See Sending files and context. |
turnContext | JSON | no | Per-turn data as a flat object, for example { "trigger": "conversation.opened" }. Available to the workflow as turn.context. |
testDraft | Boolean | no | When true, this turn runs the agent’s workflow’s live-editable draft instead of its published snapshot — no publish required, and the workflow doesn’t even need to have been published yet. Meant for testing in-progress edits from a dedicated test session; omit (or leave false) for any real session — this is not how a real user’s turn should ever be sent. |
draftApprovalOutcome | ApprovalDraftOutcome | no | Draft tests only. APPROVED, REJECTED or EXPIRED: an approval node follows that edge instead of failing, and no real request is created. See Draft tests. |
draftSideEffects | DraftSideEffectMode | no | Draft tests only. MOCK (the default): tool, action, webhook and pdf_export nodes return mock results instead of calling out, and LLM nodes still call the model. LIVE: every node runs for real; workspace owner only. See Testing a Workflow Safely. |
draftMocks | JSON | no | Requires mock mode. Per-node results keyed by node id: { "output": <any JSON> } or { "fail": "<message>" }; at most 50 nodes and 64 KB. Pass it through variables. |
draftMockLlm | Boolean | no | Requires mock mode. true mocks every llm node too (no model call, no charge). Default false. |
Request
mutation SendMessage($input: SendMessageInput!) { sendMessage(input: $input) { id status }}{ "input": { "sessionId": 501, "text": "What's your return policy?" }}Response
{ "data": { "sendMessage": { "id": 501, "status": "ACTIVE" } }}endSession
Section titled “endSession”Ends a Session (sets status ENDED). Triggers async Evaluation generation — poll the evaluation(sessionId) query afterward (see Evaluation & Export), it will be null until that job completes.
Auth: JWT
endSession(sessionId: ID!): SessionDto!Request
mutation EndSession($sessionId: ID!) { endSession(sessionId: $sessionId) { id status endedAt }}{ "sessionId": "501" }Response
{ "data": { "endSession": { "id": 501, "status": "ENDED", "endedAt": "2026-08-09T10:15:00.000Z" } }}interruptSession
Section titled “interruptSession”Interrupts (barges in on) an in-flight AI response for this Session. Unlike most mutations on this page, takes a bare sessionId and returns a Boolean, not the Session object itself.
Auth: JWT or avatarToken — this is the one exception to the JWT/avatarToken split described above. It’s callable from a first-party JWT-authenticated dashboard client (the flow every other mutation on this page uses), and also directly by the <vai-avatar> embed SDK’s own barge-in handling, which only has the avatarToken from sdkStart — there’s no separate sdkInterruptSession mutation the way there’s a separate sdkSendMessage/sdkEndSession pair for the send/end cases. Send either Authorization: Bearer <accessToken> (JWT) or Authorization: Bearer <avatarToken> — both work. When authenticating with an avatarToken, sessionId must match the session that token was issued for (see the session-binding note above) — a JWT holder is unaffected, since a JWT is legitimately tenant-wide.
interruptSession(sessionId: ID!): Boolean!Request
mutation InterruptSession($sessionId: ID!) { interruptSession(sessionId: $sessionId)}{ "sessionId": "501" }Response
{ "data": { "interruptSession": true } }Since 2026-09-28, an interrupt that lands while a response node is already producing its reply also drops that reply. Previously the reply could still arrive after InterruptedEvent. You don’t need interruptSession just because the user sent a new message: a newer message now supersedes an in-flight turn automatically. See When a user sends a second message before the first reply.
Reading sessions
Section titled “Reading sessions”These queries work for either flow’s sessions (they’re the same underlying SessionDto regardless of how the session was started), but require a JWT — the SDK/embed flow does not have a query equivalent, since it authenticates by API key/avatarToken instead.
session
Section titled “session”Fetches a single Session by id, scoped to the caller’s tenant. This is also the only way to retrieve chat history — select the nested messages field; there is no standalone getChatHistory/messages(sessionId) query.
Auth: JWT
session(id: ID!): SessionDtoRequest
query GetSession($id: ID!) { session(id: $id) { id status messages { role text turnIndex } }}{ "id": "501" }Response
{ "data": { "session": { "id": 501, "status": "ENDED", "messages": [ { "role": "USER", "text": "What's your return policy?", "turnIndex": 0 }, { "role": "MODEL", "text": "You can return any item within 30 days of purchase.", "turnIndex": 1 } ] } }}sessions — deprecated
Section titled “sessions — deprecated”Deprecated. Lists every Session for the caller’s tenant (optionally filtered by agentId), newest first, with no pagination — one plain array containing every session the tenant has ever had. This can be a very large response for a tenant with real usage history. Use sessionActivitySummaries instead for any dashboard-style session list.
Auth: JWT
sessions(agentId: Int, includeTests: Boolean = false): [SessionDto!]! @deprecated(reason: "Unpaginated — returns every session the tenant has ever had. Use sessionActivitySummaries instead.")Request
query ListSessions($agentId: Int) { sessions(agentId: $agentId) { id status clientExternalId }}{ "agentId": 42 }Response
{ "data": { "sessions": [ { "id": 501, "status": "ENDED", "clientExternalId": "visitor-8841" } ] }}sessionActivitySummaries
Section titled “sessionActivitySummaries”The recommended replacement for sessions on any dashboard-style session list. A Relay-paginated, newest-first connection — one row per session, each already carrying the agent name, session status, last message time, the latest workflow run’s id/status/error, the session’s evaluation score/recommendation (evaluations are per session, generated asynchronously after the session ends — null until that job completes), and a server-computed needsAttention flag (true when the latest run is FAILED or AWAITING_INPUT). Everything comes back in one request with a fixed number of DB queries regardless of page size — no more calling workflowRuns(sessionId)/evaluation(sessionId) once per session in a loop. It’s a summary only: no messages, node trace, or context — drill into a specific session with session(id) and workflowRuns(sessionId).
Auth: JWT
sessionActivitySummaries( after: String agentId: Int first: Int = 20 includeTests: Boolean = false mine: Boolean search: String sortBy: SessionActivitySortField sortDirection: SessionActivitySortDirection status: SessionStatus): SessionActivitySummaryConnection!-
includeTests: true(workspace owner only; ignored for a member): also lists “Try it” test chats, each row carryingisTest. Defaultfalse: test chats are left out of the rows and oftotalCount. -
Pagination:
first(default 20, capped at 100) /after(a previous page’spageInfo.endCursor) —afterwalks to older sessions, since results are newest first. -
Filters, all combinable:
agentId,status(an exactSessionStatusmatch),search(case-insensitive substring match onclientExternalId;%/_are matched literally, not as SQL wildcards; capped at 200 characters; blank = no filter). -
Sorting:
sortBypicks the order andsortDirection(ASC/DESC) its direction. With neither set, the order is newest first, exactly as before.sortBySorts by CREATED_ATSession creation order (the default). STARTED_ATWhen the session started. ENDED_ATWhen the session ended. STATUSSession status, in status order (pending, active, ended, expired) — not alphabetical. CLIENT_EXTERNAL_IDYour own customer identifier for the session. AGENT_NAMEThe agent’s name. EVALUATION_SCOREThe session’s evaluation score. Rows with no value for the sort field (a session that hasn’t ended, or has no evaluation yet) always come last, in both directions. Ties are broken by session id in the same direction. Last-message time and latest-run status can’t be sorted server-side yet.
-
Cursors belong to a sort. An
aftercursor is only valid with the samesortByandsortDirectionit was issued under. Change the sort (or any filter) and start again from the first page; a cursor used with a different sort returns a400(Invalid \after` cursor`). -
totalCountcounts every session matching the filters, ignoring the cursor — so a per-status tab count is a separatesessionActivitySummaries(status: X, first: 1) { totalCount }call. -
mine: truekeeps only the sessions you started with your dashboard sign-in, that is whosestartedByUserIdis your user id. It combines with every other filter andtotalCounthonours it. Sessions started with an API key, anavatarToken, a channel, a sub-agent or a regression replay have no starting user and are never “mine”. A caller with no user behind the token gets an empty page (totalCount: 0), not an error. Omitted orfalse, the list is every session of the workspace, as before. Each row also carriesstartedByUserId(same meaning as onSessionDto).
Request
query RecentSessions($agentId: Int, $status: SessionStatus, $after: String) { sessionActivitySummaries( agentId: $agentId status: $status first: 20 after: $after ) { totalCount pageInfo { hasNextPage endCursor } edges { node { sessionId agentId agentName sessionStatus lastMessageAt latestRunStatus latestRunErrorMessage needsAttention evaluationScore evaluationRecommendation } } }}Response
{ "data": { "sessionActivitySummaries": { "totalCount": 123, "pageInfo": { "hasNextPage": true, "endCursor": "MjEyMg==" }, "edges": [ { "node": { "sessionId": 3196, "agentId": 4, "agentName": "General Support Assistant", "sessionStatus": "ACTIVE", "lastMessageAt": "2026-09-27T17:53:27.456Z", "latestRunStatus": "COMPLETED", "latestRunErrorMessage": null, "needsAttention": false, "evaluationScore": null, "evaluationRecommendation": null } } ] } }}agentActivitySummary
Section titled “agentActivitySummary”A per-AI-employee outcome roll-up for a supervision console. Where sessionActivitySummaries answers “what happened in each conversation”, this answers “how is each agent doing”: runs by status, recovered failures, average evaluation score, open questions waiting on a person, and schedule health, for the whole tenant or one agent. One request, a fixed number of database queries however many agents you have. Read-only.
Auth: JWT. Not available with an API key.
agentActivitySummary( agentId: Int window: ActivityWindowPreset = LAST_7_DAYS from: DateTime to: DateTime): AgentActivitySummaryDto!These are separate arguments, not an input object.
- Window. Pick a
windowpreset (LAST_24_HOURS,LAST_7_DAYS,LAST_30_DAYS,LAST_90_DAYS,MONTH_TO_DATE; month boundaries are UTC, the same asapiUsageSummary), or send an explicitfromandtotogether. A custom window is half-open (frominclusive,toexclusive),frommust be beforeto,tocannot be in the future, and the span cannot exceed 92 days. Anything else is a400. Whenfromandtoare given,windowis ignored. agentId. Optional. An agent that is not in your tenant returns404.- Point in time vs windowed.
openAwaits,oldestOpenAwaitSince,runsInProgressand theschedules*counts describe now, whatever the window. Everything else is counted inside the window. - What counts. A run is one engine execution, not one conversation step: pausing at an
await_replynode and resuming is two runs.runsInProgressonly counts runs started in the last hour, because a run whose worker died is never resolved and would otherwise be counted forever. Regression-replay runs and deleted sessions are excluded. A run with no agent of its own is attributed to its session’s agent. - Recovered failures.
handledFailureCountcounts node failures that took a labelled failure edge and let the run carry on, across runs of every finished status.runsWithHandledFailurecounts only runs that still finishedCOMPLETED. - Evaluations are counted in the window they were generated in, not when the session started, and every evaluation counts.
needsAttentionistruefor an agent that had a failed run in the window, has a schedule that was switched off after repeated failures, or has an open question older than 24 hours.- Credits are not reported yet.
creditsAttributionisNONE, andcreditsUsedandscheduleFiresare alwaysnull. They are never estimated. - Rows.
agentshas one row per agent, idle agents included, plus deleted agents that had activity in the window.totalsis the sum of those rows. - Cost of the query. Select
totals, oragents { metrics }, with the fields you need. Selecting every field of both can exceed the API’s query-complexity limit. - Rate limit. 30 requests per minute per client IP. See Rate limiting.
Request
query AgentOutcomes($window: ActivityWindowPreset) { agentActivitySummary(window: $window) { windowStart windowEnd totals { runsTotal runsFailed avgEvaluationScore openAwaits } agents { agentId agentName needsAttention metrics { runsCompleted runsFailed handledFailureCount openAwaits oldestOpenAwaitSince avgEvaluationScore schedulesAutoDisabled } } }}{ "window": "LAST_7_DAYS" }Response
{ "data": { "agentActivitySummary": { "windowStart": "2026-09-26T04:00:00.000Z", "windowEnd": "2026-10-03T04:00:00.000Z", "totals": { "runsTotal": 142, "runsFailed": 3, "avgEvaluationScore": 78.4, "openAwaits": 2 }, "agents": [ { "agentId": 4, "agentName": "General Support Assistant", "needsAttention": true, "metrics": { "runsCompleted": 120, "runsFailed": 3, "handledFailureCount": 5, "openAwaits": 2, "oldestOpenAwaitSince": "2026-10-01T22:10:00.000Z", "avgEvaluationScore": 78.4, "schedulesAutoDisabled": 0 } } ] } }}sessionCount
Section titled “sessionCount”Total number of Sessions belonging to the caller’s tenant. “Try it” test chats are not counted unless a workspace owner passes includeTests: true (ignored for a member).
Auth: JWT
sessionCount(includeTests: Boolean = false): Int!Request
query { sessionCount}Response
{ "data": { "sessionCount": 1 } }speechToken
Section titled “speechToken”Returns a credential for the speech routes of the Avatar Service REST API (/stt, /stt/gemini, /stt/google, /tts, /tts/google, /tts/gemini) without starting a session: no sdkStart, no agent, no metered session. Use it when you only want speech-to-text or text-to-speech. See the Speech-to-Text and Text-to-Speech Integration Guide.
Auth: JWT or X-Api-Key
Rate limit: 30 requests per minute per client IP.
mutation { speechToken { token expiresInMs avatarServiceUrl }}{ "data": { "speechToken": { "token": "eyJhbGciOi...redacted", "expiresInMs": 3600000, "avatarServiceUrl": "https://avatar.example.com" } }}| Field | Notes |
|---|---|
token | Send as Authorization: Bearer <token> to the speech routes. Keep it on your server. |
expiresInMs | Validity left. The same token can be returned to repeated calls while it has more than ten minutes left, so this can be less than the full lifetime. |
avatarServiceUrl | The base URL to send the speech requests to. |
Call it when you need a token, not once per clip: reuse the token until expiresInMs runs low. A speech token is tenant-scoped and cannot be revoked individually; it expires after about an hour, and revoking an API key does not cancel tokens already issued. Speech usage is metered against your tenant as before.
avatarToken (deprecated)
Section titled “avatarToken (deprecated)”Deprecated: use speechToken. Returns the same speech token as a bare string, for a dashboard JWT only. Until 2026-10 this query returned a tenant-wide token that stopped working when tokens were bound to sessions (2026-09-08); it now returns the speech token.
Auth: JWT
avatarToken: String!query { avatarToken}sessionsByExternalCustomerId
Section titled “sessionsByExternalCustomerId”Relay-paginated (forward pagination only) list of Sessions matching externalCustomerId.
Auth: API key (X-Api-Key header)
sessionsByExternalCustomerId(externalCustomerId: String!, first: Int, after: String): SessionConnection!Arguments
| Argument | Type | Required | Notes |
|---|---|---|---|
externalCustomerId | String! | yes | Matches clientExternalId/clientId from startSession/sdkStart. |
first | Int | no | Page size, 1 to 100 (default 20). A larger value returns 100 rows; keep paging with pageInfo.hasNextPage / endCursor. A value below 1 returns 1 row. |
after | String | no | Opaque cursor from a previous page’s pageInfo.endCursor. |
SessionConnection fields
| Field | Type | Notes |
|---|---|---|
edges | [SessionEdge!]! | Each edge is { cursor: String!, node: SessionDto! }. |
pageInfo | SessionPageInfo! | { hasNextPage: Boolean!, endCursor: String }. |
totalCount | Int! | Total matching Sessions, independent of pagination. |
Request
query SessionsByCustomer( $externalCustomerId: String! $first: Int $after: String) { sessionsByExternalCustomerId( externalCustomerId: $externalCustomerId first: $first after: $after ) { totalCount pageInfo { hasNextPage endCursor } edges { cursor node { id status createdAt } } }}{ "externalCustomerId": "omni-cust-1", "first": 20 }POST /graphqlContent-Type: application/jsonX-Api-Key: <your-api-key>x-huat-platform: customerResponse
{ "data": { "sessionsByExternalCustomerId": { "totalCount": 2, "pageInfo": { "hasNextPage": false, "endCursor": "Mg==" }, "edges": [ { "cursor": "MQ==", "node": { "id": 501, "status": "ENDED", "createdAt": "2026-08-01T10:00:00.000Z" } }, { "cursor": "Mg==", "node": { "id": 502, "status": "ACTIVE", "createdAt": "2026-08-05T10:00:00.000Z" } } ] } }}To fetch the next page, pass after: "Mg==" (the previous page’s endCursor).
customerUsageSummary
Section titled “customerUsageSummary”Rolled-up token/cost usage across every Session belonging to externalCustomerId — a single aggregate, not a page of rows.
Auth: API key (X-Api-Key header)
customerUsageSummary(externalCustomerId: String!): CustomerUsageSummaryDto!CustomerUsageSummaryDto fields
| Field | Type | Notes |
|---|---|---|
externalCustomerId | String! | Echoes the argument back. |
sessionCount | Int! | Number of Sessions matching this customer. |
tokensIn | Int! | Sum of every Message’s tokensIn across all matching Sessions. |
tokensOut | Int! | Sum of every Message’s tokensOut across all matching Sessions. |
costUsd | Float! | Sum of every Message’s costUsd across all matching Sessions. Estimate, not billing-exact — see MessageDto.costUsd above. |
Request
query CustomerUsage($externalCustomerId: String!) { customerUsageSummary(externalCustomerId: $externalCustomerId) { externalCustomerId sessionCount tokensIn tokensOut costUsd }}{ "externalCustomerId": "omni-cust-1" }Response
{ "data": { "customerUsageSummary": { "externalCustomerId": "omni-cust-1", "sessionCount": 2, "tokensIn": 1200, "tokensOut": 800, "costUsd": 0.00042 } }}SDK/embed flow
Section titled “SDK/embed flow”These operations are the <vai-avatar> embed SDK’s equivalents of startSession/sendMessage/endSession, plus refreshAvatarToken for renewing a session token that’s about to expire. sdkStart authenticates via an X-Api-Key header (see Authentication) — not a JWT — and must be called server-side, since the API key must never reach the browser. It returns a session-scoped avatarToken; sdkSendMessage and sdkEndSession then authenticate using that token instead of a JWT or API key.
This is the flow most runtime integrators use: your backend calls sdkStart with your API key, then hands the returned sessionId/avatarToken/config attributes to the browser-side <vai-avatar> component (or your own client), which uses them directly.
sdkStart
Section titled “sdkStart”Entry point for the <vai-avatar> embed SDK. Starts a Session and returns everything the SDK web component needs as attributes: sessionId, avatarToken (a separate HMAC token for the avatar service, not this API), and voice/avatar config.
Auth: API key (X-Api-Key header) — see Authentication. Rate-limited (as of 2026-09-08) — repeated calls from the same source IP are throttled.
An agent still being set up refuses to start. An agent that Wetel set up for you from a role template stays off until its setup checklist is finished (see agentTemplateInstall). While it is, sdkStart for that agent fails with a Forbidden error saying it is still being set up, and so does connecting a channel to it. Agents you created yourself are never affected.
sdkStart(input: SdkStartInput!): SdkStartResult!SdkStartInput fields (all optional — supply either agentId or the inline persona fields)
| Field | Type | Notes |
|---|---|---|
agentId | Int | Use an existing Agent’s configuration. |
agentName | String | Inline persona name, if not using agentId. |
personaPrompt | String | Inline persona prompt, if not using agentId. |
position | String | Inline persona position label. |
useCase | AgentUseCase | Inline persona use case. |
voiceTier | VoiceTier | Inline persona voice tier. |
clientId | String | Caller-supplied external identifier, analogous to clientExternalId in the dashboard flow. |
context | JSON | Optional per-session data as a flat object, for example { "accountId": "acct-1", "channelName": "telegram" }. Send it through variables. Stored on the session and available to every workflow turn as session.context. Same rules as the turn context: scalars only, up to 32 keys, strings up to 4,096 characters, 8 KB in total, and the runner’s reserved names (session, channel, attachments, turn, …) are refused. Never put secrets in it. |
SdkStartResult fields
| Field | Type | Notes |
|---|---|---|
sessionId | Int! | |
agentId | Int! | |
avatarToken | String! | Session-scoped — see the callout above. Pass this to sdkSendMessage/sdkEndSession and to the avatar service. |
avatarServiceUrl | String! | Base URL of the avatar/TTS/STT service to connect the SDK component to. |
graphqlEndpoint | String! | GraphQL endpoint to use for the rest of the SDK flow. |
avatarGlb | String! | |
avatarGender | String! | |
ttsVoice | String! | |
ttsLang | String! | |
sttLanguageCode | String! | STT primary language, already resolved server-side as the agent’s sttLanguageCode, else its ttsLang, else en-US. Never null. Pass it to <vai-avatar> as stt-lang. live in production since 2026-10-01. |
sttAlternativeLanguageCodes | [String!] | STT fallback languages exactly as stored on the agent. null = platform default (leave stt-alternative-langs unset), [] = primary only (set it to ""), a list = comma-join it. live in production since 2026-10-01. |
lipsyncLang | String! | |
speakingRate | Float! | |
vadThreshold | Float! |
The two stt* fields are also returned by the dashboard-authenticated startAvatarDemoSession, which has the same result shape. Set them per agent with sttLanguageCode / sttAlternativeLanguageCodes.
Request
mutation SdkStart($input: SdkStartInput!) { sdkStart(input: $input) { sessionId avatarToken avatarServiceUrl graphqlEndpoint avatarGlb ttsVoice }}{ "input": { "agentId": 42, "clientId": "visitor-8841" }}POST /graphqlContent-Type: application/jsonX-Api-Key: <your-api-key>x-huat-platform: customerResponse
{ "data": { "sdkStart": { "sessionId": 502, "avatarToken": "eyJhbGciOi...redacted", "avatarServiceUrl": "<speech service URL for this environment>", "graphqlEndpoint": "https://api.wetel.dev/graphql", "avatarGlb": "avatar-01.glb", "ttsVoice": "en-US-Standard-C" } }}refreshAvatarToken
Section titled “refreshAvatarToken”Exchanges an avatarToken that is about to expire — or expired within the last 10 minutes — for a fresh, full-TTL one on the same session, so a conversation can outlive the ~1 hour avatarToken lifetime without starting over. Added 2026-09-14.
Auth: the presented token itself, passed as input.avatarToken — not as an Authorization: Bearer header, and no API key or JWT. This is the one avatarToken-authenticated operation that must accept an already-expired token, which AvatarTokenGuard (the header-based path every other SDK mutation uses) rejects outright. The signature is still verified in full; only the expiry bound is relaxed, and only within the grace window. Rate-limited to 10 requests per 60 seconds per client IP.
refreshAvatarToken(input: RefreshAvatarTokenInput!): RefreshAvatarTokenResult!RefreshAvatarTokenInput fields
| Field | Type | Required | Notes |
|---|---|---|---|
avatarToken | String! | yes | The current token — the one whose expiry is imminent or has just passed. Max 4,096 characters. |
RefreshAvatarTokenResult fields
| Field | Type | Notes |
|---|---|---|
sessionId | Int! | Read back from the old token’s own signed session binding, never from client input — this mutation cannot move a token onto a different session. |
avatarToken | String! | The replacement token. Same session, same tenant, same avatar backend config as the one you presented. |
expiresInMs | Int! | Lifetime of the new token, so you can schedule the next refresh without hardcoding this deployment’s TTL. |
Request
mutation RefreshAvatarToken($input: RefreshAvatarTokenInput!) { refreshAvatarToken(input: $input) { sessionId avatarToken expiresInMs }}{ "input": { "avatarToken": "eyJhbGciOi...current-or-just-expired" }}POST /graphqlContent-Type: application/jsonx-huat-platform: customerResponse
{ "data": { "refreshAvatarToken": { "sessionId": 502, "avatarToken": "eyJhbGciOi...fresh", "expiresInMs": 3600000 } }}Grace window. You don’t need to refresh pre-emptively — a token that expired within the last 10 minutes is still accepted (AVATAR_TOKEN_REFRESH_GRACE_MS, configurable per deployment), so the reactive pattern works: call, get Invalid or expired avatar token, refresh, retry. Past that window there’s nothing to refresh into and you need a fresh sdkStart.
Errors
| Error | Cause |
|---|---|
Invalid or expired avatar token (401) | Bad signature, malformed token, expired beyond the grace window, or a session that no longer exists. Deliberately one indistinguishable message — a caller never learns which. |
Session <id> is not active (status: <...>) (400) | The token verified, but its session has already ended. Start a new session. |
sdkSendMessage
Section titled “sdkSendMessage”The <vai-avatar> SDK’s equivalent of sendMessage — takes the avatarToken from sdkStart rather than a JWT. Same async-reply semantics: this returns immediately, the AI’s reply arrives via the sessionEvents subscription.
Auth: avatarToken (session-scoped, from sdkStart, via AvatarTokenGuard) — input.sessionId must match the session the token was minted for.
sdkSendMessage(input: SdkSendMessageInput!): Boolean!SdkSendMessageInput fields
| Field | Type | Required | Notes |
|---|---|---|---|
sessionId | Int! | yes | |
text | String! | yes | |
clientTurnId | String | no | An identifier you supply to correlate a specific reply with the message that produced it — echoed back verbatim on the AiResponseEvent for this turn (see Events & Subscriptions). Purely optional passthrough; omit it if you don’t need per-turn correlation (e.g. single-turn-at-a-time usage, where the next sessionEvents reply is unambiguously the answer to the message you just sent). |
Request
mutation SdkSendMessage($input: SdkSendMessageInput!) { sdkSendMessage(input: $input)}{ "input": { "sessionId": 502, "text": "What's your return policy?", "clientTurnId": "turn-8841-01" }}POST /graphqlContent-Type: application/jsonAuthorization: Bearer <avatarToken-from-sdkStart>x-huat-platform: customerResponse
{ "data": { "sdkSendMessage": true } }Sending again before the reply arrives: since 2026-09-28, a message sent while the previous turn is still in progress supersedes that turn. The earlier message gets no reply, and only the newer one is answered. The exception is a turn that has already sent its first reply, which runs to completion. If you correlate replies by clientTurnId, don’t wait forever on a superseded one. This applies to sendMessage too. See When a user sends a second message before the first reply.
Sending files and context
Section titled “Sending files and context”Since 2026-10-08 you can pass files and data as fields instead of writing them into the message text. Both are optional and additive: a client that sends only text behaves exactly as before, and the older [[WETEL_ATTACHMENT ...]] and [image:<url>] text markers keep working.
{ "input": { "sessionId": 502, "text": "Here is my receipt", "attachments": [ { "url": "https://files.example.com/receipt.png", "kind": "IMAGE" }, { "url": "https://files.example.com/terms.pdf", "filename": "terms.pdf" } ], "turnContext": { "trigger": "conversation.opened", "plan": "pro" } }}MessageAttachmentInput
| Field | Type | Notes |
|---|---|---|
url | String! | Absolute http or https URL, at most 2,048 characters, no user:password@. It is not fetched when you send it; a workflow node fetches it later and the fetch rules apply (private addresses refused, the workspace’s allowed hosts). |
mime | String | At most 100 characters. Inferred from the filename or the URL’s file extension when absent. |
filename | String | At most 255 characters; quotes, brackets and control characters are removed. |
kind | AttachmentKind | IMAGE, DOCUMENT, AUDIO, VIDEO or OTHER. Inferred from the mime when absent. |
Send kind: IMAGE (or a mime) for an image URL that has no file extension, for example https://placehold.co/600x400/png: without either, it is classed as OTHER and left out of the image list below.
What a workflow sees
| Context key | Holds |
|---|---|
attachments | [{ url, mime, filename, kind }], [] when none. mime and filename are empty strings when unknown. |
attachmentImageUrls | The IMAGE URLs, in order. Name it as an llm node’s imageUrlsVar to read every image with no extra node. |
attachmentUrl, attachmentMime, attachmentFilename | From the first attachment (empty strings when none), for graphs written before this existed. |
attachment | { url, mime, filename } of the first attachment (null fields when unknown). |
turn.context | Your turnContext, {} when none. |
channel.trigger | Set from turnContext.trigger when it is a non-blank string. |
session.context | The context you gave sdkStart, {} when none. |
Precedence: when attachments is present, it replaces any attachment written as a text marker. With none, a marker in the text is parsed as before. The message text is never altered.
Limits and refusals (BAD_REQUEST, and none of them echoes the URL back): at most 10 attachments; empty text is refused unless an attachment is present; context and turnContext allow scalars only (no nested objects or arrays), at most 32 keys, strings up to 4,096 characters, 8 KB in total, and a key that the workflow runner uses is refused (session, channel, userMessage, attachments, attachmentImageUrls, turn, and the other reserved names), so do not name your own data like that. Do not put secrets in either field. Reserved: because attachments, attachmentImageUrls and turn are now runner names, a workflow node cannot use them as an outputVar or saveAs.
The dashboard sendMessage mutation accepts the same attachments and turnContext, including with testDraft: true.
Stored with the message, and read back redacted. The attachments (and your turnContext) are saved with the message. The message’s attachments field (url, mime, filename, kind; null when the message had none) returns the URL with its query string, fragment and credentials removed: https://cdn.example.com/p/a.png?[redacted]. The stored URL is untouched, so a workflow node can still fetch a signed link. turnContext is never returned on a message. The same redaction applies to the URL fields in a run’s context (workflowRuns: attachments[].url, attachmentUrl, attachmentImageUrls); the values of turnContext and of the sdkStart context appear in a run’s context as turn.context and session.context for the workspace’s own dashboard users, which is why they must never hold secrets (a credential-looking key name or value is masked in session.context, but do not rely on that).
Earlier images stay visible to later turns. An llm node with imageMarkerHistory reads the IMAGE attachments of earlier user messages (and of the current one) as well as [image:<url>] text markers, newest first, within the usual image limits. A user can send a picture in one message and ask about it in the next.
sdkEndSession
Section titled “sdkEndSession”The <vai-avatar> SDK’s equivalent of endSession — takes the avatarToken from sdkStart rather than a JWT. Ends the session and evaluates it before returning, so the evaluation is in the response. It uses the same prompt as the dashboard’s endSession evaluation, including a CUSTOM agent’s own template and labels.
Auth: avatarToken (session-scoped, from sdkStart) — input.sessionId must match the session the token was minted for.
sdkEndSession(input: SdkEndSessionInput!): SdkEndSessionResult!SdkEndSessionInput fields
| Field | Type | Required |
|---|---|---|
sessionId | Int! | yes |
SdkEndSessionResult fields
| Field | Type | Notes |
|---|---|---|
sessionId | Int! | |
durationSeconds | Int! | |
evaluation | SdkEvaluationResult | Computed before the call returns. null when the conversation has fewer than two messages or the evaluation failed (the session is still ended). Calling sdkEndSession again on an ended session returns the stored evaluation. |
SdkEvaluationResult: { score: Int!, recommendation: String!, summary: String!, strengths: [String!]!, weaknesses: [String!]! }. recommendation is one of the use case’s labels; for a CUSTOM agent, one of its evaluationRecommendationLabels (good, fair, poor when it has no evaluationPromptTemplate).
Request
mutation SdkEndSession($input: SdkEndSessionInput!) { sdkEndSession(input: $input) { sessionId durationSeconds evaluation { score recommendation summary } }}{ "input": { "sessionId": 502 } }Response
{ "data": { "sdkEndSession": { "sessionId": 502, "durationSeconds": 184, "evaluation": null } }}sdkVerifyWebbyxOneIdentity
Section titled “sdkVerifyWebbyxOneIdentity”Exchanges a single-use SSO ticket for the caller’s verified WebbyX One (IAM One) identity, and stores it on the Session so subsequent workflow turns can branch on it. WebbyX One has no separately-hosted login page for this flow — your own frontend hosts the email/password form and calls WebbyX One’s POST /sso/login directly with those credentials (client-side, safe — this call needs only the public X-Client-Id, no secret). That call returns a redirect_url containing a ticket query parameter; extract the ticket from that URL (there’s no need to actually navigate the browser there unless your own callback route depends on it) and pass it to this mutation. The actual token exchange (which needs a client secret) happens server-side on Wetel’s end — never in your browser code.
Once verified, a workflow’s router/condition node config can reference the identity via {{session.webbyxOneIdentity.companyIds}}, {{session.webbyxOneIdentity.email}}, etc.
Auth: avatarToken (session-scoped, from sdkStart, via AvatarTokenGuard) — input.sessionId must match the session the token was minted for.
sdkVerifyWebbyxOneIdentity(input: SdkVerifyWebbyxOneIdentityInput!): SdkWebbyxOneIdentityResult!SdkVerifyWebbyxOneIdentityInput fields
| Field | Type | Required | Notes |
|---|---|---|---|
sessionId | Int! | yes | |
ticket | String! | yes | Short-lived (5 minutes), single-use — request a fresh one from /sso/login if this fails. |
productTickets | [SdkWebbyxOneProductTicketInput!] | no | Optional, additive. Extra tickets that let a workflow authenticate against sibling WebbyX-group products as this same signed-in caller, instead of a shared service-account credential. See the note below. |
SdkWebbyxOneProductTicketInput fields
| Field | Type | Required | Notes |
|---|---|---|---|
product | SdkSessionIdentityProduct! | yes | AISCRM | OMNICHAT today — see webbyxOneProductClientIds for the current, backend-served list. |
ticket | String! | yes | A SECOND, independently-minted ticket, scoped to that product’s own X-Client-Id — not the same value as the top-level ticket field. |
SdkWebbyxOneIdentityResult fields
| Field | Type | Notes |
|---|---|---|
email | String! | |
companyIds | [String!]! | |
scopes | [String!]! | WebbyX One-defined scopes for this identity. |
linkedProducts | [SdkLinkedProduct!]! | One entry per submitted productTickets item, in request order. Empty if you didn’t send any — fully additive, existing callers see no change. |
SdkLinkedProduct fields
| Field | Type | Notes |
|---|---|---|
product | SdkSessionIdentityProduct! | |
linked | Boolean! | false if the ticket couldn’t be redeemed for a downstream session at all (e.g. the product rejected it). true with an empty organizations means the identity was recognised but has no grant on that product. |
organizations | [String!]! | Human-readable organization names this identity can act through on this product. Two or more means the agent will ask which one to use the first time an action needs it — no extra call needed on your end. |
Request
mutation SdkVerifyWebbyxOneIdentity($input: SdkVerifyWebbyxOneIdentityInput!) { sdkVerifyWebbyxOneIdentity(input: $input) { email companyIds scopes linkedProducts { product linked organizations } }}{ "input": { "sessionId": 502, "ticket": "tk_a1b2c3...", "productTickets": [{ "product": "OMNICHAT", "ticket": "tk_d4e5f6..." }] }}Response
{ "data": { "sdkVerifyWebbyxOneIdentity": { "companyIds": ["13"], "scopes": ["portal"], "linkedProducts": [ { "product": "OMNICHAT", "linked": true, "organizations": ["Acme Sdn Bhd"] } ] } }}Throws if the ticket is invalid, expired, or already used. An invalid/expired entry in productTickets does not fail the whole call — it just comes back with linked: false for that product.
webbyxOneProductClientIds
Section titled “webbyxOneProductClientIds”The public WebbyX One X-Client-Id for every sibling product a signed-in identity can link credentials for via productTickets above. Your sign-in widget calls this first to know which extra /sso/login calls to make in the same submit as the main sign-in — served from the backend rather than hardcoded per-integration, so the product list has exactly one source of truth and can grow without a docs or client update. Returns no secrets — /sso/login takes no client_secret.
Auth: avatarToken (session-scoped, from sdkStart) OR a dashboard JWT — either is accepted, since both a dashboard tool and an embed sign-in widget legitimately need this.
webbyxOneProductClientIds: [SdkWebbyxOneProductClientId!]!SdkWebbyxOneProductClientId fields
| Field | Type | Notes |
|---|---|---|
product | SdkSessionIdentityProduct! | AISCRM | OMNICHAT. |
clientId | String! | The public X-Client-Id to mint that product’s ticket under, via WebbyX One’s own /sso/login. |
Request
query WebbyxOneProductClientIds { webbyxOneProductClientIds { product clientId }}Response
{ "data": { "webbyxOneProductClientIds": [ { "product": "AISCRM", "clientId": "019f..." }, { "product": "OMNICHAT", "clientId": "019f..." } ] }}See also
Section titled “See also”- Core API Flow — the end-to-end narrative walkthrough of both flows
- Agents — creating and configuring the Agent a Session runs against
- Events & Subscriptions — how AI replies and other live session events actually arrive
- Workflows — attaching a visual conversation graph to an Agent
- Authentication — JWT, API key, and avatarToken details
- Evaluation & Export — reading the async post-session Evaluation
- API Reference: Overview