Skip to content

Events & Subscriptions

Wetel sends everything that happens during a live session — the agent’s reply, workflow progress, session lifecycle changes — over a single GraphQL subscription. Mutations like sdkSendMessage only ever return an immediate acknowledgement; the actual content always arrives through this subscription. If you haven’t read Core API Flow yet, start there for the full session lifecycle — this page focuses specifically on the subscription channel.

Subscriptions use the graphql-transport-ws protocol (the graphql-ws library), connecting to the same host as your GraphQL HTTP endpoint, just over wss:// instead of https://:

wss://api.wetel.dev/graphql

Send a connection_init message with both required values inside payload — not as HTTP headers, since a WebSocket upgrade doesn’t carry your usual headers through to the application layer:

{
"type": "connection_init",
"payload": {
"x-huat-platform": "customer",
"Authorization": "Bearer <avatarToken>"
}
}

Both fields are required. Missing x-huat-platform gets you rejected as “platform not specified”; missing or invalid Authorization gets the connection closed as unauthorized. On success, you’ll receive a connection_ack message back.

Once acknowledged, open the subscription for your session:

subscription SessionEvents($sessionId: ID!) {
sessionEvents(sessionId: $sessionId) {
__typename
... on AiResponseEvent {
text
mood
isFinal
turnComplete
clientTurnId
}
... on NodeExecutingEvent {
workflowRunId
nodeId
nodeType
}
... on NodeFailedEvent {
workflowRunId
nodeId
nodeType
error
}
... on AvatarSpeakEvent {
text
mood
}
... on SessionWillEndEvent {
type
}
... on SessionEndedEvent {
durationSeconds
}
... on InterruptedEvent {
sessionId
timestamp
}
}
}

sessionEvents returns a GraphQL union. These are the only event types that exist — there are no others:

EventFieldsMeaning
AiResponseEventtext, mood, isFinal, turnComplete, clientTurnIdThe agent’s text reply. Responses stream as chunks — isFinal: false means more is coming, isFinal: true means this specific chunk is complete. Concatenate chunks and render the full text once isFinal: true arrives. Use turnComplete, not isFinal, to know when the whole conversational turn is over — see the callout below. clientTurnId echoes back whatever you supplied on the sdkSendMessage/sendMessage input that triggered this reply (see Sessions) — null if you didn’t supply one, or for a reply with no originating turn (e.g. an opening greeting). Useful for matching a specific reply to the message that produced it if you send multiple messages before waiting for a response.
NodeExecutingEventworkflowRunId, nodeId, nodeTypeA workflow node is starting execution (for example, a tool call). Use this to show a transient “process chip” — e.g. a badge reading “Checking availability…” — that persists until the next event.
NodeFailedEventworkflowRunId, nodeId, nodeType, errorA node failed (tool timeout, upstream API error, etc). Surface this as an error state; optionally offer a retry or an escalation path.
AvatarSpeakEventtext, moodVoice/3D-avatar integrations only. Text-only chat integrations can ignore this entirely.
SessionWillEndEventtypeNon-terminal. The agent is about to ask for confirmation before ending the session (for example, “Shall I book this room?”). The session stays active and the user can still respond — do not treat this as session end.
SessionEndedEventdurationSecondsTerminal. The session has actually ended. The subscription closes shortly after this fires. durationSeconds is useful for analytics.
InterruptedEventsessionId, timestampVoice/barge-in only — fires when a user interrupts the agent mid-speech. Text-only integrations can ignore this.

isFinal vs turnComplete — these answer different questions

Section titled “isFinal vs turnComplete — these answer different questions”

isFinal and turnComplete on AiResponseEvent look similar but answer different questions, and conflating them was a real integration blocker:

  • isFinal — is this specific chunk complete? For the direct (non-workflow) streaming path this is only true on the trailing, empty-text marker chunk of a response. For a workflow reply (an agent whose replies come from a response node in a Workflow), every individual response-node message is isFinal: true on its own — it’s never streamed sentence-by-sentence the way a direct LLM reply is.
  • turnComplete — is the entire conversational turn over, i.e. no further AiResponseEvents are coming until you send the next message? This is the field to watch if you need to know “has the agent finished replying to what I just said.”

The distinction only matters for workflow-driven agents that speak multiple lines in a single turn (e.g. a workflow with two response nodes in sequence — “Let me check that for you.” followed later by the actual answer). Each of those two messages arrives as its own event with isFinal: true, because each is a complete, non-streamed message on its own — but only the second one has turnComplete: true. If you were watching isFinal to decide “the agent is done talking,” you’d stop listening after the first line and never see the real answer.

For a direct (non-workflow) agent, or a workflow that only ever speaks one line per turn, isFinal and turnComplete coincide and this distinction is invisible — but always prefer turnComplete for “is the agent done” logic, since it’s correct in both cases.

Critical gotcha: union fragments silently drop event types you didn’t ask for

Section titled “Critical gotcha: union fragments silently drop event types you didn’t ask for”

GraphQL unions require you to explicitly select fields for every concrete type you care about, using an inline fragment: ... on TypeName { field1 field2 }. If your subscription query omits a fragment for a given event type, the server does not error, does not warn — it just never sends that event. The payload is silently discarded before it reaches your client.

This is standard GraphQL union behavior, not a bug, but it’s extremely easy to trip over in practice:

  • If your query omits ... on NodeExecutingEvent { ... }, you will never see workflow-progress events — even though the server is emitting them on every run. Your UI will show a bare loading spinner where a richer “process chip” UX was possible, and nothing in your logs will tell you why.
  • If your query omits ... on SessionWillEndEvent { ... }, a confirmation step the agent is trying to surface (e.g. “confirm this booking?”) will vanish, and your UI will look like it’s simply not responding to that turn.
  • Adding support for a new event type later means updating every subscription query in your codebase that reads from sessionEvents — not just one central place.

The fix: always select every event type listed in the table above with its own inline fragment, even ones you think you don’t need yet, unless you’ve deliberately decided to ignore that event class (e.g. AvatarSpeakEvent/InterruptedEvent for a text-only integration). When debugging “the agent isn’t responding to X,” check your subscription’s fragment list before looking anywhere else.

Common gotcha: sending your first message too soon after subscribing

Section titled “Common gotcha: sending your first message too soon after subscribing”

There is no acknowledgement sent back to you specifically confirming that your subscribe frame has been fully registered server-side and is ready to receive events — connection_ack only confirms the WebSocket connection itself, not that a specific subscription is wired up and listening.

In practice, this creates a race: if you send your first sdkSendMessage immediately after firing off the subscribe frame, it’s possible for the agent’s reply to be generated and published before your subscription has finished being registered on the server. When that happens, the event is lost — not queued, not redelivered — and your UI never receives a reply for that first turn, even though the mutation itself returned true.

The fix: after sending your subscribe frame, wait roughly 250ms before sending your first sdkSendMessage call. This is a small, fixed delay that’s cheap to add and eliminates the race in practice. It only matters for the very first message of a session — by the time a user has seen one reply, the subscription is unambiguously live and no further delay is needed.

  • Core API Flow — the full sdkStartsdkSendMessagesdkEndSession lifecycle this subscription plugs into.
  • Authentication — how the avatarToken used in connection_init is issued and scoped.
  • Agents — configuring the workflow whose node execution you’re subscribing to.
  • Channels: attachment markers — if you’re collecting a multi-response-node turn (e.g. an early acknowledgement followed later by a real answer), turnComplete — not a quiet-window timeout — is the only reliable way to know the turn is actually done; a fixed silence window can end the turn early while a node (an action call, an LLM step) is still running between two replies.
  • Troubleshooting — diagnosing missing events, dropped connections, and related issues.