What it does
VoltAgent is a TypeScript framework for building AI agents — with memory, Zod-typed tools, MCP support, sub-agent delegation, and a workflow engine that can suspend mid-execution and resume from external input. It runs a local HTTP server (port 3141 by default) and connects to an optional cloud console for observability and trace inspection.
Why I starred it
Most agent frameworks paper over the hard problems. They give you a way to call tools and chain LLM completions, but the moment you need a human approval gate in the middle of a workflow, you're on your own. VoltAgent's workflow engine has suspend() and resume() as first-class primitives.
The other thing: the sub-agent delegation model isn't just recursive tool calls. There's an explicit SubAgentManager that registers agents in a global AgentRegistry, enriches stream metadata across agent boundaries, and forwards observability spans up through the supervisor — so the parent agent's trace includes what the sub-agents did.
How it works
The entry point is new VoltAgent({ agents, workflows, server, logger }) in packages/core/src/voltagent.ts. It registers agents, sets up routes, and starts the Hono server. From there, the runtime is centered on Agent in packages/core/src/agent/agent.ts.
agent.ts is large (~160 imports) but structured. The execution path for a streamText call runs through:
- Input middleware (
runInputMiddlewares) - Input guardrails (
executeInputGuardrails) - Memory retrieval and conversation buffer assembly
- The
streamText/generateTextcall via the AI SDK - Output guardrails — including a streaming pipeline via
createGuardrailPipelineinstreaming/guardrail-stream.ts - Memory persistence queue flush
The middleware and guardrail layers are separate types with different contracts. Middlewares can rewrite input/output; guardrails validate and block. The createInputGuardrail and createOutputGuardrail helpers in agent/guardrail.ts attach optional streamHandler callbacks to output guardrails, so you can intercept streaming output chunk-by-chunk without buffering the full response.
Sub-agent delegation lives in agent/subagent/index.ts. The SubAgentManager wraps each registered sub-agent as a Tool (via createTool) so the parent LLM can call it like any other function. The wrapping is automatic when you declare subAgents on an Agent. What's non-obvious: the stream from the sub-agent gets piped through createMetadataEnrichedStream in stream-metadata-enricher.ts, which injects subAgentId, subAgentName, agentPath, and parentAgentId into every chunk before forwarding upstream. That's what makes the console's trace view show the full agent hierarchy rather than a flat log.
The workflow engine is in packages/core/src/workflow/. createWorkflowChain returns a WorkflowChain class with a fluent builder API — .andThen(), .andWhen(), .andAgent(), .andMap(), .andRace(), .andSleep(), and more. Each step receives { data, suspend, resumeData }. The suspend mechanism in suspend-controller.ts wraps the native AbortController:
export function createSuspendController(): WorkflowSuspendController {
const abortController = new AbortController();
let suspensionReason: string | undefined;
let suspended = false;
return {
signal: abortController.signal,
suspend: (reason?: string) => {
if (!suspended && !cancelled) {
suspensionReason = reason;
suspended = true;
abortController.abort({ type: "suspended", reason });
}
},
isSuspended: () => suspended,
getReason: () => suspensionReason,
// ...
};
}
When a step calls await suspend("Manager approval required", payload), the controller fires the abort signal with type: "suspended". The workflow runtime catches that, serializes the current step state, and returns a WorkflowExecutionResult with status: "suspended". The caller can store this state and call workflow.resume(executionId, resumeData) later. The resumed execution re-enters the same step with resumeData populated.
The tool routing layer (packages/core/src/tool/routing/) adds semantic search over large tool sets. If you configure toolRouting on an agent with an embedding strategy, the runtime inserts two internal tools — __search_tools__ and __call_tool__ — and the model finds relevant tools by description similarity rather than being handed the full list. The constants file uses Symbol keys (TOOL_ROUTING_INTERNAL_TOOL_SYMBOL) to mark these as non-exposed in the API surface.
Using it
Scaffold a new project:
npm create voltagent-app@latest
cd my-agent-app
npm run dev
Define an agent with a tool and a guardrail:
import { Agent, createTool, createInputGuardrail } from "@voltagent/core";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
const searchTool = createTool({
name: "web_search",
description: "Search the web for current information",
parameters: z.object({ query: z.string() }),
execute: async ({ query }) => fetchResults(query),
});
const topicGuardrail = createInputGuardrail({
name: "topic-filter",
handler: async ({ input }) => {
const text = typeof input === "string" ? input : JSON.stringify(input);
if (text.includes("competitor")) {
return { success: false, message: "Topic not allowed" };
}
return { success: true };
},
});
const agent = new Agent({
name: "research-agent",
instructions: "You are a research assistant.",
model: openai("gpt-4o"),
tools: [searchTool],
inputGuardrails: [topicGuardrail],
});
A workflow with a human approval gate:
const approvalWorkflow = createWorkflowChain({
id: "publish-approval",
input: z.object({ content: z.string(), authorId: z.string() }),
result: z.object({ published: z.boolean(), reviewedBy: z.string() }),
})
.andThen({
id: "check-content",
resumeSchema: z.object({ approved: z.boolean(), reviewerId: z.string() }),
execute: async ({ data, suspend, resumeData }) => {
if (resumeData) {
return { ...data, approved: resumeData.approved, reviewedBy: resumeData.reviewerId };
}
await suspend("Needs editorial review", { content: data.content });
return { ...data, approved: true, reviewedBy: "system" };
},
})
.andThen({
id: "publish",
execute: async ({ data }) => ({
published: data.approved,
reviewedBy: data.reviewedBy,
}),
});
Rough edges
The framework is actively developed — weekly releases, recent commits adding prepareStep at agent level, fixing Cloudflare Workers edge cases, metadata persistence. That velocity is good but the API has spots that show it. The agent.ts file is ~2,000 lines with 160 imports. It's readable, but contributing to it means navigating a lot of surface area.
The VoltOps console is a SaaS product. The open-source framework works standalone, but the observability dashboard, deployment, and eval features require connecting to console.voltagent.dev. There's a self-hosted path mentioned but the docs for it are thin at time of writing.
Memory adapters are first-class (LibSQLAdapter, in-memory), but the built-in semantic memory path requires setting up vector search separately. The agent-semantic-search.spec.ts and memory/ directory show it's there, but it's not a zero-config feature.
MCP support exists (@voltagent/mcp-docs-server, tool configuration via mcpTools), but connecting to arbitrary MCP servers requires working through the config API — it's not a single npx connect-mcp-server command yet.
Bottom line
VoltAgent is the right choice if you're building TypeScript agent systems that need structured multi-agent delegation, production observability, or workflows with external approval gates. The suspend/resume workflow primitive alone puts it ahead of most open-source alternatives for anything beyond a linear chain.
