Skip to content

JavaScript Runtime ​

The JavaScript runtime (@helix-agents/runtime-js) executes agents in-process within your Node.js application. It's the simplest runtime to set up and ideal for development, testing, and simple deployments.

When to Use ​

Good fit:

  • Local development and testing
  • Prototyping and experimentation
  • Single-process deployments
  • Short-lived agent executions (< 30 minutes)
  • Serverless functions (Lambda, Cloud Functions)

Not ideal for:

  • Long-running agents that may outlive the process
  • Production workloads requiring crash recovery
  • Multi-process distributed systems

Installation ​

bash
npm install @helix-agents/runtime-js @helix-agents/store-memory

Or use the SDK which bundles everything:

bash
npm install @helix-agents/sdk

Basic Setup ​

typescript
import { JSAgentExecutor, InMemoryStateStore, InMemoryStreamManager } from '@helix-agents/sdk';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';

// Create stores
const stateStore = new InMemoryStateStore();
const streamManager = new InMemoryStreamManager();
const llmAdapter = new VercelAIAdapter();

// Create executor
const executor = new JSAgentExecutor(stateStore, streamManager, llmAdapter);

Constructor ​

typescript
new JSAgentExecutor(
  stateStore: SessionStateStore,
  streamManager: StreamManager,
  llmAdapter: LLMAdapter,
  options?: {
    logger?: Logger;
    runLease?: { ttlMs?: number }; // default JS_RUN_LEASE_MS (30 000 ms)
    llmStepRetry?: LLMStepRetryPolicy; // default DEFAULT_LLM_STEP_RETRY (3 retries, 1 s, exponential)
    // ...workspaceProviders, agentRegistry, orphanedChildDetection, ...
  }
)

Parameters:

  • stateStore - Where agent state is persisted (Memory, Redis). The store must implement the run methods (getCurrentRun, getRun, listRuns, updateRunStatus, commitState, renewRunLease); the constructor throws a validation_error naming the missing ones otherwise
  • streamManager - How events are streamed (Memory, Redis)
  • llmAdapter - LLM provider adapter (Vercel, Custom)
  • options.runLease - The run lease (JSRunLeaseOptions)
  • options.llmStepRetry - How a model call that fails with a retryable: true error is retried, as a new attempt of the same step (Retrying a failed model call)

Executing Agents ​

Basic Execution ​

typescript
const handle = await executor.execute(MyAgent, 'Research the benefits of TypeScript');

// Stream events
const stream = await handle.stream();
if (stream) {
  for await (const chunk of stream) {
    if (chunk.type === 'text_delta') {
      process.stdout.write(chunk.delta);
    }
  }
}

// Get result
const result = await handle.result();
console.log(result.output);

With Multiple Messages ​

Send multiple user messages in a single execution. This is useful for injecting context alongside a question, providing system-generated context, or batching messages from an async channel:

typescript
const handle = await executor.execute(
  MyAgent,
  {
    message: [
      {
        role: 'user',
        content: 'Background: user is on the enterprise plan',
        metadata: { source: 'system' },
      },
      { role: 'user', content: 'What features do I have access to?' },
    ],
  },
  { sessionId: 'my-session' }
);

Each message in the array is persisted individually and visible to the LLM. You can attach files to any message:

typescript
const handle = await executor.execute(
  MyAgent,
  {
    message: [
      {
        role: 'user',
        content: 'Analyze this screenshot',
        files: [
          {
            data: 'data:image/png;base64,iVBORw0KGgo...',
            mediaType: 'image/png',
            filename: 'screenshot.png',
          },
        ],
      },
    ],
  },
  { sessionId: 'my-session' }
);

String and multi-message inputs can be mixed freely across turns in the same session.

With Initial State ​

typescript
const handle = await executor.execute(MyAgent, {
  message: 'Continue the research',
  state: {
    previousFindings: ['Finding 1', 'Finding 2'],
    phase: 'analyzing',
  },
});

With Options ​

typescript
const handle = await executor.execute(MyAgent, 'Research topic', {
  sessionId: 'my-session-id', // Session ID for conversation continuity
  parentStreamId: 'parent-stream', // For sub-agent streaming
  parentSessionId: 'parent-session-id', // Parent session reference (for sub-agents)
});

With Conversation History ​

When you manage your own message history externally:

typescript
const handle = await executor.execute(MyAgent, {
  message: 'Continue from here',
  messages: [
    { role: 'user', content: 'Previous question' },
    { role: 'assistant', content: 'Previous answer' },
  ],
});

Execution Handle ​

The handle returned from execute() provides these methods:

stream() ​

Get an async iterable of stream chunks:

typescript
const stream = await handle.stream();
if (stream) {
  for await (const chunk of stream) {
    console.log(chunk.type, chunk);
  }
}

Returns null if streaming is not available.

result() ​

Wait for and get the final result:

typescript
const result = await handle.result();

if (result.status === 'completed') {
  console.log('Output:', result.output);
} else {
  console.log('Failed:', result.error);
}

abort(reason?) ​

Cancel the agent execution:

typescript
await handle.abort('User requested cancellation');

The agent checks the abort signal between steps and during tool execution.

getState() ​

Get current agent state:

typescript
const state = await handle.getState();
console.log('Step count:', state.stepCount);
console.log('Messages:', state.messages.length);
console.log('Custom state:', state.customState);

canResume() ​

Check if the agent can be resumed:

typescript
const { canResume, reason } = await handle.canResume();
if (canResume) {
  const newHandle = await handle.resume();
}

resume() ​

Resume a paused or interrupted agent:

typescript
const { canResume, reason } = await handle.canResume();
if (canResume) {
  const newHandle = await handle.resume();
  const result = await newHandle.result();
}

retry() ​

Retry a failed execution:

typescript
const result = await handle.result();
if (result.status === 'failed') {
  const retryHandle = await handle.retry();
  const retryResult = await retryHandle.result();
}

The retry is one run-start commit: it rewinds the log to the target (atomically, inside the commit) and appends the input. Which target and input it uses (core planRetry, the same on every runtime):

  • Default target: the failed run's last committed step. A failure writes a terminal checkpoint, but retry() never targets it. It restores the failed run's latest step commit (its customState, stepCount, forcedCompletion). The triggering user message is already in that prefix, so pass message (appended after it); without one retry() throws a validation_error (No user message found to retry with).
  • The run failed on its first step. The retry re-runs the run's entry: the log is rewound to just before the run's own triggering user message, which the retried run re-appends, followed by message when given. Without a message the trigger is re-run (CP-58; this used to throw).
  • The session failed before its run started. If the failing entry never reached its run-start commit, nothing of it is in the log: the whole log is kept, and message is required.
  • checkpointId - Restore exactly this checkpoint. It must belong to this session: an unknown id throws state_checkpoint_not_found, another session's checkpoint state_checkpoint_foreign, before any state change. A real user message after the checkpoint is re-run; with none, message is required.
  • If the session has no checkpoint at all (pre-upgrade data), retry() restarts fresh from the triggering message.

See Retrying Failed Agents and the upgrade guide.

send() ​

Continue the conversation with another message. This is syntactic sugar for continuing the conversation in the same session:

typescript
// Simple string input (becomes user message)
const handle2 = await handle1.send('Tell me more about that');
const result = await handle2.result();

// Message array input (for advanced use cases)
const handle2 = await handle1.send([
  { role: 'user', content: 'Here is some context' },
  { role: 'user', content: 'Now my actual question' },
]);

State: the new turn continues from the session's committed state. send() takes no state (input state seeds a fresh session only, U23 / CP-223); to change state on a later turn, write it from a tool or a hook such as onAgentStart.

A new request: send() does not inherit the original execute()'s abortSignal, requestKey or branch. The first request's signal may abort long after its run ended (a closed HTTP request, a caller's stop), and it must not refuse or stop a later turn. Stop a send's run through the handle send() returns (interrupt() / abort()), or start the turn with executor.execute(agent, input, { sessionId, abortSignal }) to give it a signal of its own.

Important: send() waits for the current execution to complete before starting the new one. If you need parallel conversations, create separate handles via executor.execute().

History and time travel ​

Every entry point that loads the conversation into memory (execute() on an existing session, resume(), retry(), branch, sub-agent reloads, a companion resumed from interrupted) uses core loadLLMHistory. If the store returns fewer rows than its live count, the run fails with errorDetail.code === 'state_history_incomplete' (persisted, on the stream and in onAgentFail) instead of calling the model on a partial transcript. When that read happens before the entry's run-start commit (execute()'s history load and its run-start plan, retry()'s plan), the entry never started a run: the caller gets the typed failure (execute() returns a handle whose result is failed with that code, or rejects), and a session that already has a run is left untouched (its status, errorDetail, stream and hooks). Only a session that never had a run is marked failed. execute() on a session that is still running throws AgentAlreadyRunningError as before.

resume({ mode: 'from_checkpoint', checkpointId }) rewinds the durable log and the in-memory state to the target checkpoint. Previously only the in-memory state was restored while the log was truncated to the latest checkpoint, so the model still saw messages from after the target (RM-25). The rewind happens inside the resume's run-start commit (startRun.truncateTo), atomically with the new run, so a concurrent resume() gets AgentAlreadyRunningError with nothing changed, and the winner writes a stream_resync (with the rewind's truncateCheckpointId) after its run_started. Pending client-tool calls made after the checkpoint get an aborted tool_end; see from_checkpoint.

Reconnecting to Sessions ​

Use getHandle() to reconnect to an existing session:

typescript
// Get handle for existing session
const handle = await executor.getHandle(MyAgent, 'session-123');

if (handle) {
  // Check if we can resume
  const { canResume, reason } = await handle.canResume();

  if (canResume) {
    // Resume execution
    const resumedHandle = await handle.resume();
    const result = await resumedHandle.result();
  } else {
    // Get completed result
    const result = await handle.result();
  }
}

The sessionId is the primary identifier for conversation continuity. Persist it to reconnect to conversations across server restarts or process boundaries.

Multi-Turn Conversations ​

Enable conversation continuation where each message builds on the previous exchange using the session-centric model.

Using sessionId ​

Pass the same sessionId to continue a conversation within the same session:

typescript
// First message - creates a new session
const handle1 = await executor.execute(agent, 'Hello, my name is Alice', {
  sessionId: 'session-123',
});
await handle1.result();

// Continue the conversation - agent remembers the name
const handle2 = await executor.execute(agent, 'What is my name?', {
  sessionId: 'session-123', // Same session continues the conversation
});
const result = await handle2.result();
// Agent responds: "Your name is Alice"

A turn started while the previous run is still finishing (CP-85)

The previous run writes its terminal commit, then its run status, then ends the session's stream. A turn started before that run status lands is refused with AgentAlreadyRunningError (the previous run still holds its lease). A turn started after it wins its run-start commit and claims the stream; the old run's late stream end is fenced to its own run id and changes nothing, so it no longer ends the new turn's stream. The finding record stays open until its conformance cell lands; see CP-85 in the conformance findings.

Using handle.send() ​

Syntactic sugar for continuation within the same session:

typescript
const handle1 = await executor.execute(agent, 'Hello, my name is Alice', {
  sessionId: 'session-123',
});
await handle1.result();

// Equivalent to execute() with same sessionId
const handle2 = await handle1.send('What is my name?');
const result = await handle2.result();

Using Direct Messages ​

When you manage your own message history in an external database:

typescript
const handle = await executor.execute(agent, {
  message: 'What is my name?',
  messages: [
    { role: 'user', content: 'Hello, my name is Alice' },
    { role: 'assistant', content: 'Hello Alice! How can I help you today?' },
  ],
});

This is useful when:

  • You store conversation history in your own database
  • You want full control over what context the agent sees
  • You're building chat features outside the framework's state store

Note: System messages in messages are filtered out and re-added dynamically by the agent.

Input Formats ​

The message field accepts two formats:

  • string - Simple text message (sugar for a single user message)
  • UserInputMessage[] - Multiple user messages, each with optional metadata and files
typescript
// String shorthand
await executor.execute(agent, 'Hello');

// Structured with multi-message
await executor.execute(agent, {
  message: [
    { role: 'user', content: 'System context', metadata: { hidden: true } },
    { role: 'user', content: 'User question' },
  ],
});

Behavior Table ​

Both messages and state have override semantics when combined with existing session state:

InputMessages SourceState Source
message only (new session)Empty (fresh)Empty (fresh)
message + sessionId (existing)From sessionFrom session
message + messagesFrom messagesEmpty (fresh)
message + stateEmpty (fresh)From state
message + sessionId + messagesFrom messages (override)From session
message + sessionId + stateFrom sessionFrom state (override)
All fourFrom messages (override)From state (override)

Key points:

  • message can be a string or UserInputMessage[] in all rows above
  • Sessions contain all messages and state for a conversation
  • Each execution creates a new run within the session (for debugging, billing, tracing)
  • messages (conversation history) overrides history from session when both are provided
  • state overrides state from session when both are provided
  • Non-existent sessions are automatically created on first message

Branching Conversations ​

Use the branch option to create a new session from an existing checkpoint:

typescript
// Create a new session branching from an existing checkpoint
const handle = await executor.execute(agent, 'What if we tried a different approach?', {
  sessionId: 'new-session-456',
  branch: { fromSessionId: 'session-123', checkpointId: 'cp_abc' },
});
// new-session-456 starts with state from checkpoint cp_abc

The new session gets exactly the checkpoint's first messageCount messages and its customState. Use messageIndex instead of checkpointId to copy the first N messages. The checkpoint must belong to fromSessionId (state_checkpoint_foreign otherwise, before anything is cloned).

Method Comparison ​

MethodPurposeStream BehaviorValid From Status
execute()New/continue conversationResets streamAny except running
resume()Continue after interruptPreserves streaminterrupted, paused
retry()Recover from failureResets to its targetfailed

Common Patterns ​

typescript
// Multi-turn conversation
const h1 = await executor.execute(agent, 'Hello', { sessionId: 'chat-1' });
await h1.result();
const h2 = await executor.execute(agent, 'Tell me more', { sessionId: 'chat-1' });

// Interrupt and resume
const h = await executor.execute(agent, 'Long task', { sessionId: 'task-1' });
await h.interrupt();
const resumed = await h.resume();

// Retry after failure
const h = await executor.execute(agent, 'Risky task', { sessionId: 'task-2' });
const result = await h.result();
if (result.status === 'failed') {
  const retried = await h.retry();
}

Concurrency Protection ​

The JS runtime prevents concurrent executions on the same session:

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

const handle1 = await executor.execute(agent, 'First', { sessionId: 'sess-1' });

// Throws AgentAlreadyRunningError
try {
  await executor.execute(agent, 'Second', { sessionId: 'sess-1' });
} catch (error) {
  if (error instanceof AgentAlreadyRunningError) {
    console.log('Session already running');
  }
}

Protection Mechanisms ​

Every entry into a run (execute(), resume() in any mode, retry(), a client-tool continuation, a sub-agent turn) writes one run-start commit before anything else: appendMessages(sessionId, inputRows, { streamSequence, runId, startRun }). The store applies it atomically: it checks that the session's current run is still the one the caller read, refuses a run whose owner is still live, supersedes any other live run, applies a rewind (resume from a checkpoint, retry()), appends the input rows and creates the run record and its input checkpoint.

OutcomeWhat the caller sees
WinnerClaims the stream (a compare-and-set), makes it live, writes run_started, then the run's content
LoserAgentAlreadyRunningError (RunStartConflictError); it wrote nothing: no input row, no stream status change, no chunk
RefusedRunStartRejectedError when the call's own commit cannot apply (for example the same runId with a different input, idempotency_mismatch); the chat handler maps it to a 409 state_run_start_conflict

The only earlier write is a new session's write-once createSession (two concurrent execute() calls on a new id: one gets AgentAlreadyRunningError). A fresh owner token is minted per run.

resume() in continue / with_message mode on a paused session whose client-tool batch is still partial (some awaited call has no submitted result) is refused with a retryable state_not_resumable before any write (R44). Submit every result of the batch (the runtime then continues), call interrupt() (it ends the paused run at once), or submit error results for the calls you want to abandon. A from_checkpoint rewind that discards the unanswered calls proceeds.

Run lease and superseded runs ​

A JS run is owned through a lease, not a process lock, so a crashed process never bricks a session:

  • The run-start commit grants the run a lease of runLease.ttlMs (default JS_RUN_LEASE_MS, 30 000 ms, exported by @helix-agents/runtime-js). The store evaluates it on its own clock.
  • The executor renews it every ttl / 3 through the store's renewRunLease (a compare-and-set), on a timer, so a long LLM or tool call keeps it. The suspension or terminal commit releases it.
  • An entry into a session whose current run holds a valid lease gets AgentAlreadyRunningError. A run whose lease lapsed (its process crashed, or a pre-upgrade run with no lease) is superseded by the next execute() / resume().
  • A run whose renewal the store rejects (another entry took the session over) stops at once. A transient or hung renewal is retried and never stops the run on its own. Every chunk it writes is gated on the lease (past the local deadline the gate renews first), so a lapsed run never writes to a stream a newer run may have claimed, and every tool execution checks that the run still owns the session first.
  • A blocked event loop can be superseded. If the process's event loop is blocked for longer than about ttl - ttl/3 (20 s with the default), renewals cannot run, the lease can lapse and a concurrent entry may take over (with no concurrent entry, the run renews and continues once the loop frees up). Raise runLease.ttlMs if your tools block the loop for long periods.

A run that was taken over stops as superseded, never failed:

typescript
const result = await handle.result();
if (result.status === 'superseded') {
  // errorDetail.code === 'state_run_superseded', retryable: false.
  // Another run now drives the session; reconnect to it with getHandle().
}

onAgentComplete / onAgentFail do not fire for a superseded run (one exception: a supersede that lands after onAgentComplete fired and before the run's terminal commit leaves onAgentComplete then onAgentSuperseded; the last terminal hook is authoritative); the optional onAgentSuperseded hook does. Every write a superseded run attempts is fenced on its run id (RunSupersededError, state_run_superseded) and changes nothing. In-process sub-agent runs keep their own leases. The Cloudflare DO, which runs this executor, keeps no lease: its synchronous claim decides the owner.

resume() refuses a terminal session with an untyped error (DI-31)

resume() refuses a session whose run is still executing with AgentAlreadyRunningError in every mode, from_checkpoint included (before this release a from_checkpoint resume was accepted and superseded the live run, CP-83). A completed or failed session is still refused with a plain Error, not AgentNotResumableError (DI-31). See the conformance findings.

Client-tool deadlines ​

A run suspended on a client-tool call that no client answers is advanced at the call's deadline (clientToolTimeoutMs) by the executor itself:

  • The suspension arms an in-process timer at the earliest deadline of the run's unanswered calls.
  • At the deadline the executor expires the lapsed calls as client_tool_timeout. The write is run-fenced and stop-guarded, so a submit or a stop that lands first wins.
  • Once the batch is complete, the executor continues the run with an ordinary resume(). A call with a later deadline, or a step still waiting on sub-agents, is not continued early.
  • No request is needed. The stream carries the timeout's tool_end and the continuation's chunks, so a client that reconnects through the snapshot sees them.

The limit: it runs only while the process lives. If the process exits after the suspension, or its host freezes it between requests (serverless, scale-to-zero), nothing fires. The lapsed call is then expired by the next request that touches the session:

  • a reconnect's getHandle() or a submitToolResult() re-arms the timer;
  • a resume() expires the call in its drain.

submitToolResult() re-arms only with an agentRegistry, because it needs the session's agent. Sub-agent sessions are advanced through their parent run, not on their own.

Upgrading: after a deadline the run may already have advanced. Code that waited past a deadline and then called resume() to move the run on now usually finds it already continued, and the late resume() is refused, typed:

  • ResumeRefusedError (code: 'state_not_resumable', sessionStatus: 'completed', not retryable) once the continuation completed the run;
  • AgentAlreadyRunningError while it is still running. It is a plain Error subclass, not a HelixError: match it by class or name. Its run-start-commit form, the RunStartConflictError subclass, carries code: 'state_run_start_conflict'; core classifyError maps both to state_already_running.

Read the outcome instead: (await executor.getHandle(agent, sessionId))?.result(), or the session snapshot.

A resume({ mode: 'with_message' }) that arrives while the deadline's continuation is running is refused state_already_running, as against any live run; nothing is written and the client may re-send it (a new turn once the run has completed). Whether such a message should instead be queued onto the continuation is an open design question.

The Cloudflare Durable Object, which runs this executor, fires deadlines from its own alarm instead (it survives eviction). See the client-tools guide for every runtime.

Execution Flow ​

Here's how the JS runtime executes an agent:

mermaid
flowchart TB
    Start["execute() called"]

    subgraph Init ["1. Start the run"]
        I1["Mint run ID; parse initial state from schema defaults"]
        I2["Run-start commit: user message + run record + input checkpoint"]
        I3["Winner only: claim the stream, run_started"]
        I4["Start the run lease"]
    end

    subgraph Loop ["2. Execution loop (while status === 'running')"]
        subgraph Build ["3. Build messages"]
            B1["Add system prompt"]
            B2["Include conversation history"]
        end

        subgraph LLM ["4. Call LLM"]
            L1["Stream text deltas"]
            L2["Get tool calls"]
        end

        subgraph Process ["5. Process step result"]
            P1["Check for __finish__ tool"]
            P2["Extract output if complete"]
            P3["Plan tool executions"]
        end

        subgraph Tools ["6. Execute tools (parallel)"]
            T1["Regular tools: execute directly"]
            T2["Sub-agent tools: recursive execute()"]
        end

        subgraph Update ["7. Commit the step"]
            U1["Add assistant message"]
            U2["Add tool results"]
            U3["Step commit (rows + state + checkpoint)"]
        end

        subgraph Stop ["8. Stop decision (resolveStopDecision)"]
            S1["Terminal step? (final answer, output)"]
            S2["stopWhen predicate? → completed"]
            S3["maxSteps reached? → failed (framework_max_steps_exhausted)"]
        end
    end

    Return["9. Return handle immediately<br/>(Execution continues in background)"]

    Start --> Init
    Init --> Loop
    Build --> LLM --> Process --> Tools --> Update --> Stop
    Stop -->|Continue| Build
    Loop --> Return

Parallel Tool Execution ​

The JS runtime executes tool calls in parallel:

typescript
// If LLM returns multiple tool calls:
// [search('topic A'), search('topic B'), analyze('data')]
// All three execute concurrently

This includes sub-agent calls - multiple sub-agents can run simultaneously.

Parallel state updates:

When parallel tools update state, the runtime uses delta merging:

  • Array pushes are accumulated (not overwritten)
  • Object properties are merged
  • Conflicts are resolved via last-write-wins

Sub-Agent Handling ​

Sub-agents execute recursively within the same process:

typescript
// Parent agent calls sub-agent tool
// JS runtime:
// 1. Detects sub-agent tool call
// 2. Creates new state for sub-agent (same streamId)
// 3. Recursively calls runLoop()
// 4. Sub-agent events stream to same stream
// 5. Sub-agent output becomes tool result

Sub-agents share the stream but have isolated state.

Persistent Sub-Agent Handling ​

Persistent sub-agents configured via persistentAgents are managed through companion tools (companion__spawnAgent, companion__sendMessage, etc.). In the JS runtime:

  • Blocking spawn: The child agent's full execution loop runs inline within the parent's step. The parent waits for the child to complete before continuing.
  • Non-blocking spawn: The child starts immediately and the parent continues. The child runs concurrently via Promise.resolve().
  • sendMessage: Sends a new user message to an already-running persistent child, triggering another execution loop.
  • Companion tool results are returned directly as tool results in the parent's conversation.

Persistent children are tracked via SubSessionRef entries with mode: 'persistent' in the state store.

Re-spawning a completed persistent child continues it on its preserved session (memory retained) rather than recreating it — see Re-consulting a persistent companion (the critic loop). A child without outputSchema that runs out of maxSteps ends failed, so it is re-spawned fresh (its memory is lost); give such a companion a stopWhen or a larger maxSteps if it must stay continuable.

Error Handling ​

Tool Errors ​

Tool errors are caught and returned to the LLM:

typescript
const searchTool = defineTool({
  name: 'search',
  execute: async (input) => {
    throw new Error('API rate limited');
  },
});

// LLM sees: "Tool 'search' failed: API rate limited"
// LLM can decide to retry, use different approach, etc.

Execution Errors ​

Fatal errors fail the agent:

typescript
try {
  const result = await handle.result();
} catch (error) {
  // LLM API failed, state store failed, etc.
}

Check result.status for graceful handling:

typescript
const result = await handle.result();
if (result.status === 'failed') {
  console.error('Agent failed:', result.error);
}

Error Classification ​

When LLM calls fail, errors are automatically classified into typed ErrorChunk events in the stream:

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

// Stream error chunks include classification when available
for await (const chunk of stream) {
  if (chunk.type === 'error') {
    console.log(chunk.error); // 'Provider overloaded'
    console.log(chunk.code); // 'provider_overloaded' (from HelixError classification)
    console.log(chunk.recoverable); // true (whether client can retry)
  }
}

The JS runtime's onError callback checks if the error is a HelixError instance. If so, the ErrorChunk includes code and recoverable from the classified error. For unclassified errors, code is omitted and recoverable defaults to false.

See Error Handling Guide for the complete error pipeline.

Completion-support refusal ​

execute(), resume(), retry() and handle.resume() throw a typed, non-retryable HelixError before any store read or write when an outputSchema agent's model has no completion strategy (framework_completion_strategy_unavailable, for example an unrecognised Claude model id) or its completion schema cannot be sent as native structured output (framework_completion_schema_unsupported). Sub-agents and persistent companions are checked with their own config and adapter when they start; a refusal surfaces to the parent as a failed delegation. An agent with an llmConfigOverride is not refused at entry, because the override may pick the forced call's model; a forced call whose override selects such a model fails that call with the same code instead.

Limitations ​

No Crash Recovery ​

If the process dies, in-flight executions are lost:

typescript
// Process starts
const handle = await executor.execute(agent, 'Long task');

// Process crashes here - execution is lost

// After restart, state exists but execution stopped
const reconnected = await executor.getHandle(agent, handle.sessionId);
// reconnected.canResume() returns true
// But original execution context is gone

Mitigation: Use Redis (or Postgres) stores to preserve state, then resume. The crashed run stops renewing its lease; until the lease lapses (up to runLease.ttlMs, 30 s by default) an entry is refused with AgentAlreadyRunningError, after it the entry supersedes the stranded run:

typescript
// After crash/restart
const handle = await executor.getHandle(agent, savedSessionId);
if (handle) {
  const { canResume } = await handle.canResume();
  if (canResume) {
    const resumed = await handle.resume();
    // Continues from last saved state
  }
}

No Distributed Execution ​

Everything runs in one process. For distributed execution, use Temporal.

No Per-Tool Timeouts ​

Tools run without individual timeout enforcement. Add your own:

typescript
const toolWithTimeout = defineTool({
  name: 'slow_api',
  execute: async (input, context) => {
    const timeoutPromise = new Promise((_, reject) =>
      setTimeout(() => reject(new Error('Tool timeout')), 30000)
    );

    const apiPromise = callSlowApi(input);

    return Promise.race([apiPromise, timeoutPromise]);
  },
});

Best Practices ​

1. Use Redis for Production ​

In-memory stores lose data on restart:

typescript
import { RedisStateStore, RedisStreamManager } from '@helix-agents/store-redis';

const executor = new JSAgentExecutor(
  new RedisStateStore(redis),
  new RedisStreamManager(redis),
  llmAdapter
);

2. Handle Abort Signals ​

Check abort signal in long-running tools:

typescript
execute: async (input, context) => {
  for (const item of items) {
    if (context.abortSignal.aborted) {
      throw new Error('Aborted');
    }
    await processItem(item);
  }
};

3. Set Appropriate maxSteps ​

Prevent runaway agents:

typescript
const agent = defineAgent({
  maxSteps: 20, // Reasonable limit for your use case
});

4. Monitor Step Count ​

Track execution progress:

typescript
// In your tool
const state = await handle.getState();
console.log(`Step ${state.stepCount} of ${agent.maxSteps}`);

Next Steps ​

Released under the MIT License.