Skip to content

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 + avatarToken auth, 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:

  1. The speech token (the speechToken mutation, and the older, deprecated avatarToken query that returns the same thing) — a token scoped to the caller’s tenant, with no session, for the speech routes of the avatar service (/stt and /tts) and nothing else. It cannot send messages, read events or refresh itself.
  2. The avatarToken field returned by sdkStart — 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, via AvatarTokenGuard. 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.


Returned by session, sessions, startSession, sendMessage, and endSession:

FieldTypeNotes
idInt!
agentIdFloat!
agentAgentDtoNested Agent — see Agents.
statusSessionStatus!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.
clientExternalIdString!Caller-supplied external identifier, echoed back.
metadataJSONCaller-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 { ... } }.
interruptCountInt!Number of times interruptSession has fired for this session.
totalTokensInContextInt!Running LLM context-window token count.
startedAtDateTime
endedAtDateTime
lastEventAtDateTime
isTestBoolean!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.
startedByUserIdIntId 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.
createdAtDateTime!
updatedAtDateTime!

MessageDto fields (returned inside messages):

FieldTypeNotes
idInt!
sessionIdFloat!
roleMessageRole!USER | MODEL.
textString!
turnIndexFloat!Ordering index within the session.
tokensInFloat!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.
tokensOutFloat!Estimated output tokens for this message. Same estimate caveat as tokensIn.
costUsdFloat!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.
structuredContentJSONOptional 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.
createdAtDateTime!
updatedAtDateTime!
enum SessionStatus {
ACTIVE
ENDED
EXPIRED
PENDING
}
enum MessageRole {
MODEL
USER
}

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.

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

FieldTypeRequiredNotes
agentIdInt!yesMust be an Agent owned by the caller’s tenant.
clientExternalIdString!yesAn identifier you control (e.g. a visitor or ticket id) — echoed back on the Session, useful for correlating sessions with your own records.
metadataJSONnoArbitrary caller-supplied data, echoed back on the Session.
isTestBooleannoMarks 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 /graphql
Content-Type: application/json
Authorization: Bearer <jwt>
x-huat-platform: customer

Response

{
"data": {
"startSession": {
"id": 501,
"status": "ACTIVE",
"agentId": 42,
"clientExternalId": "visitor-8841"
}
}
}

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

FieldTypeRequiredNotes
sessionIdInt!yes
textString!yesUp to 100,000 characters. May be empty only when attachments has at least one entry.
clientTurnIdStringnoAn 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!]noUp to 10 files for this message: { url, mime, filename, kind }. See Sending files and context.
turnContextJSONnoPer-turn data as a flat object, for example { "trigger": "conversation.opened" }. Available to the workflow as turn.context.
testDraftBooleannoWhen 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.
draftApprovalOutcomeApprovalDraftOutcomenoDraft tests only. APPROVED, REJECTED or EXPIRED: an approval node follows that edge instead of failing, and no real request is created. See Draft tests.
draftSideEffectsDraftSideEffectModenoDraft 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.
draftMocksJSONnoRequires 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.
draftMockLlmBooleannoRequires 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"
}
}
}

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"
}
}
}

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.


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.

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!): SessionDto

Request

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
}
]
}
}
}

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" }
]
}
}

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 carrying isTest. Default false: test chats are left out of the rows and of totalCount.

  • Pagination: first (default 20, capped at 100) / after (a previous page’s pageInfo.endCursor) — after walks to older sessions, since results are newest first.

  • Filters, all combinable: agentId, status (an exact SessionStatus match), search (case-insensitive substring match on clientExternalId; %/_ are matched literally, not as SQL wildcards; capped at 200 characters; blank = no filter).

  • Sorting: sortBy picks the order and sortDirection (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 after cursor is only valid with the same sortBy and sortDirection it was issued under. Change the sort (or any filter) and start again from the first page; a cursor used with a different sort returns a 400 (Invalid \after` cursor`).

  • totalCount counts every session matching the filters, ignoring the cursor — so a per-status tab count is a separate sessionActivitySummaries(status: X, first: 1) { totalCount } call.

  • mine: true keeps only the sessions you started with your dashboard sign-in, that is whose startedByUserId is your user id. It combines with every other filter and totalCount honours it. Sessions started with an API key, an avatarToken, 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 or false, the list is every session of the workspace, as before. Each row also carries startedByUserId (same meaning as on SessionDto).

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
}
}
]
}
}
}

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 window preset (LAST_24_HOURS, LAST_7_DAYS, LAST_30_DAYS, LAST_90_DAYS, MONTH_TO_DATE; month boundaries are UTC, the same as apiUsageSummary), or send an explicit from and to together. A custom window is half-open (from inclusive, to exclusive), from must be before to, to cannot be in the future, and the span cannot exceed 92 days. Anything else is a 400. When from and to are given, window is ignored.
  • agentId. Optional. An agent that is not in your tenant returns 404.
  • Point in time vs windowed. openAwaits, oldestOpenAwaitSince, runsInProgress and the schedules* 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_reply node and resuming is two runs. runsInProgress only 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. handledFailureCount counts node failures that took a labelled failure edge and let the run carry on, across runs of every finished status. runsWithHandledFailure counts only runs that still finished COMPLETED.
  • Evaluations are counted in the window they were generated in, not when the session started, and every evaluation counts.
  • needsAttention is true for 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. creditsAttribution is NONE, and creditsUsed and scheduleFires are always null. They are never estimated.
  • Rows. agents has one row per agent, idle agents included, plus deleted agents that had activity in the window. totals is the sum of those rows.
  • Cost of the query. Select totals, or agents { 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
}
}
]
}
}
}

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 } }

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"
}
}
}
FieldNotes
tokenSend as Authorization: Bearer <token> to the speech routes. Keep it on your server.
expiresInMsValidity 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.
avatarServiceUrlThe 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.

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
}

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

ArgumentTypeRequiredNotes
externalCustomerIdString!yesMatches clientExternalId/clientId from startSession/sdkStart.
firstIntnoPage 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.
afterStringnoOpaque cursor from a previous page’s pageInfo.endCursor.

SessionConnection fields

FieldTypeNotes
edges[SessionEdge!]!Each edge is { cursor: String!, node: SessionDto! }.
pageInfoSessionPageInfo!{ hasNextPage: Boolean!, endCursor: String }.
totalCountInt!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 /graphql
Content-Type: application/json
X-Api-Key: <your-api-key>
x-huat-platform: customer

Response

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

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

FieldTypeNotes
externalCustomerIdString!Echoes the argument back.
sessionCountInt!Number of Sessions matching this customer.
tokensInInt!Sum of every Message’s tokensIn across all matching Sessions.
tokensOutInt!Sum of every Message’s tokensOut across all matching Sessions.
costUsdFloat!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
}
}
}

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.

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)

FieldTypeNotes
agentIdIntUse an existing Agent’s configuration.
agentNameStringInline persona name, if not using agentId.
personaPromptStringInline persona prompt, if not using agentId.
positionStringInline persona position label.
useCaseAgentUseCaseInline persona use case.
voiceTierVoiceTierInline persona voice tier.
clientIdStringCaller-supplied external identifier, analogous to clientExternalId in the dashboard flow.
contextJSONOptional 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

FieldTypeNotes
sessionIdInt!
agentIdInt!
avatarTokenString!Session-scoped — see the callout above. Pass this to sdkSendMessage/sdkEndSession and to the avatar service.
avatarServiceUrlString!Base URL of the avatar/TTS/STT service to connect the SDK component to.
graphqlEndpointString!GraphQL endpoint to use for the rest of the SDK flow.
avatarGlbString!
avatarGenderString!
ttsVoiceString!
ttsLangString!
sttLanguageCodeString!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.
lipsyncLangString!
speakingRateFloat!
vadThresholdFloat!

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 /graphql
Content-Type: application/json
X-Api-Key: <your-api-key>
x-huat-platform: customer

Response

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

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

FieldTypeRequiredNotes
avatarTokenString!yesThe current token — the one whose expiry is imminent or has just passed. Max 4,096 characters.

RefreshAvatarTokenResult fields

FieldTypeNotes
sessionIdInt!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.
avatarTokenString!The replacement token. Same session, same tenant, same avatar backend config as the one you presented.
expiresInMsInt!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 /graphql
Content-Type: application/json
x-huat-platform: customer

Response

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

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

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

FieldTypeRequiredNotes
sessionIdInt!yes
textString!yes
clientTurnIdStringnoAn 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 /graphql
Content-Type: application/json
Authorization: Bearer <avatarToken-from-sdkStart>
x-huat-platform: customer

Response

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

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

FieldTypeNotes
urlString!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).
mimeStringAt most 100 characters. Inferred from the filename or the URL’s file extension when absent.
filenameStringAt most 255 characters; quotes, brackets and control characters are removed.
kindAttachmentKindIMAGE, 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 keyHolds
attachments[{ url, mime, filename, kind }], [] when none. mime and filename are empty strings when unknown.
attachmentImageUrlsThe IMAGE URLs, in order. Name it as an llm node’s imageUrlsVar to read every image with no extra node.
attachmentUrl, attachmentMime, attachmentFilenameFrom 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.contextYour turnContext, {} when none.
channel.triggerSet from turnContext.trigger when it is a non-blank string.
session.contextThe 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.

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

FieldTypeRequired
sessionIdInt!yes

SdkEndSessionResult fields

FieldTypeNotes
sessionIdInt!
durationSecondsInt!
evaluationSdkEvaluationResultComputed 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
}
}
}

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

FieldTypeRequiredNotes
sessionIdInt!yes
ticketString!yesShort-lived (5 minutes), single-use — request a fresh one from /sso/login if this fails.
productTickets[SdkWebbyxOneProductTicketInput!]noOptional, 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

FieldTypeRequiredNotes
productSdkSessionIdentityProduct!yesAISCRM | OMNICHAT today — see webbyxOneProductClientIds for the current, backend-served list.
ticketString!yesA 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

FieldTypeNotes
emailString!
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

FieldTypeNotes
productSdkSessionIdentityProduct!
linkedBoolean!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": {
"email": "[email protected]",
"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.


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

FieldTypeNotes
productSdkSessionIdentityProduct!AISCRM | OMNICHAT.
clientIdString!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..." }
]
}
}