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.
Connecting
Section titled “Connecting”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/graphqlConnection handshake
Section titled “Connection handshake”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.
Subscribing
Section titled “Subscribing”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 } }}The full event catalogue
Section titled “The full event catalogue”sessionEvents returns a GraphQL union. These are the only event types that exist — there are no others:
| Event | Fields | Meaning |
|---|---|---|
AiResponseEvent | text, mood, isFinal, turnComplete, clientTurnId | The 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. Note that a message superseded by a newer one gets no reply at all (see below). |
NodeExecutingEvent | workflowRunId, nodeId, nodeType | A 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. |
NodeFailedEvent | workflowRunId, nodeId, nodeType, error | A node failed (tool timeout, upstream API error, etc). Surface this as an error state; optionally offer a retry or an escalation path. |
AvatarSpeakEvent | text, mood | Voice/3D-avatar integrations only. Text-only chat integrations can ignore this entirely. |
SessionWillEndEvent | type | Non-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. |
SessionEndedEvent | durationSeconds | Terminal. The session has actually ended. The subscription closes shortly after this fires. durationSeconds is useful for analytics. |
InterruptedEvent | sessionId, timestamp | Voice/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 onlytrueon the trailing, empty-text marker chunk of a response. For a workflow reply (an agent whose replies come from aresponsenode in a Workflow), every individualresponse-node message isisFinal: trueon 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 furtherAiResponseEvents 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.
When a user sends a second message before the first reply: supersede
Section titled “When a user sends a second message before the first reply: supersede”Added 2026-09-28. This applies to every session, whatever the channel.
A newer message replaces a turn that is still in progress. Suppose a user sends message A, then sends message B before the agent has replied to A. B now supersedes A. A’s turn stops, B’s turn runs, and the user gets one answer, to B. Before this change, A and B ran at the same time as two independent turns. Both could reply, in whichever order they happened to finish, so the reply to A could arrive after the reply to B and answer a question the user had already moved past.
What that means for what you receive:
- You get no reply for the superseded message. No
AiResponseEventis published for A, and none carries A’sclientTurnId. A’s reply is not written to the transcript either, so later turns don’t reason over it. If your client waits for a reply to eachclientTurnIdit sends, stop waiting for A once a reply for a later message arrives. Otherwise that wait never ends. - No
InterruptedEventis sent for a supersede. That event exists to tell a voice client to cut audio after an explicit barge-in. For a supersede, the reply to B is the signal. - A superseded workflow run is recorded as
INTERRUPTED, notFAILED, inworkflowRuns. The processor’s generic “Sorry, I ran into a problem” notice is suppressed for it too, so the user never gets an apology about a question they’ve moved on from.
When A stops. A workflow-driven turn stops at its next node boundary. A direct (non-workflow) streamed reply stops at its next sentence. Sentences already streamed stay streamed and are kept in the transcript. A node that is already running always finishes, including an action, tool or webhook call. An outbound call is never cut off halfway, because a half-sent request can’t be un-sent.
A turn that has already said something is never superseded. Once a turn’s workflow reaches its first response node (for example an early acknowledgement such as “Got it, filing your application now”), that turn is committed. It runs to completion, and a follow-up message runs alongside it, as every message did before this change. Two other cases are also never superseded: a turn resuming a paused await_reply node, so the user’s answer is never thrown away, and a headless runWorkflowTask or scheduled run.
The residual to design around: side effects before the first reply
Section titled “The residual to design around: side effects before the first reply”This follows directly from the rules above, so plan for it when you build workflows. Take a workflow that performs a side effect, such as submitting a form or creating a record, before its first response node. If the user sends a second message before that workflow has said anything, the first turn is superseded, and any side-effect node it had not yet started is skipped. The newer message then starts its own turn from start. That is often exactly what you want, because the user changed their mind. But if the second message was just “hello?” or “thanks”, the user may believe the first request was handled when it wasn’t.
The fix is structural: put a response node before any side effect that must not be skipped. An early acknowledgement (“One moment, I’m submitting that now”) commits the turn, so a quick follow-up can no longer cancel the action the reply just promised. This is the same early-acknowledgement shape that already works well for slow action calls. See the turnComplete section above for how to collect a multi-reply turn correctly.
Other things that changed:
- An explicit
interruptSessionbarge-in now also drops a reply still being generated. If the barge-in lands while aresponsenode is already running, that reply is no longer published. Previously it could still arrive after the client had been sentInterruptedEvent. - It fails open. If the platform’s coordination store is briefly unavailable, sessions fall back to the old behaviour, where both turns run, rather than dropping replies.
- Your own debounce still helps. If you already batch rapid messages before sending them (a quiet window, or one “turn in progress” flag per conversation), keep it. Supersede is a safety net for messages that get through anyway, not a replacement for batching several fragments into one message.
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.
Next steps
Section titled “Next steps”- Core API Flow — the full
sdkStart→sdkSendMessage→sdkEndSessionlifecycle this subscription plugs into. - Authentication — how the
avatarTokenused inconnection_initis 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 (anactioncall, an LLM step) is still running between two replies. - Troubleshooting — diagnosing missing events, dropped connections, and related issues.