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', {
  runId: 'custom-run-id', // Custom run ID
  parentStreamId: 'parent-stream', // For sub-agent streaming
  parentAgentId: 'parent-run-id', // Parent agent reference
});

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

Reconnecting to Runs

Use getHandle() to reconnect to an existing run:

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

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

Execution Flow

Here's how the JS runtime executes an agent:

execute() called

    ├── 1. Initialize state
    │   ├── Create run ID and stream ID
    │   ├── Parse initial state from schema defaults
    │   ├── Add user message
    │   └── Save state to store

    ├── 2. Start execution loop (async)
    │   │
    │   └── While status === 'running':
    │       │
    │       ├── 3. Build messages
    │       │   ├── Add system prompt
    │       │   └── Include conversation history
    │       │
    │       ├── 4. Call LLM
    │       │   ├── Stream text deltas
    │       │   └── Get tool calls
    │       │
    │       ├── 5. Process step result
    │       │   ├── Check for __finish__ tool
    │       │   ├── Extract output if complete
    │       │   └── Plan tool executions
    │       │
    │       ├── 6. Execute tools (parallel)
    │       │   ├── Regular tools: execute directly
    │       │   └── Sub-agent tools: recursive execute()
    │       │
    │       ├── 7. Update state
    │       │   ├── Add assistant message
    │       │   ├── Add tool results
    │       │   └── Save to store
    │       │
    │       └── 8. Check stop conditions
    │           ├── maxSteps reached?
    │           ├── stopWhen predicate?
    │           └── Output produced?

    └── 9. Return handle immediately
        └── Execution continues in background

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.runId);
// 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, savedRunId);
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.