Core Concepts
Before building agents, let's understand the five 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)
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-4o'),
temperature: 0.7,
},
maxSteps: 20,
});An agent definition is just data - it doesn't execute anything. You pass it to a runtime to actually run.
Tools
Tools are functions that agents can call. They're how agents interact with the world beyond generating text.
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 stateupdateState<T>(draft => {...})- Modify state using Immeremit(eventName, data)- Emit custom streaming eventsabortSignal- Check for cancellationagentId,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 historystepCount- Number of LLM calls madestatus- Running, completed, failed, etc.output- Final structured output (if any)
Custom State
You define additional state with a Zod schema:
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:
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 Type | Description |
|---|---|
text_delta | Incremental text from LLM |
thinking | Reasoning/thinking content (Claude, o-series) |
tool_start | Tool execution beginning |
tool_end | Tool execution complete (with result) |
subagent_start | Sub-agent invocation beginning |
subagent_end | Sub-agent complete (with output) |
custom | Custom events from tools |
state_patch | State changes (RFC 6902 format) |
error | Error occurred |
output | Final agent output |
Consume streams in your application:
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;
}
}Sub-Agents
Sub-agents enable hierarchical agent systems. A parent agent can delegate tasks to specialized child agents.
// 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-4o-mini') },
});
// 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:
- Creates a new agent run for the child
- Executes the child agent to completion
- Returns the child's output as the tool result
- 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.
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)
- Max steps reached
- Error occurs
↓
7. Final output emittedNext Steps
- Getting Started - Build your first agent
- Defining Agents - Deep dive into agent configuration
- Defining Tools - Complete tool reference