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: StateStore,
  streamManager: StreamManager,
  llmAdapter: LLMAdapter
)

Parameters:

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 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();
}

Options:

  • mode: 'from_checkpoint' (default) - Restore from checkpoint
  • mode: 'from_start' - Clear state and start fresh
  • checkpointId - Specific checkpoint to restore from
  • message - Replacement message

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' },
]);

// With state override
const handle2 = await handle1.send('Continue', { state: { mood: 'curious' } });

State inheritance: Both string and Message[] inputs inherit state from the source run when no explicit state is provided. Use the state option to override.

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().

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"

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.

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:

  • Sessions contain all messages and state for a conversation
  • Each execution creates a new run within the session (for debugging, billing, tracing)
  • messages 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

Method Comparison

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

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

MethodMechanism
execute()Status check + StaleStateError handling
resume()CAS (compareAndSetStatus)
retry()CAS (compareAndSetStatus)

When concurrent calls race past status checks, optimistic locking via version numbers ensures only one succeeds.

Execution Flow

Here's how the JS runtime executes an agent:

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

    subgraph Init ["1. Initialize state"]
        I1["Create run ID and stream ID"]
        I2["Parse initial state from schema defaults"]
        I3["Add user message"]
        I4["Save state to store"]
    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. Update state"]
            U1["Add assistant message"]
            U2["Add tool results"]
            U3["Save to store"]
        end

        subgraph Stop ["8. Check stop conditions"]
            S1["maxSteps reached?"]
            S2["stopWhen predicate?"]
            S3["Output produced?"]
        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.

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);
}

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 stores to preserve state, then resume:

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.