Overview
VoltAgent (@voltagent/core) lets a Node.js project define agents in code: a model, instructions, Zod-typed tools, a memory adapter, optional sub-agents and guardrails. A VoltAgent instance registers agents and workflows and, with @voltagent/server-hono, serves them over a REST API on port 3141 with Swagger UI at /ui. Workflows are declarative step chains that can suspend for a human decision and resume later. The companion VoltOps Console (console.voltagent.dev, cloud or self-hosted) connects to the local server for traces, chat testing and workflow runs; the framework itself is MIT-licensed and works without it.
Instructions
Installation
Scaffold a project (asks for provider, package manager and server; writes .env):
npm create voltagent-app@latest order-desk
cd order-desk
npm run dev
The generated project has src/index.ts, src/tools/, src/workflows/, and scripts dev (tsx watch --env-file=.env ./src), build (tsdown), start (node dist/index.js) and typecheck. --example <name> starts from a folder of the repo's examples/ directory, e.g. npm create voltagent-app@latest -- --example with-research-assistant.
Adding VoltAgent to an existing project instead:
npm install @voltagent/core @voltagent/server-hono @voltagent/libsql @voltagent/logger zod
Put the provider key in .env: OPENAI_API_KEY (platform.openai.com/api-keys), ANTHROPIC_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, GROQ_API_KEY or MISTRAL_API_KEY.
Define an agent with a tool and memory
Models can be given as "provider/model" strings (resolved by VoltAgent's built-in provider registry) or as AI SDK model objects.
import { VoltAgent, Agent, Memory, createTool } from "@voltagent/core";
import { LibSQLMemoryAdapter } from "@voltagent/libsql";
import { honoServer } from "@voltagent/server-hono";
import { z } from "zod";
const lookupOrder = createTool({
name: "lookupOrder",
description: "Look up an order by ID and return its status and carrier",
parameters: z.object({ orderId: z.string().describe("Order ID such as ORD-48213") }),
execute: async ({ orderId }) => {
const res = await fetch(`${process.env.ORDERS_API_URL}/orders/${orderId}`);
return res.json();
},
});
const memory = new Memory({
storage: new LibSQLMemoryAdapter({ url: "file:./.voltagent/memory.db" }),
});
const support = new Agent({
name: "order-support",
instructions: "Answer order questions. Always call lookupOrder before answering.",
model: "openai/gpt-4o-mini",
tools: [lookupOrder],
memory,
});
new VoltAgent({ agents: { support }, server: honoServer({ hostname: "127.0.0.1" }) });
Without memory, an agent keeps history in process memory only; memory: false disables it. LibSQLMemoryAdapter also accepts a Turso url plus authToken.
Call an agent from code or over HTTP
import { Output } from "ai";
const reply = await support.generateText("Where is order ORD-48213?", {
memory: { userId: "cust-5521", conversationId: "ticket-9912" },
});
console.log(reply.text);
const stream = await support.streamText("Summarize my last three orders", {
memory: { userId: "cust-5521", conversationId: "ticket-9912" },
});
for await (const chunk of stream.textStream) process.stdout.write(chunk);
const triage = await support.generateText("Classify: my parcel arrived damaged", {
output: Output.object({ schema: z.object({ category: z.enum(["delivery", "damage", "billing"]) }) }),
});
console.log(triage.output.category);
Top-level userId/conversationId options still work but are deprecated in core 2.11; use the memory envelope. Structured output is generateText/streamText with an output setting; generateObject/streamObject are deprecated. The same calls are exposed by the server:
curl -s -X POST http://localhost:3141/agents/order-support/text \
-H "Content-Type: application/json" \
-d '{"input":"Where is order ORD-48213?","options":{"memory":{"userId":"cust-5521","conversationId":"ticket-9912"}}}'
Other routes: GET /agents, POST /agents/:id/stream, POST /agents/:id/object, GET /workflows.
Supervisor and sub-agents
Passing agents in subAgents gives the supervisor an automatic delegate_task tool; it picks which specialist gets each part of the task.
const billing = new Agent({
name: "billing",
instructions: "Handle invoices, refunds and payment failures.",
model: "openai/gpt-4o-mini",
});
const lead = new Agent({
name: "support-lead",
instructions: "Route each question to order-support or billing, then write one reply.",
model: "anthropic/claude-sonnet-4-5",
subAgents: [support, billing],
supervisorConfig: { customGuidelines: ["Never promise a refund amount"] },
});
MCP tools
import { MCPConfiguration } from "@voltagent/core";
const mcp = new MCPConfiguration({
servers: {
filesystem: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "./policies"],
},
},
});
const policyTools = await mcp.getTools(); // names are prefixed: filesystem_read_file, ...
Pass policyTools into an agent's tools. Remote servers use type: "http" (or "sse", "streamable-http") with a url. Call mcp.disconnect() on shutdown.
Guardrails
import { createInputGuardrail, createInputLengthGuardrail } from "@voltagent/core";
const noCardNumbers = createInputGuardrail({
name: "block-card-numbers",
handler: async ({ inputText }) =>
/\b\d{16}\b/.test(inputText ?? "")
? { pass: false, action: "block", message: "Please do not paste card numbers." }
: { pass: true },
});
// new Agent({ ..., inputGuardrails: [createInputLengthGuardrail({ maxCharacters: 2000 }), noCardNumbers] })
A blocked input makes generateText throw with code GUARDRAIL_INPUT_BLOCKED. outputGuardrails work the same way on responses; ready-made ones include createPIIInputGuardrail, createEmailRedactorGuardrail and createSensitiveNumberGuardrail.
Workflows with human approval
import { createWorkflowChain } from "@voltagent/core";
import { z } from "zod";
export const refundApproval = createWorkflowChain({
id: "refund-approval",
name: "Refund Approval",
purpose: "Auto-approve small refunds, pause large ones for a reviewer",
input: z.object({ orderId: z.string(), amount: z.number() }),
result: z.object({ status: z.enum(["approved", "rejected"]), approvedBy: z.string() }),
})
.andThen({
id: "check-amount",
resumeSchema: z.object({ approved: z.boolean(), reviewer: z.string() }),
execute: async ({ data, suspend, resumeData }) => {
if (resumeData) return { ...data, approved: resumeData.approved, approvedBy: resumeData.reviewer };
if (data.amount > 200) await suspend("Refund over $200 needs review", { orderId: data.orderId });
return { ...data, approved: true, approvedBy: "auto" };
},
})
.andThen({
id: "finalize",
execute: async ({ data }) => ({
status: data.approved ? ("approved" as const) : ("rejected" as const),
approvedBy: data.approvedBy,
}),
});
Register it with new VoltAgent({ workflows: { refundApproval } }) to run it from the Console or REST. Other step builders: andAgent (call an agent inside a step), andAll and andRace (parallel), andWhen and andBranch (conditions), andForEach, andSleep, andTap.
Examples
Example 1: Order-status agent the frontend can call
Request: "Build me a TypeScript agent that answers 'where is my order' questions using our orders API and remembers each customer's ticket."
npm create voltagent-app@latest order-desk, pick OpenAI, then replacesrc/index.tswith the agent from "Define an agent with a tool and memory" and setORDERS_API_URL=https://orders.internal.shopnorth.ioin.env.npm run devprintsVOLTAGENT SERVER STARTED SUCCESSFULLY,HTTP Server: http://localhost:3141andSwagger UI: http://localhost:3141/ui.- The frontend posts to
/agents/order-support/text. The response looks like:
{"success":true,"data":{"text":"Order ORD-48213 shipped via UPS, arriving 2026-10-03.","finishReason":"stop","toolCalls":[{"toolName":"lookupOrder","input":{"orderId":"ORD-48213"}}]}}
Follow-up messages with the same conversationId reuse the history stored in .voltagent/memory.db.
Example 2: Refund workflow that waits for a manager
Request: "Refunds under $200 should go through automatically; bigger ones must wait until Maria approves them in our admin panel."
With the refundApproval workflow registered and the server running:
curl -s -X POST http://localhost:3141/workflows/refund-approval/execute \
-H "Content-Type: application/json" \
-d '{"input":{"orderId":"ORD-48377","amount":640}}'
{"success":true,"data":{"executionId":"40be49cb-17d4-49ea-ae6b-5f278a92e993","status":"suspended","result":null}}
The admin panel stores the executionId; when Maria decides, it resumes:
curl -s -X POST http://localhost:3141/workflows/refund-approval/executions/40be49cb-17d4-49ea-ae6b-5f278a92e993/resume \
-H "Content-Type: application/json" \
-d '{"resumeData":{"approved":true,"reviewer":"maria.lopez"}}'
{"success":true,"data":{"status":"completed","result":{"status":"approved","approvedBy":"maria.lopez"}}}
In code the same flow is const wf = refundApproval.toWorkflow(), register wf with new VoltAgent({ workflows: { refundApproval: wf } }), then const run = await wf.run(input) and await run.resume({ approved: true, reviewer: "maria.lopez" }). A $45 refund returns status: "completed" immediately with approvedBy: "auto".
Guidelines
- Pin one major version across the
@voltagent/*packages. Core 2.x needsai6.x; the npmlatesttag ofaiand@ai-sdk/openaihas moved to the next major, so if you pass AI SDK model objects install theai-v6tagged versions (npm install ai@ai-v6 @ai-sdk/openai@ai-v6) or use"provider/model"strings, which need no extra provider package. - Resuming from code needs the workflow registered on a
VoltAgentinstance and run through the same object:const wf = chain.toWorkflow(), registerwf, callwf.run(). Resuming a chain that was never registered fails with "Workflow not found"; running the chain whileVoltAgentholds its own copy fails with "Workflow state not found". Over REST this is handled for you. - Suspension data is stored in the workflow's memory. Pass a persistent
Memory(for example the LibSQL one above) asmemoryincreateWorkflowChain({...})so approvals that wait for days survive a restart. - Without auth every endpoint is open, including
POST /tools/:name/execute(runs a tool directly) and/api/memory/*(reads stored conversations), andhonoServer()listens on0.0.0.0, not localhost. For local-only use passhonoServer({ hostname: "127.0.0.1" }). Before exposing it, addauthNext: { provider: jwtAuth({ secret: process.env.JWT_SECRET! }) }(jwtAuthis exported by@voltagent/server-hono) and run withNODE_ENV=production: in any other environment a request with the headerx-voltagent-dev: trueor?dev=trueskips authNext. - Tools run with your process's permissions. Validate inputs in
execute, keep write actions narrow, and useneedsApprovalon tools that change data. - Keep keys in
.env(git-ignored). VoltOps keys (VOLTAGENT_PUBLIC_KEY,VOLTAGENT_SECRET_KEY, from console.voltagent.dev) are only needed to send traces to VoltOps. maxStepscaps tool-call loops per request; set it on agents whose tools can fail repeatedly.- The official docs MCP server (
npx -y @voltagent/docs-mcp) gives a coding agent current VoltAgent docs; use it when the API in this skill looks out of date. The repo README still shows the old name@voltagent/mcp-docs-server, which is not on npm. - Not the right tool for Python stacks (use LangGraph, CrewAI or PydanticAI), for a single prompt-and-response call (the AI SDK alone is lighter), or when you need a visual no-code builder.