Skip to content

Core Concepts ​

Before building agents, let's understand the seven core concepts in Helix Agents.

Agents ​

An agent is a configuration object that defines how an AI assistant behaves. It specifies:

  • What the agent knows (system prompt)
  • What it can do (tools)
  • What data it tracks (state schema)
  • What it produces (output schema)
  • How it thinks (LLM configuration)
typescript
import { defineAgent } from '@helix-agents/core';
import { z } from 'zod';

const ResearchAgent = defineAgent({
  name: 'researcher',
  systemPrompt: 'You are a research assistant. Search for information and summarize findings.',
  tools: [searchTool, summarizeTool],
  stateSchema: z.object({
    searchCount: z.number().default(0),
    findings: z.array(z.string()).default([]),
  }),
  outputSchema: z.object({
    summary: z.string(),
    sources: z.array(z.string()),
  }),
  llmConfig: {
    model: openai('gpt-6-luna'),
  },
  maxSteps: 20,
});

An agent definition is just data - it doesn't execute anything. You pass it to a runtime to actually run.

Agents can also declare a workspace (plus a workspaceOpenStrategy) to give the LLM file, shell, code, or snapshot tools — see the Workspaces guide.

Tools ​

Tools are functions that agents can call. They're how agents interact with the world beyond generating text.

typescript
import { defineTool } from '@helix-agents/core';
import { z } from 'zod';

const searchTool = defineTool({
  name: 'search',
  description: 'Search the web for information',
  inputSchema: z.object({
    query: z.string().describe('Search query'),
    maxResults: z.number().default(5),
  }),
  outputSchema: z.object({
    results: z.array(
      z.object({
        title: z.string(),
        url: z.string(),
        snippet: z.string(),
      })
    ),
  }),
  execute: async (input, context) => {
    // Perform the search
    const results = await performSearch(input.query, input.maxResults);
    return { results };
  },
});

Tools receive a context object that provides:

  • getState<T>() - Read current agent state
  • updateState<T>(draft => {...}) - Modify state using Immer
  • emit(eventName, data) - Emit custom streaming events
  • abortSignal - Check for cancellation
  • agentId, agentType - Execution context

State ​

State is data that persists across an agent's execution steps. There are two types:

Built-in State ​

Every agent automatically tracks:

  • messages - Conversation history
  • stepCount - Number of LLM calls made
  • status - Running, completed, failed, etc.
  • output - Final structured output (if any)

Custom State ​

You define additional state with a Zod schema:

typescript
const stateSchema = z.object({
  searchCount: z.number().default(0),
  findings: z.array(z.string()).default([]),
  currentTopic: z.string().optional(),
});

Tools can read and modify this state:

typescript
execute: async (input, context) => {
  // Read state
  const state = context.getState<typeof stateSchema>();
  console.log(`Search count: ${state.searchCount}`);

  // Update state using Immer's draft pattern
  context.updateState<typeof stateSchema>((draft) => {
    draft.searchCount++;
    draft.findings.push(input.query);
  });

  return { results };
};

State is persisted to the StateStore after each step, enabling resume after crashes.

Streaming ​

Streaming provides real-time visibility into agent execution. The framework emits typed events:

Event TypeDescription
text_deltaIncremental text from LLM
thinkingReasoning/thinking content (Claude, o-series)
tool_startTool execution beginning
tool_endTool execution complete (with result)
subagent_startSub-agent invocation beginning
subagent_endSub-agent complete (with output)
customCustom events from tools
state_patchState changes (RFC 6902 format)
errorError occurred
outputFinal agent output
run_interruptedAgent was interrupted by user
run_resumedAgent resumed from checkpoint
checkpoint_createdReserved; no runtime emits it today

Consume streams in your application:

typescript
const handle = await executor.execute(agent, 'Research AI agents');
const stream = await handle.stream();

for await (const chunk of stream) {
  switch (chunk.type) {
    case 'text_delta':
      process.stdout.write(chunk.delta);
      break;
    case 'tool_start':
      console.log(`\nCalling tool: ${chunk.toolName}`);
      break;
    case 'tool_end':
      console.log(`Tool result:`, chunk.result);
      break;
    case 'output':
      console.log('\nFinal output:', chunk.output);
      break;
  }
}

Sessions ​

A session is the primary unit of conversation state. It contains all messages, custom state, and checkpoints for an agent conversation.

Session vs Run ​

  • Session: A conversation container identified by sessionId. Persists all conversation state.
  • Run: A single execution within a session. Multiple runs can occur in one session (after interrupts, resumes, or follow-up messages).
typescript
// Start a new session
const handle = await executor.execute(agent, { message: 'Hello' });
console.log(handle.sessionId); // e.g., 'ses_abc123'

// Continue the same session with a follow-up
const handle2 = await executor.execute(
  agent,
  { message: 'Tell me more' },
  {
    sessionId: handle.sessionId, // Reuse the session
  }
);
// handle2 continues from where handle left off, with full message history

Why Sessions Matter ​

  1. Efficient Storage: Messages are stored once per session, not duplicated per run (O(n) vs O(n²)).
  2. Natural Continuity: Reusing a sessionId automatically loads the conversation history.
  3. Clean Isolation: Each session (including sub-agent sessions) has independent state.

Run Tracking ​

Each entry into a session (execute(), resume() or retry()) creates a run. The run is created by the entry's run-start commit, which appends the entry's input and creates the run record in one atomic step and is the only gate between concurrent callers: it also refuses an entry whose session or current run changed since it planned (CP-96). The loser writes nothing and gets AgentAlreadyRunningError (or, for a changed session status, RunStartRejectedError('entry_status_changed')). A new run supersedes the previous one, whose record then reads superseded.

typescript
// Get the current (latest) run for a session
const currentRun = await stateStore.getCurrentRun(sessionId);
// { runId, turn, status, startSequence, stepCount, ... }

// List all runs in a session's history
const { runs, hasMore } = await stateStore.listRuns(sessionId, { limit: 10 });

Run fields:

FieldPurpose
runIdUnique identifier for this execution
turnCounter (1, 2, 3...) for each run in the session
statusLive: running, suspended_client_tool, suspended_awaiting_children, suspended_step_partial. Terminal: completed, failed, interrupted, superseded
startSequenceStream head at the run-start commit (for filtering chunks)
startMessageCountMessage count after the run's input rows
stepCountNumber of LLM steps in this run

Use cases for run tracking:

  • Multi-run UI: Show execution history for a session
  • Content deduplication: Use startSequence to filter stream chunks to current run only
  • Debugging: Trace which run produced which messages

Branching ​

You can create a new session by branching from an existing one. This is useful for "what-if" scenarios or creating variations of a conversation.

typescript
// Branch from a specific checkpoint
const forked = await executor.execute(
  agent,
  { message: 'What if instead...' },
  {
    branch: {
      fromSessionId: handle.sessionId,
      checkpointId: 'cp_xyz', // Optional: omit to copy everything
    },
  }
);
// forked is a NEW session with state copied from the checkpoint

Key branching behaviors:

  • A new sessionId is generated for the forked session
  • State and messages up to the checkpoint are copied to the new session: exactly the checkpoint's first messageCount messages and its customState. branch.messageIndex copies the first N messages instead (checkpointId wins when both are set); with neither, everything is copied
  • The checkpoint must belong to fromSessionId: another session's checkpoint is rejected with state_checkpoint_foreign before anything is written
  • The original session remains unchanged and can continue independently
  • Checkpoints are not copied—the new session starts fresh for checkpoint tracking
  • Both sessions can continue in parallel with diverging histories
typescript
// Example: Exploring different conversation paths
const original = await executor.execute(
  agent,
  { message: 'Plan a trip to Japan' },
  {
    sessionId: 'trip-planning',
  }
);
await original.result();

// Original continues with budget focus
const budget = await executor.execute(
  agent,
  { message: 'Focus on budget options' },
  {
    sessionId: 'trip-planning',
  }
);

// Fork to explore luxury options without affecting original
const luxury = await executor.execute(
  agent,
  { message: 'Focus on luxury options' },
  {
    branch: { fromSessionId: 'trip-planning' },
  }
);
// luxury has its own sessionId and can evolve independently

Cloning Sessions ​

For programmatic session copying, you can use stateStore.cloneSession():

typescript
// Clone entire session
await stateStore.cloneSession('source-session', 'target-session');

// Clone up to a specific checkpoint
await stateStore.cloneSession('source-session', 'target-session', {
  fromCheckpointId: 'cp_xyz',
});

// Clone first N messages only
await stateStore.cloneSession('source-session', 'target-session', {
  fromMessageIndex: 10,
});

A clone never takes the source's live status, output, error or forced-completion phase. A checkpoint clone takes them from the checkpoint (its result or error only at a completed or failed checkpoint); a message-index or whole-log clone starts active with the source's current customState and step count. See Branching from a Checkpoint.

Sub-Agents ​

Sub-agents enable hierarchical agent systems. A parent agent can delegate tasks to specialized child agents.

typescript
// Define a specialized sub-agent
const AnalyzerAgent = defineAgent({
  name: 'analyzer',
  systemPrompt: 'You analyze text for sentiment and key topics.',
  outputSchema: z.object({
    sentiment: z.enum(['positive', 'negative', 'neutral']),
    topics: z.array(z.string()),
  }),
  llmConfig: { model: openai('gpt-6-luna') },
});

// Create a tool that invokes the sub-agent
import { createSubAgentTool } from '@helix-agents/core';

const analyzeTool = createSubAgentTool(AnalyzerAgent, z.object({ text: z.string() }), {
  description: 'Analyze text for sentiment and topics',
});

// Parent agent uses the sub-agent tool
const ResearchAgent = defineAgent({
  name: 'researcher',
  tools: [searchTool, analyzeTool], // Include sub-agent tool
  // ...
});

When the parent's LLM calls subagent__analyzer, the framework:

  1. Creates a new agent run for the child
  2. Executes the child agent to completion
  3. Returns the child's output as the tool result
  4. Child events stream to the same stream as the parent

Sub-agents have isolated state - the child's state doesn't affect the parent's.

Persistent Sub-Agents ​

For longer-lived children that need follow-up messages or management, use persistent sub-agents configured via persistentAgents:

typescript
const OrchestratorAgent = defineAgent({
  name: 'orchestrator',
  persistentAgents: [
    { agent: ResearcherAgent, mode: 'blocking' }, // Parent waits for result
    { agent: MonitorAgent, mode: 'non-blocking' }, // Runs in background
  ],
  // ...
});

When persistentAgents is configured, companion tools are auto-injected (prefixed with companion__). Five are always injected; a sixth, waitForResult, is added only when at least one persistent child is mode: 'blocking':

  • spawnAgent -- Create and start a child (always)
  • sendMessage -- Send a follow-up message to a child (always)
  • listChildren -- List all persistent children (always)
  • getChildStatus -- Check a specific child's output/status (always)
  • terminateChild -- Stop a running child (always)
  • waitForResult -- Block until a child completes (only when a child is mode: 'blocking')

The parent's LLM decides when to use these tools. Children maintain state across multiple interactions. Re-consulting a child that has already completed does not start over — sendMessage/spawnAgent to a completed child continues it on its preserved session (its memory is retained, and it produces a fresh typed output). Only failed or terminated children error (a companion without outputSchema that runs out of maxSteps ends failed, so it is re-spawned fresh rather than continued; see the critic-loop recipe). This is what powers the maker → critic loop, where a parent re-consults the same critic across rounds.

See Sub-Agent Orchestration for the full guide, and the critic-loop recipe for the re-consult pattern.

Skills ​

Skills give an agent a library of specialized capabilities — workflows, runbooks, reference protocols — without paying for all of them on every request. The model always sees a small catalog (each skill's name + description), and loads a skill's full instructions on demand via the auto-injected load_skill tool only when a task matches. Every load is append-only and cache-safe.

typescript
const agent = defineAgent({
  name: 'assistant',
  // SkillProvider OR an array of in-code skill definitions
  skills: [
    {
      name: 'pdf-processing',
      description: 'Extract text and tables from PDFs. Use when working with PDF files.',
      body: '# PDF processing\n…full instructions…',
    },
  ],
});

Skills can be defined in-code (Workers-safe) or loaded from SKILL.md directories on disk. Skills you want always in context can be injected up front with preloadSkills (their bodies render every step and the catalog marks them loaded="true"), and remote skill packages (git repos / Claude plugin marketplaces) can be baked into the in-code provider at build time via @helix-agents/skill-cli. See Skills for the full guide.

Hooks ​

Hooks are callback functions that let you observe and react to agent execution events. Use them for logging, metrics, auditing, and tracing.

typescript
const agent = defineAgent({
  name: 'researcher',
  systemPrompt: 'You are a research assistant.',
  hooks: {
    onAgentStart: (payload, ctx) => {
      console.log(`[${ctx.sessionId}] Starting with input: ${payload.input}`);
    },
    onAgentComplete: (payload, ctx) => {
      console.log(`[${ctx.sessionId}] Completed in ${payload.durationMs}ms`);
    },
    beforeTool: (payload, ctx) => {
      console.log(`[${ctx.sessionId}] Calling: ${payload.tool.name}`);
    },
    afterTool: (payload, ctx) => {
      console.log(`[${ctx.sessionId}] ${payload.tool.name}: ${payload.success ? 'OK' : 'FAILED'}`);
    },
  },
  // ...
});

Available hooks include:

HookWhen Invoked
onAgentStartBefore first LLM call
onAgentCompleteAgent finished successfully
onAgentFailAgent failed with error
beforeLLMCallBefore each LLM call
afterLLMCallAfter each LLM response
beforeTool / afterToolAround tool execution
beforeSubAgent / afterSubAgentAround sub-agent execution
onStateChangeWhen state is modified
onMessageWhen message is added

Hooks receive a context object similar to tools, with access to state and streaming capabilities.

Interrupt and Resume ​

Interrupt/resume enables user-controlled pauses, crash recovery, and time-travel debugging.

Interrupt ​

Soft stop that saves state for later resumption:

typescript
const handle = await executor.execute(agent, 'Research AI');

// Interrupt the running agent
await handle.interrupt('user_requested');

// Status is now 'interrupted'

Resume ​

Continue execution from where it stopped:

typescript
// Resume from last checkpoint
const newHandle = await handle.resume();
const result = await newHandle.result();

Resume modes:

  • continue - Resume from where it stopped (default)
  • with_message - Resume with a new user message
  • with_confirmation - Resume with data for a pending tool
  • from_checkpoint - Time-travel to a specific checkpoint

Retry ​

Recover from failed executions:

typescript
const result = await handle.result();
if (result.status === 'failed') {
  // Retry from the failed run's last committed step
  const retryHandle = await handle.retry({
    message: 'Research quantum computing',
  });
}

retry() restores the failed run's last step checkpoint, or its run-start commit when it failed on its first step, never the terminal checkpoint the failure wrote. After a first-step failure, retry() without message re-sends the run's own triggering message. In every other case it needs message and throws validation_error without one. An explicit checkpointId must belong to the session (state_checkpoint_foreign otherwise) and is restored as given. This works the same on every runtime, DBOS included. See Retrying Failed Agents.

Agent Lifecycle Methods ​

MethodUse WhenValid From Status (portable contract)
execute()New conversation or continuationcompleted (portable) — JS additionally accepts paused / interrupted / failed¹
resume()Continue after interrupt/pauseinterrupted, paused
retry()Recover from failurefailed

¹ The portable cross-runtime guarantee is that execute(sessionId) continues a session whose prior run reached completed — it works identically on JS, DBOS, Temporal, the Cloudflare Durable Object runtime and Cloudflare Workflows. runtime-js, the CF DO and CF Workflows also continue paused (with no pending client-tool calls) / interrupted / failed sessions; that broader set is not part of the cross-runtime promise. Use resume() for paused/interrupted runs and retry() for failed runs on durable runtimes.

Quick Reference ​

typescript
// New conversation
const h = await executor.execute(agent, message, { sessionId: 'new-id' });

// Continue conversation
const h = await executor.execute(agent, 'follow up', { sessionId: 'existing-id' });

// Resume after interrupt
const h = await executor.resume(agent, sessionId);

// Retry after failure
const h = await executor.retry(agent, sessionId);

Checkpoints ​

Checkpoints are complete state snapshots, written by every commit (each run's start, each step, its terminal state, a rewind). They enable:

  • Crash recovery - Resume after process restarts
  • Time-travel - Go back to any previous step
  • Branching - Fork execution from a historical point
typescript
// List all checkpoints for a session
const checkpoints = await stateStore.listCheckpoints(sessionId);

// Resume from a specific checkpoint
const newHandle = await handle.resume({
  mode: 'from_checkpoint',
  checkpointId: checkpoints.checkpoints[0].id,
});

Putting It Together ​

Here's how these concepts work together in a typical execution:

1. Agent receives input message
   ↓
2. LLM generates response (streaming text_delta events)
   ↓
3. LLM requests tool calls
   ↓
4. Tools execute (streaming tool_start/tool_end events)
   - Tools can read/modify state
   - Tools can emit custom events
   - Sub-agent tools spawn child executions
   ↓
5. Tool results added to conversation
   ↓
6. Loop back to step 2 until:
   - LLM calls __finish__ tool (structured output), or gives a final text answer
   - The agent's stopWhen predicate fires (completed)
   - Max steps reached (no outputSchema: failed, framework_max_steps_exhausted;
     with outputSchema: forced completion)
   - Error occurs
   ↓
7. Final output emitted

A completed structured-output agent is not a dead end — it can be continued as a new turn (preserving its memory, producing a fresh typed result). This is how a root agent resumes via execute({ sessionId }), and how a persistent companion re-consult works. See Interrupt & Resume — Continuing a completed agent and the critic-loop recipe.

Next Steps ​

Released under the MIT License.