Skip to content

Error Handling ​

Helix Agents provides a unified error handling architecture that classifies errors at the backend, transports them through the stream protocol, and reconstructs them on the frontend. Every error — whether from an LLM provider, a tool, or the framework itself — flows through the same pipeline with typed codes, categories, and retry information.

Overview ​

The error system has three layers:

  1. Backend Classification (@helix-agents/core) — Converts any error into a typed HelixError with a code, category, and retryability flag
  2. LLM Error Mapping (@helix-agents/llm-vercel) — Maps Vercel AI SDK errors (rate limits, auth failures, timeouts) to HelixError
  3. Frontend Reconstruction (@helix-agents/ai-sdk) — Reconstructs typed errors from stream events for client-side handling

The end-to-end flow:

LLM throws → mapVercelError() classifies → runtime onError writes ErrorChunk
  → SSE transport → HelixStreamError.fromEvent() reconstructs on frontend

Error Classification ​

All errors are normalized into HelixError, which extends Error with structured metadata:

typescript
import { HelixError } from '@helix-agents/core';

const error = new HelixError({
  message: 'Rate limit exceeded',
  code: 'provider_rate_limited',
  retryable: true,
  statusCode: 429,
  cause: originalError,
});

error.code; // 'provider_rate_limited'
error.category; // 'provider' (derived from code prefix)
error.retryable; // true
error.statusCode; // 429
error.cause; // original error for debugging

The category is automatically derived from the error code prefix unless explicitly overridden. A code like provider_rate_limited maps to category provider, while tool_execution_failed maps to tool.

HelixError.isInstance() ​

Use HelixError.isInstance() to check if an error is a HelixError. This uses a Symbol.for marker rather than instanceof, so it works reliably across package versions and module boundaries:

typescript
import { HelixError } from '@helix-agents/core';

if (HelixError.isInstance(error)) {
  console.log(error.code); // typed ErrorCode
  console.log(error.retryable); // boolean
}

Utility Functions ​

Two utilities handle the common problem of extracting useful information from unknown error values:

typescript
import { extractErrorMessage, ensureError } from '@helix-agents/core';

// Safe message extraction from any value
extractErrorMessage(new Error('fail')); // 'fail'
extractErrorMessage({ message: 'fail' }); // 'fail'
extractErrorMessage('string error'); // 'string error'
extractErrorMessage({ foo: 'bar' }); // '{"foo":"bar"}'
extractErrorMessage(null); // 'null'

// Wrap non-Error values into proper Error instances
const err = ensureError('string error');
err instanceof Error; // true
err.message; // 'string error'
err.cause; // 'string error' (original value preserved)

classifyError() ​

The classifyError() function converts any error into a HelixError. It recognizes framework-specific error types and maps them to appropriate codes:

typescript
import { classifyError } from '@helix-agents/core';

const classified = classifyError(someUnknownError);
// Always returns a HelixError, never throws

Classification rules:

InputErrorCode
Already a HelixErrorPassed through unchanged
AbortError / DOMException('AbortError')framework_cancelled
StaleStateErrorstate_concurrency_conflict
FencingTokenMismatchErrorstate_concurrency_conflict
StepCommitConflictErrorstate_concurrency_conflict
AgentAlreadyRunningErrorstate_already_running
RunStartConflictError (a subclass)state_already_running
RunSupersededErrorstate_run_superseded
RunStartRejectedErrorstate_run_start_conflict
CommitStoppedErrorstate_commit_stopped
AgentNotResumableErrorstate_not_resumable
StreamDropErrortransport_stream_drop
RemoteAgentFailedErrortransport_remote_agent_failed
Any other Errorframework_internal_error
Non-Error valueframework_internal_error

RunSupersededError and RunStartRejectedError are plain Error subclasses that carry their own code; CommitStoppedError is a HelixError. classifyError() matches all three by name (CommitStoppedError also by its code), so an error revived across a Temporal, DBOS or Workflows boundary classifies the same as a local one. All three are non-retryable. RunStartConflictError keeps the name AgentAlreadyRunningError and has code: 'state_run_start_conflict', but classifyError() maps it, like its parent class, to state_already_running.

classifyKnownError(error) applies the same rules but returns undefined for the last two rows, so you can tell "unrecognised" apart from a real classification.

resolveErrorDetail() ​

resolveErrorDetail(error, detail?) is how every runtime turns a terminal failure into the ErrorDetail it persists, streams and returns. In order:

  1. An explicit detail wins.
  2. Otherwise it walks the error and then its cause chain, outermost first. The first error that carries an attached detail, or that classifyKnownError recognises, gives the code, category and retryable. When that error is a cause, the detail keeps the outer error's message and records the wrapped message as cause.
  3. Otherwise it returns { message } (plus a code the raw value itself carries, such as a statusCode, and a boolean retryable property, which Cloudflare platform errors carry). It never invents framework_internal_error.

When the outermost error is itself classified and wraps a cause (for example a HelixError built with cause: err), the cause is recorded as its own resolved ErrorDetail: ErrorDetail.cause is string | ErrorDetail, nested at most 8 deep.

typescript
import { resolveErrorDetail, HelixError } from '@helix-agents/core';

const inner = new HelixError({
  message: 'rate limited',
  code: 'provider_rate_limited',
  retryable: true,
});
resolveErrorDetail(new Error('D1 write failed', { cause: inner }));
// { message: 'D1 write failed', cause: 'rate limited',
//   code: 'provider_rate_limited', category: 'provider', retryable: true }

resolveErrorDetail(new Error('boom'));
// { message: 'boom' }  (no code, no retryable)

resolveErrorDetail(
  new HelixError({
    message: 'usage store down',
    code: 'framework_usage_store_unavailable',
    retryable: true,
    cause: inner,
  })
);
// { message: 'usage store down', code: 'framework_usage_store_unavailable', category: 'framework', retryable: true,
//   cause: { message: 'rate limited', code: 'provider_rate_limited', category: 'provider', retryable: true } }

Error Codes ​

The ErrorCode type defines 36 codes organized by category:

Every code also has a user-safe sentence, core userMessageFor(code), which a typed error body's error and a stream error's errorText carry (the developer text rides as detail; see user-safe error bodies). The sentences live in one table, core USER_MESSAGES: Record<ErrorCode, string> (packages/core/src/errors/user-message.ts), so adding a code, or merging a branch that adds one, is a typecheck error until the code has its sentence. A sentence names no code, id, cause or instruction a user cannot act on.

Provider Errors ​

Errors from the LLM provider (OpenAI, Anthropic, etc.):

CodeDescriptionRetryable
provider_overloadedService at capacity (503, 529)Yes
provider_rate_limitedRate limit hit (429)Yes
provider_auth_errorInvalid credentials (401, 403)No
provider_content_filteredContent policy violationNo
provider_refusedRequest refusedNo
provider_timeoutRequest timed out (408)Yes
provider_invalid_requestMalformed request (400, 422)No
provider_errorGeneric provider error (5xx)Depends

Tool Errors ​

Errors during tool execution:

CodeDescription
tool_input_invalidLLM provided invalid arguments
tool_execution_failedTool's execute() threw
tool_not_foundReferenced tool doesn't exist
tool_timeoutTool exceeded time limit

Diagnostic: non-object tool-call input ​

If you see [helix-agents] non-object tool-call input coerced to {} in your logs, the model/SDK returned a tool-call input that was not a JSON object and could not be parsed into one. The framework replaced it with {}. If the tool's schema has required fields, this fails input validation (tool_input_invalid) and surfaces as a recoverable tool error rather than crashing the run on the next step's replay; if every field is optional, execute() runs with {} and this warning is your only signal. A [helix-agents] structured output repaired from JSON string warning means a structured output arrived as a JSON string and was repaired against the agent's outputSchema. Both are robustness guards that enforce the "tool input is an object" invariant (issue #155).

State Errors ​

Errors from the state store or session management:

CodeDescription
state_commit_stoppedA commit of a stopped run found its durable stop record and wrote nothing (CommitStoppedError, WA MR2). Non-retryable; the runtime ends the run by the stop (interrupted, or failed with framework_cancelled)
state_concurrency_conflictConcurrent modification detected
state_not_stoppableA stop found no live run to stop (a completed, failed or interrupted session) and wrote nothing. Non-retryable; HTTP 409 with currentStatus
state_session_not_foundSession doesn't exist
state_already_runningSession has an active run. A chat send that handleChatStream cannot start for this reason answers 409 { error, code: 'state_already_running', retryable: true }; send again once the current run ends
state_not_resumableSession can't be resumed
state_history_incompletePersisted history could not be fully read; no model call is made. The error's reason (the cause field of a snapshot route's 503 / 409 body) says why: short_read (a count still mismatched after the bounded re-reads, non-retryable), corrupt_row (a stored row failed validation, non-retryable), store_error (a store call threw, retryable: true, and the runtime's retry policy applies: Temporal activity retry, DBOS / CF Workflows step retry) or snapshot_unstable (no snapshot read pass was stable, retryable; the error's snapshotPasses says why each pass failed — fence_changed with the field that moved and its before/after values, or, last, deadline with the read in flight when the whole read missed its deadline (default 10 s) — and the message carries a short summary such as deadline@getMessages or runId 1a2b3c4d…->5e6f7a8b…)
state_checkpoint_not_foundThe checkpointId does not exist
state_checkpoint_foreignThe checkpoint belongs to another session; rejected before any state change
state_checkpoint_sequence_unavailableA commit could not read the live stream head it records as streamSequence. Retryable: the commit writes nothing, and the runtime's retry policy applies. The run fails with this code if the read keeps failing
state_run_start_conflictA run-start commit was refused for a reason other than "another live run owns the session" (RunStartRejectedError: idempotency_mismatch, entry_status_changed (the session left the status the entry planned from, for example an abort; on Temporal, DBOS and CF Workflows also a stale entry whose winner already finished, see Entry gates), entry_abandoned, resumed_companion_ref_missing (a resumed companion's parent ref is gone: a typed tool error to the parent on runtime-js; on Temporal and CF Workflows the resumed child fails with it, CP-105), request_key_taken (another run of the session holds the entry's request key; a keyed entry re-plans from its key and attaches, so it rarely reaches a caller)). Also the code of RunStartConflictError. Through handleChatStream it answers 409 with cause
state_run_supersededThe write's run is no longer the session's current live run (RunSupersededError). Never retried; the run reports status: 'superseded', not failed. A late client-tool submit to such a run answers 409
state_step_messages_uncommittedDBOS only: a turn tried to end while some of its step messages were never committed. Non-retryable; it signals a framework bug
state_agent_type_mismatchThe CF Durable Object was asked to run a different agent type than the session was created with (a /start on an existing session, /resume or /retry). Non-retryable; 409, nothing written
state_request_key_conflictA keyed request reused its request key for different input in this session (RequestKeyConflictError, D-2). Non-retryable; 409 with the runId that holds the key; nothing written. Send the new request with a new key
state_request_already_completedA keyed request whose run already ended while a later run of the session exists (RequestAlreadyCompletedError, D-2). Non-retryable; 409 with the runId that answered it; nothing written. Re-anchor through the snapshot, which holds that turn

Transport Errors ​

CodeDescription
transport_errorCommunication failure
transport_stream_dropA remote sub-agent's stream dropped (StreamDropError, retryable)
transport_remote_agent_failedA remote sub-agent reported failure (RemoteAgentFailedError, not retryable)

Remote Agent Errors ​

These errors are used internally by the runtimes for remote sub-agent stream recovery. They are standalone error classes (not HelixErrors) that runtimes catch via instanceof to trigger platform-specific retry mechanisms. If one reaches classifyError(), it becomes transport_stream_drop or transport_remote_agent_failed (see the table above).

Error ClassWhen ThrownRuntime Handling
StreamDropErrorSSE stream ends without an end event (network drop, server restart)Temporal: triggers activity retry. Cloudflare: triggers step retry. JS: handled internally (not thrown).
RemoteAgentFailedErrortransport.getStatus() reports a failed state, or a non-recoverable transport error occursAll runtimes: surfaces as a tool failure to the parent agent

Both classes carry metadata for recovery:

typescript
import { StreamDropError, RemoteAgentFailedError } from '@helix-agents/core';

// StreamDropError — carries position for retry
try {
  /* stream reading */
} catch (e) {
  if (e instanceof StreamDropError) {
    console.log(e.remoteSessionId); // Which remote agent
    console.log(e.lastSequence); // Resume from this position
  }
}

// RemoteAgentFailedError — carries failure reason
try {
  /* remote execution */
} catch (e) {
  if (e instanceof RemoteAgentFailedError) {
    console.log(e.remoteSessionId);
    console.log(e.remoteError); // Why the remote agent failed
  }
}

See Remote Agents — Stream Recovery for configuration options.

Validation Errors ​

CodeDescription
validation_errorInput validation failed

Framework Errors ​

CodeDescription
framework_internal_errorUnexpected internal error
framework_not_supportedFeature not supported (e.g. a workspace agent on Temporal, DBOS or CF Workflows, or an agent with persistentAgents on a CF Durable Object without subAgentNamespace)
framework_companion_not_supportedA companion refused because its tree can call a client-executed or approval-gated tool nothing on the runtime can answer (the CF Durable Object, CP-40). Non-retryable: change the agent definition
framework_cancelledOperation was cancelled
framework_max_steps_exhaustedA run without outputSchema ran out of maxSteps before the model finished. Non-retryable; retry() continues it with a fresh budget
framework_completion_strategy_unavailableAn outputSchema agent's model has no completion strategy: the adapter does not recognise it (an Anthropic model id missing from the capability table) or it supports neither forced tool choice nor native structured output. Raised before the run starts (early refusal), or on a forced call whose llmConfigOverride switched models. Non-retryable; fix with the adapter's capabilities override. An early refusal is a typed 400 { error, code } on every HTTP host, a chat send, chat resume or client-tool submit through handleChatStream included
framework_completion_schema_unsupportedThe completion tool's schema cannot be sent as native structured output (z.record, tuples, object intersections, z.date()) to a model that needs the native_output strategy. Raised before the run starts (early refusal), or by the adapter on a forced call whose llmConfigOverride switched to a model that cannot take the schema. Non-retryable. An early refusal is the same typed 400 { error, code } on every HTTP host, chat routes included
framework_prompt_prefix_changedThe rendered system prompt or tool set changed while signed thinking from a model that binds thinking to history (Opus 5.5, Sonnet 5.5, Fable 5.1, Mythos 5.1) exists in the session. Refused before the call; non-retryable. See Preserved thinking
framework_agent_not_foundThe agent type is not registered: AgentResolutionError.notFound, thrown by the CF and Temporal AgentRegistry and a CF Durable Object resolver. Non-retryable; HTTP 404 (the remote protocol's NOT_FOUND). Detect it with isAgentNotFoundError(e). A CF DO wake that hits it ends the run failed with this code
framework_agent_resolution_failedA CF Durable Object's resolver or llmAdapter factory threw while materializing the agent (AgentResolutionError.failed, the original as cause). Retryable unless the cause is classified retryable: false: a request answers 503 (409 / 500 once its input committed), a wake re-arms under the crash-loop budget
framework_agent_name_conflictDBOS: a different agent config object was registered under a name this process already holds (registerAgent, or the execute() / resume() / retry() that call it). One config per agent name per process; refused before anything is written. Non-retryable
framework_auto_resume_exhaustedA CF Durable Object gave up re-driving a stranded run after maxWakeResumeAttempts wakes without committed progress. The run ends failed; cause is the last re-armed failure (an ErrorDetail). retryable: true: recover with /retry
framework_usage_store_unavailableA CF Durable Object's consumer usageStore factory threw. The entry stops (no fallback to the internal store). Retryable: a request answers 503, a wake re-arms
framework_hook_failedA CF Durable Object beforeEntry hook or transformRequest threw. Keeps its cause's retryability (unclassified: retryable, 503); nothing is written
framework_skills_unavailableThe skills provider threw while the skills catalog or a preloaded skill was resolved. The step fails closed instead of running without the catalog (DBOS: logged and run without the catalog until DBOS support lands). Retryable

The forced-completion codes (framework_forced_completion_exhausted, framework_completion_protocol_violation, framework_completion_invalid_output, framework_completion_tool_missing, plus framework_completion_strategy_unavailable and framework_prompt_prefix_changed on a forced call) are described in Finishing Agents → Exhaustion means the run failed. The former framework_completion_tool_choice_unsupported was never raised and has been removed; a model that rejects forced tool choice now uses the native_output completion strategy.

CF Durable Object entry failures ​

A CF Durable Object classifies every failed entry with core classifyEntryFailure (lost, refused, transient, fatal) and acts on the class; it never warn-logs and continues.

Entrylostrefusedtransientfatal
/start, /resume, /retry409 state_already_runningthe core status (e.g. 404)503; 409 / 500 once the input committedthe core status (409 / 500)
a wake (alarm, eviction recovery, submit)no-opthe run ends failed, typedre-arm (lastFailure on the wake record)the run ends failed, typed

A request that fails before its run-start commit writes nothing to a session that already has a run. Exhausting the re-arm budget ends the run failed with framework_auto_resume_exhausted, whose cause is the last failure. The /subagent/:type/* routes answer the remote-protocol envelope instead, with the typed code in errorCode. A keyed request (D-2) whose request key a run already holds is answered from its key before the concurrency check: a 200 status: 'attached' naming that run, or 409 state_request_key_conflict / state_request_already_completed; it is never one of the busy answers above. See Cloudflare Durable Objects for the full answer table and the DO drive pipeline upgrade guide.

Currently Produced vs Reserved Codes ​

The framework currently produces the following error codes:

Provider codes (via mapVercelError in llm-vercel):

  • provider_auth_error (401, 403)
  • provider_rate_limited (429)
  • provider_timeout (408)
  • provider_invalid_request (400, 422)
  • provider_overloaded (503, 529)
  • provider_error (other 5xx, no status code)

State codes (via classifyError in core):

  • state_concurrency_conflict (StaleStateError, FencingTokenMismatchError, StepCommitConflictError)
  • state_already_running (AgentAlreadyRunningError, including RunStartConflictError)
  • state_not_resumable: runtime-js (and the CF DO, which runs it) throws ResumeRefusedError for a completed or failed session (non-retryable) and for a paused session whose client-tool batch is still partial (retryable). AgentNotResumableError maps to it too, but no runtime throws that class; DBOS refuses a completed or failed session with a plain Error, which classifies as framework_internal_error (DI-31 in the conformance findings).
  • state_agent_type_mismatch: the CF DO drive pipeline's agent-type gate
  • state_request_key_conflict (RequestKeyConflictError) and state_request_already_completed (RequestAlreadyCompletedError), thrown by a keyed entry's plan (core planKeyedEntry) on runtime-js and the CF DO, and by handleChatStream for a keyed send that reuses the current run's key for other input (D-2)
  • state_history_incomplete (thrown directly by loadLLMHistory and the snapshot reader readSnapshotView, not via classifyError): non-retryable for short_read and corrupt_row; retryable: true, with the store error as cause, for store_error — the runtime's retry policy then applies (Temporal activity retry, DBOS getMessagesStep re-attempts, CF Workflows LLM step.do retry); retryable: true for snapshot_unstable, whose snapshotPasses (a SnapshotPassOutcome[]) records why each read pass failed
  • state_checkpoint_not_found / state_checkpoint_foreign (thrown directly by resolveCheckpoint, not via classifyError)
  • state_checkpoint_sequence_unavailable (thrown directly by core resolveCommitSequence when the stream-head read throws; retryable)
  • state_run_start_conflict (RunStartRejectedError, and the code of RunStartConflictError), thrown by the stores' run-start commit
  • state_run_superseded (RunSupersededError), thrown by the superseded-run fence: every run-attributed commit, a fenced saveState, a lease renewal and a terminal run's status change
  • state_step_messages_uncommitted (DBOS turn exit guard)
  • state_commit_stopped (CommitStoppedError), thrown by a commit (refuseIfStopped) that found a stop record for its run
  • state_not_stoppable, a stop's answer when the session has no live run (HTTP 409; nothing written)
  • transport_error (RunStartAbandonedError and RunStartOutcomeUnknownError, the run-start fail-safes of Temporal, DBOS and CF Workflows, and CF Workflows' RunStartStalledError for a run that committed but never started; see Distributed Coordination)

Transport codes (via classifyError in core):

  • transport_stream_drop (StreamDropError, retryable)
  • transport_remote_agent_failed (RemoteAgentFailedError)

Framework codes:

  • framework_cancelled: an AbortError (via classifyError), and every hard abort on runtime-js, the CF DO and CF Workflows
  • framework_max_steps_exhausted: core createMaxStepsExhaustedError, for a run without outputSchema cut off by its maxSteps budget (message Max steps (N) reached); non-retryable. Produced on runtime-js, the CF DO and CF Workflows; Temporal and DBOS still end that run completed (DI-25). See When maxSteps runs out
  • framework_not_supported: assertRuntimeSupportsWorkspaces (a workspace-declaring agent on Temporal, DBOS or CF Workflows), and the CF DO for an agent with persistentAgents but no subAgentNamespace (answered 500: a server misconfiguration); non-retryable
  • framework_agent_not_found / framework_agent_resolution_failed: core AgentResolutionError, from the CF and Temporal AgentRegistry, runtime-js's submit-side lookup and the CF DO's agent materialization. A Temporal miss inside an activity still loses its code at the activity boundary (DI-04)
  • framework_auto_resume_exhausted, framework_usage_store_unavailable, framework_hook_failed: the CF DO drive pipeline
  • framework_internal_error: classifyError's fallback for a generic Error or unknown value. resolveErrorDetail never produces it; an unrecognised terminal failure has no code. Also raised (retryable) when memoryManager.buildTools throws on runtime-js or the CF DO: the step fails closed instead of silently dropping the memory tools
  • framework_completion_strategy_unavailable / framework_completion_schema_unsupported: core checkCompletionSupport (early refusal of an outputSchema agent on runtime-js, the CF DO, CF Workflows and Temporal; DBOS does not refuse yet) and prepareForcedCompletionCall (per forced call); framework_completion_schema_unsupported also from the Vercel adapter when a native-output schema cannot be converted
  • framework_prompt_prefix_changed: core checkPrefixStability, at the LLM call site of runtime-js, the CF DO, CF Workflows and Temporal (not yet DBOS)
  • framework_skills_unavailable: core resolveSkillsCatalog, on every runtime except DBOS (retryable: Temporal's activity retry and CF Workflows' step.do retry apply; runtime-js and the CF DO fail the run, and retry() re-runs it). DBOS resolves the catalog in its workflow body, so until DBOS support lands it logs the failure and runs the turn without the catalog (FU-COMPLETION-DBOS-SKILLS-RECOVERY-STRAND)

Reserved for future use (not currently produced):

  • provider_content_filtered - for content policy violations
  • provider_refused - for model refusals
  • tool_input_invalid - for Zod validation failures (currently uses generic error)
  • tool_execution_failed - for tool execution errors (currently uses generic error)
  • tool_not_found - for missing tools (currently uses generic error)
  • tool_timeout - for tool timeouts (currently uses generic error)
  • state_session_not_found - for missing sessions (the CF DO answers it for a /resume or /retry with no session; elsewhere a missing session still throws a generic error)
  • validation_error - for schema validation (the CF DO's request parsing, identity gate and sub-agent rewrite use it; most other paths still use a generic error)

LLM Error Mapping ​

The @helix-agents/llm-vercel package maps Vercel AI SDK errors to HelixError using mapVercelError(). This runs automatically when using the Vercel adapter -- you don't call it directly in most cases.

typescript
import { mapVercelError } from '@helix-agents/llm-vercel';

const classified = mapVercelError(vercelSdkError);
// Returns a HelixError with appropriate code and retryability

ApiCallError Mapping ​

The Vercel AI SDK throws ApiCallError for HTTP-level failures. These are mapped by status code:

HTTP StatusErrorCode
401, 403provider_auth_error
408provider_timeout
429provider_rate_limited
400, 422provider_invalid_request
503, 529provider_overloaded
Other 5xxprovider_error

The retryable flag is preserved from the Vercel SDK's own isRetryable property.

RetryError Mapping ​

When the Vercel SDK exhausts its retry budget, it throws a RetryError wrapping the last error. mapVercelError() unwraps it, classifies the inner error, and marks the result as non-retryable (since retries already failed):

typescript
// RetryError containing a 429 ApiCallError:
// → code: 'provider_rate_limited'
// → retryable: false (retries exhausted)
// → message: 'Failed after retries: Rate limited'

Connection drops ​

A connection to the provider that drops is recognised by the runtime's own structured error, never by its message:

RuntimeBefore the response startsAfter it started (mid-body)
Workers (workerd)a plain Error('Network connection lost.') with own retryable: true (and remote: true)the same
Node (undici)TypeError('fetch failed'), cause code UND_ERR_SOCKET (a close) or ECONNRESET (a reset)TypeError('terminated'), the same cause code

Newer AI SDK provider packages wrap a failure that happens while READING a successful response. They report it as an APICallError ("Failed to process successful response", the response's 2xx statusCode, the original error as its cause); aborts and timeouts are not wrapped. That starts with @ai-sdk/provider-utils 3.0.36 / 4.0.50, which ship with @ai-sdk/openai 2.0.123 / 3.0.105 and @ai-sdk/anthropic 3.0.115. The adapter classifies such a wrapper by its cause: a wrapped drop is the drop above, and any other wrapped body error is classified as it would be unwrapped. The wrapper stays the result's cause. An APICallError for an HTTP error (non-2xx) or a failure to connect (no status) is never treated as a wrapper.

Each maps to provider_error, retryable: true. Nothing else is a drop: an error that only says "Network connection lost." in its message, a HelixError or another Error subclass carrying a retryable field, and a workerd error marked overloaded (Cloudflare's guidance is not to retry those). A plain Error that the provider call itself rejected with is provider unreachable instead; any other stays unclassified (framework_internal_error).

A drop before any model output is retried inside the adapter call with the AI SDK's own backoff (llmConfig.maxRetries, default 3; 2 s, factor 2), so Workers behave as Node, where the AI SDK retries fetch failed itself. Each retry is logged as a warning on the adapter's logger. Nothing was streamed, so the dropped attempt is invisible to the run: no error chunk, no usage, no extra afterLLMCall. When the retries run out the error is Failed after retries: Network connection lost., provider_error, retryable: false, as a RetryError maps. With maxRetries: 0 the adapter does not retry, and the drop stays provider_error, retryable: true. Nothing caps the backoff: maxRetries: 5 waits up to 62 s in all, inside a Temporal runLLMStep activity or a CF Workflows LLM step that each time out after 5 minutes by default. The adapter's drop retries and the AI SDK's own retries of a 429 / 5xx inside one attempt are counted separately. On DBOS llmConfig.maxRetries does not reach the adapter (MA-27: DBOS drops it with most llmConfig fields), so DBOS always uses the default 3.

A drop after output started is not retried by the adapter; it stays retryable: true. runtime-js, the CF DO and CF Workflows retry it as a new attempt of the same step (llmStepRetry), as Temporal does for a forced-completion call; DBOS and Temporal's other calls fail the run with it (on DBOS a forced call's error is a recoverable miss: another forced attempt). An aborted call is never retried and is framework_cancelled.

Provider unreachable ​

A provider call that fails before it produced anything (DI-76) is provider_unreachable (category provider). On Cloudflare Workers this is how an unreachable provider looks: workerd masks the failure as a plain Error('internal error; reference = …'), after a 75 s connect timeout with own overloaded: true, or at once with no flags on a DNS failure. It is recognised by where it came from, never by its message:

  • a model object (openai('…'), any V3 model): the adapter wraps the model for each call, and only a plain Error or TypeError that the model's own doStream rejected with counts. A framework error elsewhere keeps its own classification, and so do an AI SDK error (an invalid prompt, an unsupported feature), an APICallError (an HTTP answer: its status decides) and a connection drop;
  • a gateway string id (or a V2 model), which the adapter cannot wrap: a plain Error or TypeError after the streamText call and before any model output. Residual: a framework TypeError inside streamText before the provider call reads as provider_unreachable there.

Its message is person-safe (The model provider could not be reached.); workerd's raw message is its cause, and the ErrorDetail keeps workerd's overloaded / remote flags (ErrorDetail.flags, also on the nested cause), so /status, the run's stored failure and the stream's failure carry them. The adapter retries it once (min(llmConfig.maxRetries, 1), after 2 s), and never when workerd marked it overloaded, so a 75 s connect timeout costs one attempt, never three. The final error is retryable: false, so a runtime that retries a retryable LLM step (llmStepRetry) does not multiply it.

To bound the 75 s, set the opt-in connectTimeoutMs on the adapter (new VercelAIAdapter({ connectTimeoutMs: 15_000 })): a model call whose doStream has not resolved (the provider's response has not started) by then is aborted and ends provider_unreachable, retried once like the rest. There is no default: a proxy or gateway that buffers the upstream answer before it sends its own headers would fail every long call. It applies to model objects only (a gateway string id is not wrapped).

Fallback ​

If the error isn't a wrapped response-body failure (classified by its cause, above), an ApiCallError, a RetryError or a connection drop, mapVercelError() falls back to classifyError() from core.

Runtime Error Handling ​

Every runtime passes the LLM adapter an onError callback that writes an ErrorChunk to the stream with the error's classification (code, errorDetail, recoverable), and every runtime resolves the run's terminal errorDetail through resolveErrorDetail().

  • runtime-js and the CF DO retry a retryable LLM error inside the step (llmStepRetry). They carry the classified detail of a provider failure that ends the run, whether the adapter throws it or returns it as an error step, into AgentResult.errorDetail, the persisted SessionState.errorDetail and the failed stream chunk. A throw anywhere inside the run loop (a throwing systemPrompt function, for example) ends the run failed with that detail and fires onAgentFail; the session is never left active.
  • CF Workflows retries a retryable LLM error: the step rethrows it and step.do re-runs the LLM step under llmStepRetry. The retried attempt's partial output is superseded by its attempt id. When the retries run out, the run fails with the provider's own message and detail. A non-retryable or unclassified error fails the run at once. Only a terminal error is written as an error chunk: a client stops reading at one, so a per-attempt chunk would hide the retry.
  • Temporal computes an error step's classified detail inside the runLLMStep activity and carries it across the activity boundary (terminal.errorDetail), so an ordinary terminal LLM error keeps its code, category and retryable on AgentResult.errorDetail, the persisted SessionState.errorDetail and onAgentFail's error (DI-24, closed for Temporal in @helix-agents/runtime-temporal 4.0). Other activity failures still fall back to the wrapping ActivityFailure's detail (DI-04). A forced call's retryable or unclassified error is retried by the activity retry policy without consuming a forced attempt.
  • DBOS loses an ordinary LLM error's classified detail at its terminal seams (DI-24).

Retrying a failed model call ​

A model call that fails with an error classified retryable: true is called again instead of failing the run. One rule decides it on every runtime (core isRetryableLLMStepError): only retryable: true is retried. That covers a connection drop after output started (provider_error) and a retryable 429 / 5xx that the adapter did not retry itself (with llmConfig.maxRetries: 0). An unclassified error (no code) is not retried, because it may be deterministic, and neither is a classified non-retryable one.

RuntimeRetriesBudget
runtime-js, CF DOInside the step, a returned error step or a thrown errorllmStepRetry executor option / DO config (LLMStepRetryPolicy)
CF Workflowsstep.do re-runs the LLM stepllmStepRetry executor config (WorkflowStepRetryConfig)
TemporalA forced-completion call (the activity retry); an ordinary call fails the runThe activity retry policy
DBOSNot yet: the run fails (FU-E5-LLM-ERROR-STEP-NOT-RETRIED)—

The default budget is the same everywhere it applies: 3 retries after the first call, waiting 1 s, 2 s and 4 s (core DEFAULT_LLM_STEP_RETRY: { limit: 3, delayMs: 1000, backoff: 'exponential' }). On runtime-js:

typescript
const executor = new JSAgentExecutor(stateStore, streamManager, llmAdapter, {
  // 5 retries, 500 ms, 1 s, 2 s, ... ; { limit: 0, delayMs: 0 } turns retrying off.
  llmStepRetry: { limit: 5, delayMs: 500, backoff: 'exponential' },
});

What a retry is:

  • A new attempt of the same step. The retry streams under a new attemptId, with its own step_start and the same step number. The failed attempt commits nothing: no assistant message, no state, and its tools never ran (tools run after the model call completes). The step's step_committed names the winning attempt.
  • Hooks and usage per call. beforeLLMCall and afterLLMCall fire for every attempt, and each attempt's tokens are recorded (they were billed).
  • One error, at the end. No error chunk is written for an attempt that is retried (see below). When the budget is spent, the run fails with the last attempt's errorDetail. A stop during the wait ends the run as any stop does (framework_cancelled for a hard abort), also a stop recorded by another process: the wait re-reads the run's stop record before the next attempt, so no further billed call is made.
  • A forced-completion call that fails retryably is retried without consuming a forced attempt.

What a client sees: the failed attempt's partial output stays in the stream, scoped to its attempt (the stream is append-only), and the snapshot never contains it. The switch to the retry is a data-attempt-superseded resync trigger, and a replay drops the superseded attempt (see Retried attempts). A client with resync, such as useHelixChat({ resync }), re-anchors on the trigger and shows only the retry. A stock useChat with no resync shows the failed attempt's partial text followed by the retry's until the page is reloaded.

Only a terminal failure gets an error chunk ​

ai's useChat stops reading a response at its first error event. So a runtime never writes an ErrorChunk for a model attempt that will be called again: the attempt that follows would never reach the client, which would keep the failed attempt's draft. Every runtime (runtime-js, the CF DO, CF Workflows, Temporal and DBOS) holds the adapter's onError report (core holdLLMErrorReport) until the step's outcome is known, and writes it only when the error ends the run: on runtime-js and the CF DO, an isTerminalLLMStepError() error that is not retried (not retryable: true, or the retry budget is spent); isTerminalLLMStepError() on DBOS, and on Temporal for an ordinary call; on Temporal, a forced call's error is written only when it is classified non-retryable (a retryable or unclassified forced error writes nothing and is rethrown for the activity retry); on CF Workflows, an error the step does not hand back to step.do for a retry. On Temporal the held report is written as the last step of the runLLMStep activity, after the attempt's usage recording, afterLLMCall and onMessage hooks and other writes: if any of them throws, the activity is retried and that attempt writes no error chunk.

The failed attemptWhat followsError chunk from onError
generateStep throwsTemporal: the activity retry re-runs it. DBOS: the step retry re-runs it. CF Workflows, runtime-js, CF DO: retried when classified retryable: true, else the run fails.No
An error step with shouldStop: falseThe loop calls the model againNo
An error step during a forced-completion callruntime-js / CF DO / CF Workflows / Temporal: retried (the in-step retry, step.do, the activity retry) when retryable (Temporal: or unclassified), else the run fails with the provider's detail. DBOS: a recoverable miss, another forced attempt (until DBOS support lands)Yes, one when it ends the run (DBOS: no)
An error step with shouldStop: trueThe run fails (runtime-js, CF DO, CF Workflows: retried first when retryable)Yes, one (when it ends the run)

A failure that does end the run still reaches the client: through at most one ErrorChunk (from onError or the runtime's terminal path) and always through the failed stream. When Temporal's or DBOS's LLM retry runs out, the run fails with the last attempt's error and writes that chunk on its terminal path. On DBOS this holds for every application failure in a turn, with no caller awaiting handle.result(): a step that runs out of retries (a tool, a commit), a step that fails without retrying, or a throw of the workflow body. A turn that fails after a client-tool or approval suspension also clears the pending entries of the calls it was waiting on, so the next turn's trailing heal pairs their tool_use with a not-executed result. On runtime-js, the CF DO and CF Workflows the failed session keeps those entries, and the next new turn settles them before its input (RM-81): a stored answer is used, anything else is paired not-executed. A DBOS cancellation or ownership loss, a superseded run, an entry failure, and a failure of the turn's own terminal writes still propagate and write nothing more.

A Temporal activity attempt that timed out keeps running in the worker while the retry streams. runLLMStep fences each attempt: it heartbeats, and it watches the stream for a newer step_start of the same step. Once the attempt is superseded or cancelled, its model call is aborted and its writes are dropped, so it never writes the error that would end the client's read.

A hard abort ends the run failed with framework_cancelled on runtime-js, the CF DO and CF Workflows; a soft interrupt ends interrupted with no error.

A run without outputSchema that runs out of maxSteps ends failed with framework_max_steps_exhausted (retryable: false) on runtime-js, the CF DO and CF Workflows; retry() continues it with a fresh budget. Temporal and DBOS still end it completed (DI-25).

Temporal and DBOS complete a blocked, refused or truncated reply (DI-26)

For an agent without outputSchema, a final text step whose stop reason is content_filter, refusal, max_tokens, error or unknown fails the run on JS, the Cloudflare DO and Cloudflare Workflows (JS reports, for example, Content blocked by safety filters). Temporal and DBOS never classify that stop reason: they end the same run completed with no error. (DBOS's run completionReason reads 'failed' for every text answer, ordinary ones included, so it does not flag this case; DI-27. On JS and both Cloudflare runtimes completionReason is written only for a stopWhen or maxSteps stop.) @helix-agents/llm-vercel returns this shape, with or without text, for a provider finish reason of length, content-filter, error, other or any unrecognised value, so on those two runtimes a filtered or truncated reply reads as a success; it never returns refusal, which only a custom adapter can. This was observed live for content_filter on Temporal and DBOS and for max_tokens on Temporal; the other reasons, and the Cloudflare behaviour, follow from source. Agents with an outputSchema are not affected, except on DBOS persistent-mode follow-up turns, which run without the schema (MA-21). See DI-26 in the conformance findings.

Stream Protocol ​

Errors are transported as ErrorChunk events in the stream:

typescript
interface ErrorChunk {
  type: 'error';
  error: string; // Human-readable message
  code?: string; // ErrorCode (present if classified)
  recoverable: boolean; // Whether the client can retry
  agentId: string;
  agentType: string;
  timestamp: number;
  step: number;
}

The code field is only present when the error was classified as a HelixError. The recoverable field maps to retryable from the backend classification -- the wire protocol uses "recoverable" while the TypeScript API uses "retryable".

For SSE transport, these chunks are serialized as JSON data events alongside all other stream chunk types.

Frontend Error Handling ​

The @helix-agents/ai-sdk package provides two error types for frontend use.

FrontendHandlerError ​

FrontendHandlerError represents HTTP-level errors from the FrontendHandler. These are thrown before the stream starts -- during request validation, session lookup, or stream creation.

typescript
import {
  FrontendHandlerError,
  ValidationError,
  StreamNotFoundError,
  StreamFailedError,
  ExecutionError,
} from '@helix-agents/ai-sdk';

Catch these in your API route handler:

typescript
export async function POST(req: Request) {
  try {
    return await handler.handleRequest(req);
  } catch (error) {
    if (error instanceof FrontendHandlerError) {
      return new Response(JSON.stringify({ error: error.message, code: error.code }), {
        status: error.statusCode,
      });
    }

    throw error;
  }
}

Subclasses and their HTTP status codes:

Error ClassCodeStatus
ValidationErrorVALIDATION_ERROR400
StreamNotFoundErrorSTREAM_NOT_FOUND404
StreamReaderErrorSTREAM_READER_ERROR404
StreamFailedErrorSTREAM_FAILED410
ConfigurationErrorCONFIGURATION_ERROR501
ExecutionErrorEXECUTION_ERROR500
StreamCreationErrorSTREAM_CREATION_ERROR500

Typed chat-route responses ​

The chat handlers (handleChatStream, createCloudflareChatHandler) answer every HelixError with a typed JSON body { error, code, retryable?, cause? } instead of a stream, and never throw one. Only a non-Helix error rejects (AiSdkHandlerError included). A route answers that, and its own failures (body parsing, auth), with chatErrorResponse(error, { sessionId }) from @helix-agents/ai-sdk, which never echoes an unknown error's text:

StatuscodeWhen
409state_already_runningA send (a request with a new user message) was refused because another run owns the session. retryable: true
409state_run_start_conflictThe send's run-start commit was refused for another reason; cause names it (e.g. idempotency_mismatch)
400validation_errorA malformed X-Helix-Request-Key (or requestKey param) on a request that carries messages: not 1–128 characters of [A-Za-z0-9._:-]. Answered before anything is read; the key is never echoed
409state_request_key_conflictA keyed send reused its request key for other input (D-2). The body carries runId, the run that holds the key. Nothing written; send with a new key
409state_request_already_completedA keyed send whose run already ended while a later run exists (D-2). The body carries runId. Nothing written; re-anchor through the snapshot
409state_run_supersededA late client-tool submit to a run that another entry superseded
400 / 404 / 503 / 409 / 500the error's codeA send whose entry failed before its run-start commit (a history read, a completion refusal, the CF Durable Object's typed /start body, a CF Workflows preCommitFailure handle). The status is core httpStatusForHelixError's: 400 caller error, 404 a missing session or checkpoint (a foreign checkpoint answered as a missing one), 503 retryable, 409 non-retryable state_*, else 500. The body is exactly { error, code } (core typedErrorResponse)
409 / 500the error's codeThe same, but the send's run had already committed when the entry failed: never 503, because re-sending would append the message twice. Use retry() instead
400 / 404 / 503 / 409 / 500the error's codeA resume (a request carrying client-tool or approval results) whose submitToolResult() or resume() failed with a typed error (a completion refusal is 400), or whose resume handle carries preCommitFailure. Same table and body; never 503 once this request stored a submitted result (re-sending would submit it again). On runtime-js the answer is stored before the resume is refused, and the next supported resume continues from it
404 / 503 / 409 / 500the error's codeA read that routes the request (the session, its current run, a resume's snapshot) failed with a typed error before this request's input was committed: 503 when retryable (e.g. the CF Durable Object client's DOPeerError for a 5xx), 404 for a missing session or checkpoint, 409 for a non-retryable state_* error, with its reason as cause
409 / 500the error's codeA read failed after this request's input was committed (e.g. the send's response-message-id re-read after its run-start commit): never 503, because re-sending would append the input twice
409state_not_resumableA replay-prefix resume named a message the snapshot does not hold (cause: 'resume_message_unknown')
409state_not_resumableAn answer for a call the server still awaits was not stored (rejected, or its submit failed transiently) while a sibling answer of the request was (cause: 'answer_not_stored', toolCallIds naming the calls); re-read the snapshot with retry() and answer them again (CP-179 / CP-171)
503transport_stream_dropA resume waited for a starting run to go live and gave up (cause: 'run_not_live'); retry the snapshot
404state_session_not_foundA snapshot of a missing session
503 / 409 / 500the error's codeA snapshot read failed: 503 when retryable (state_history_incomplete with cause: 'snapshot_unstable' or 'store_error'), 409 when not (cause: 'corrupt_row' / 'short_read'), 500 for an unknown error

A stock DefaultChatTransport turns a non-2xx send into chat.error. parseHelixChatError(chat.error) (from @helix-agents/ai-sdk) recovers the body, so you can offer a retry for a retryable conflict. It also recovers a send whose entry failed before its run-start commit: the session still shows the previous turn, and body.code says why the new one never started (e.g. state_history_incomplete). The same holds for a refused chat resume or client-tool answer (for example framework_completion_strategy_unavailable when the model's capabilities changed while a call was pending): the chat ends in status: 'error' instead of rendering nothing. That body is exactly { error, code } and carries no retryable field; the status (503 ⇔ retryable) is the only retry signal:

typescript
import { parseHelixChatError } from '@helix-agents/ai-sdk';

const body = parseHelixChatError(chat.error);
if (body?.code === 'state_already_running') {
  // A run is still live (the previous turn, or one started elsewhere): a send never
  // attaches to it (CP-13). Keep the user's text, then re-send once the run has ended
  // (watch the snapshot's `status`), or stop the run first.
  setPendingResend(true);
}

See the upgrade guide for the snapshot and submit routes.

HelixStreamError ​

HelixStreamError represents errors received through the stream itself. Use HelixStreamError.fromEvent() to reconstruct a typed error from a stream error event:

typescript
import { HelixStreamError } from '@helix-agents/ai-sdk';

const { data } = useChat({ api: '/api/chat' });

const errorEvent = data?.find((d) => d.type === 'error');
if (errorEvent) {
  const err = HelixStreamError.fromEvent(errorEvent);

  console.log(err.message); // 'Rate limit exceeded'
  console.log(err.code); // 'provider_rate_limited'
  console.log(err.retryable); // true
}

Retry Logic ​

Use the retryable flag to implement retry behavior:

typescript
const err = HelixStreamError.fromEvent(errorEvent);

if (err.retryable) {
  // Safe to retry with backoff
  await delay(2000);
  await submitMessage(lastMessage);
} else {
  // Show error to user, don't retry
  setError(err.message);
}

retryable does not mark transient infrastructure failures

No runtime classifies a transient store or network failure (for example a D1 error caused by fetch failed) as retryable: such an error has no code and no retryable flag, unless something on its cause chain is classified. A missing code means "unclassified", not "permanent". Cloudflare Workflows retries an unclassified LLM-step error for exactly that reason (see Runtime Error Handling). DBOS drops the classified code from the persisted errorDetail of an ordinary provider error (DI-24; Temporal keeps it since @helix-agents/runtime-temporal 4.0). See the conformance findings.

Code-Based Error Handling ​

Use the code field for specific error handling:

typescript
const err = HelixStreamError.fromEvent(errorEvent);

switch (err.code) {
  case 'provider_rate_limited':
    showToast('Too many requests. Retrying shortly...');
    await retryWithBackoff();
    break;

  case 'provider_auth_error':
    showToast('Authentication failed. Check your API key.');
    break;

  case 'provider_overloaded':
    showToast('Service is busy. Please wait...');
    await retryWithBackoff();
    break;

  default:
    showToast(`Error: ${err.message}`);
}

Temporal Serialization ​

The Temporal runtime serializes error information through DTOs to cross activity/workflow boundaries. Two schemas carry the errorCode:

agentStepResultSchema() includes errorCode for step-level errors:

typescript
const agentStepResultSchema = lazySchema(() =>
  z.object({
    shouldContinue: z.boolean(),
    isComplete: z.boolean(),
    pendingToolCalls: z.array(/* ... */),
    error: z.string().optional(),
    recoverable: z.boolean().optional(),
    errorCode: z.string().optional(), // HelixError.code
    stopReason: z.custom<StopReason>(),
  })
);

failAgentStreamInputSchema() includes errorCode for stream-level failures:

typescript
const failAgentStreamInputSchema = lazySchema(() =>
  z.object({
    sessionId: z.string().min(1),
    error: z.string(),
    errorCode: z.string().optional(), // HelixError.code
    startedAt: z.number().optional(),
  })
);

This ensures that when a HelixError occurs inside a Temporal activity, its classification code survives serialization through the workflow boundary and reaches the stream with full type information.

See Also ​

Released under the MIT License.