Upgrade: user-safe error bodies, lapsed client calls (late-answers MR 2)
This release changes what a Helix HTTP error body's error field and a stream error's errorText hold, and makes the browser skip a client-tool call whose deadline lapsed. It is a major release of @helix-agents/core, @helix-agents/ai-sdk, @helix-agents/runtime-cloudflare and @helix-agents/agent-server (their error bodies change on the wire).
Design: 2026-10-26-client-tool-late-answers-design.md (§3, §4).
Deploying
Deploy the client and the server together. A new server's bodies put the developer text in detail; an old client that logs error logs the user sentence instead. A new client reads detail and falls back to error.
error is user-safe; detail is the developer text (SDK-5)
Every service deploys together, across services too. The remote-agent protocol's error envelope (RemoteAgentErrorResponse) splits the same way, and a parent rebuilds a remote child's failure from its detail. A child service on this version behind a parent on an older one hands the parent's MODEL the user sentence instead of the child's failure. Upgrade producers (agent-server, CF DO /subagent routes) and consumers (every parent) together.
Every typed error body (core typedErrorBody / typedErrorResponse: handleChatStream, the chat route helpers, the CF Durable Object's routes, @helix-agents/agent-server) is now:
{
error: string; // userMessageFor(code): a short user-safe sentence. Render this.
code: string;
detail?: string; // the error's own message, scrubbed. Log this; never render it.
cause?: string; // unchanged
// …the other typed extras, unchanged (retryable, limitBytes, sizeBytes, retryAt, runId)
}- Before:
errorwas the error's own message (scrubbed). Apps that rendered it showed developer text, internal causes and instructions such as "re-read the session snapshot (useHelixChat().retry())". - After:
errorisuserMessageFor(code)(core), one sentence per code, "Something went wrong." for an unmapped code; it never carries instructions, a cause or an id. The old text isdetail. - Streams: a stream's
errorevent'serrorTextisuserMessageFor(code)too. The run's message rides the transientdata-error-metadatapart, which gainsdetail({ code, recoverable, detail }); the part is now always sent before anerrorevent.HelixStreamError.fromEvent(event, metadata)takes its message frommetadata.detail. - The 409 for a resume of a failed stream has
error: userMessageFor(<the run's code>)(it was the literal'stream_failed') anddetail: <message>;code,retryable,messageanderrorDetailare unchanged.
What to change
- Render
error(a body) orerrorText(a stream); never renderdetail. - Log
detail:parseHelixChatError(chat.error)?.detailfor an HTTP refusal; for a stream error, capture thedata-error-metadatapart inonDataand log itsdata.detail. - A stock
useChatapp:chat.error.messagefor an HTTP refusal is the whole JSON body (ai's transport throwsnew Error(await response.text())),detailincluded. RenderparseHelixChatError(chat.error)?.error ?? <your own fallback>, neverchat.error.message. - Code that parsed
errorfor developer text (a log line, an error report, a test pin) readsdetailinstead. - The stream error a stock
useChatshows (chat.error.message) is now the user sentence, also for a provider failure: an unclassified one reads "Something went wrong.". Log the provider's text from the error metadata'sdetail(thedata-error-metadatapart, inonData). - A peer client (a custom DO client, a remote transport) rebuilds the error with core
helixErrorFromPeerBody, which now readsdetailas the message; a remote-protocol envelope with coreremoteProtocolError, which readsdetail ?? error. A custom transport that builds its own error from an envelope readsdetailfirst. @helix-agents/agent-server: its typed refusals (/start,/resume, the completion-support refusals, the submit routes,/chat) answer{ error: userMessageFor(code), code, detail }; its remote-protocol envelopes (ALREADY_COMPLETED,ALREADY_RUNNING,INTERNAL_ERROR, …) put the user sentence inerror(for the typederrorCode, else its protocol code's typed code) and the framework or failure text indetail. The protocol-code map is exhaustive:NOT_FOUND→state_session_not_found,ALREADY_RUNNING→state_already_running,ALREADY_COMPLETED→state_not_resumable,INVALID_REQUEST→validation_error,INTERNAL_ERRORandWORKSPACE_WIRING→framework_internal_error,NOT_IMPLEMENTEDandFEATURE_UNAVAILABLE→framework_not_supported.- An unknown agent type (
/start,/resume) now carrieserrorCode: 'framework_agent_not_found'beside itsNOT_FOUND, so it reads "This assistant is not available." rather than the session sentence. - The chat routes' 501
NOT_IMPLEMENTED(nochatHandlerwired) moves its wiring hint fromerrortodetail.
- An unknown agent type (
A transient submit answer is a re-anchor (SDK-5)
When the CF Durable Object stored a client-tool answer but the continuation it owes did not start (it is retried on its own), the submit answers 500 transport_continuation_pending (was the run's own code, e.g. framework_agent_resolution_failed, or 409 state_run_start_conflict). The run's code, when it had one, is the body's cause; the guidance is detail.
- A submit that lost the start race (its continuation hit
consumed_pending_changed) used to answer 409state_run_start_conflictand now answers 500transport_continuation_pendingwithcause: 'state_run_start_conflict'. An app that alerts on 409 versus 5xx sees this one move. useHelixChatanduseReanchorOnErrortreat it as a re-anchor, never an error: they clearchat.error, re-read the snapshot while it sayscontinuationOwed(1 s, 2 s, 4 s, then every 8 s, at most 2 minutes), and follow the run once it starts. Past the bound they report the typedtransport_continuation_pending(offerretry()).- The snapshot gains
continuationOwed: boolean(coreisSubmitContinuationOwed). - A custom client: on this code, do not re-submit; wait with
waitWhileContinuationOwedand re-anchor.
Lapsed client-tool calls are skipped in the browser (SDK-3)
- A client-tool call's
tool_startcarriesdeadlineAt; its UI part carriescallProviderMetadata.helix{ deadlineAt, serverNow }(live stream, reloaded snapshot, replayed prefix). useResumeClientTools/useHelixChatno longer run a call whose deadline lapsed (the server clock, estimated asserverNow + (Date.now() - receivedAt), is pastdeadlineAt + 1 s; a re-anchor that re-stampsserverNowrestartsreceivedAt, so no time is counted twice): the server already answered itclient_tool_timeout. PassrunLapsed: true(or a list of tool names) to run them anyway; the handler context gainsdeadlineAtandlapsed.- A stock
useChatsees only the extra metadata.
Late answers (onLateAnswer)
useHelixChat gains onLateAnswer(toolCallId), called once per transient data-helix-late-answer part (a real answer that arrived after its call's timeout was consumed; the server side ships in the next release). A stock useChat reads the part through onData. With a pre-built chat, onLateAnswer cannot fire (the hook cannot install its onData on a Chat it did not construct): handle data-helix-late-answer (data: { toolCallId }) in that Chat's own onData; the hook warns once per Chat instance in non-production builds.
For maintainers: a new ErrorCode needs its sentence
core USER_MESSAGES is a Record<ErrorCode, string>, so a new code in helix-error.ts does not build until it has a user-safe sentence there. This includes a main merge that brings new codes into a branch (the merge that brought in state_child_session_taken and framework_companion_not_supported failed its build until both had one). Write a sentence an app can show as is: no code, id, cause or developer instruction (the core pin over KNOWN_ERROR_CODES_LIST checks the shape of every entry). Add an exact-sentence pin only where the sentence carries contract weight (as for transport_continuation_pending).