writing/tutorial/2026/06
TutorialJun 27, 2026·24 min read

Build Observable AI Agents with VoltAgent and TypeScript

Learn how to build production-ready AI agents in TypeScript with VoltAgent — the observability-first framework. This hands-on tutorial covers agents, tools, persistent memory, multi-agent supervisors, workflows, and live tracing with VoltOps.

Most AI agent frameworks make the easy part easy — wiring an LLM to a few tools — and the hard part invisible. The moment an agent calls the wrong tool, loops forever, or burns tokens on a bad prompt, you are left squinting at console.log output trying to reconstruct what happened.

VoltAgent takes the opposite stance. It is an open-source TypeScript framework where observability is a first-class citizen, not an afterthought. Every agent run, tool call, sub-agent delegation, and workflow step is traced and visible in a visual console called VoltOps — an n8n-style canvas for watching your agents think.

In this tutorial you will build a customer-support agent from scratch, give it tools and persistent memory, expose it over HTTP, coordinate a team of specialized sub-agents under a supervisor, and watch every step live in the developer console.

Prerequisites

Before starting, make sure you have:

  • Node.js 20+ installed (node --version)
  • Basic familiarity with TypeScript and async/await
  • An API key from an LLM provider (this tutorial uses OpenAI, but any AI SDK provider works)
  • A code editor — VS Code recommended

You do not need a database, Docker, or any cloud account. VoltAgent stores memory and traces in a local SQLite file by default.

What You'll Build

A support assistant that:

  1. Answers questions using a custom order-lookup tool
  2. Remembers conversations across requests with persistent memory
  3. Runs as an HTTP server you can call from any frontend
  4. Delegates specialized work (summarizing, formatting) to sub-agents
  5. Runs a deterministic workflow for multi-step automation
  6. Streams every execution trace into the VoltOps console

Let's get into it.

Step 1: Scaffold the Project

VoltAgent ships a project generator that wires up TypeScript, the dev server, and a starter agent. Run:

npm create voltagent-app@latest support-agent

The CLI prompts you for a project name, an AI provider, and an API key. Pick OpenAI (or whichever provider you have a key for). When it finishes:

cd support-agent
npm run dev

Open the URL it prints (the VoltOps developer console). You already have a running, traceable agent. Now let's understand and rebuild it deliberately.

The key dependencies the generator added:

# Already installed by the generator — shown for reference
npm install @voltagent/core @voltagent/server-hono @voltagent/libsql @voltagent/logger
npm install @ai-sdk/openai          # the model provider

A note on the model field: VoltAgent uses the Vercel AI SDK directly. You can pass a plain string like "openai/gpt-4o-mini" and let the gateway resolve it, or pass a fully constructed LanguageModel object from @ai-sdk/openai. We'll use the string form for brevity.

Step 2: Create Your First Agent

Create src/agents/support.ts. An agent is just a name, a set of instructions (its system prompt), and a model:

import { Agent } from "@voltagent/core";
 
export const supportAgent = new Agent({
  name: "SupportAssistant",
  instructions:
    "You are a friendly customer-support assistant for an online store. " +
    "Answer concisely. If you need order details, use the available tools. " +
    "Never invent order information.",
  model: "openai/gpt-4o-mini",
});

Now wire it into a VoltAgent instance and expose it over HTTP. Create src/index.ts:

import { VoltAgent } from "@voltagent/core";
import { honoServer } from "@voltagent/server-hono";
import { supportAgent } from "./agents/support";
 
new VoltAgent({
  agents: { support: supportAgent },
  server: honoServer(), // starts on port 3141 by default
});

Restart npm run dev. The server boots on port 3141, and your agent appears in the VoltOps console. You can chat with it directly from the console — and every message produces a trace.

To call the agent in code instead, use generateText:

const response = await supportAgent.generateText(
  "What are your shipping options?"
);
console.log(response.text);

For real-time UIs, stream the response token by token:

const stream = await supportAgent.streamText("Explain your return policy");
 
for await (const chunk of stream.textStream) {
  process.stdout.write(chunk);
}

Step 3: Give the Agent a Tool

A chatbot that can only talk is not an agent. Tools let the model take action — query a database, hit an API, run a calculation. VoltAgent defines tools with createTool and validates their inputs with Zod, so the arguments the model produces are type-checked before your code ever runs.

Create src/tools/order.ts:

import { createTool } from "@voltagent/core";
import { z } from "zod";
 
// Pretend this is your database
const ORDERS: Record<string, { status: string; eta: string }> = {
  "1001": { status: "shipped", eta: "2026-06-29" },
  "1002": { status: "processing", eta: "2026-07-02" },
};
 
export const lookupOrderTool = createTool({
  name: "lookup_order",
  description: "Look up the status and ETA of a customer order by its ID.",
  parameters: z.object({
    orderId: z.string().describe("The numeric order ID, e.g. 1001"),
  }),
  execute: async ({ orderId }) => {
    const order = ORDERS[orderId];
    if (!order) {
      return { found: false, message: "No order with that ID." };
    }
    return { found: true, ...order };
  },
});

Attach it to the agent:

import { Agent } from "@voltagent/core";
import { lookupOrderTool } from "../tools/order";
 
export const supportAgent = new Agent({
  name: "SupportAssistant",
  instructions:
    "You are a friendly customer-support assistant. " +
    "Use the lookup_order tool whenever a customer asks about an order. " +
    "Never invent order information.",
  model: "openai/gpt-4o-mini",
  tools: [lookupOrderTool], // [!code highlight]
});

Now ask it: "Where is order 1001?" The model decides to call lookup_order, passes { orderId: "1001" }, receives the result, and answers in natural language. In the VoltOps console you will see the tool call as a distinct span — its inputs, its output, and how long it took.

Keep tool descriptions specific and action-oriented. The model chooses tools based purely on the name and description, so "Look up order status by ID" beats a vague "order helper." Use .describe() on each Zod field — those hints land directly in the model's tool schema.

Step 4: Add Persistent Memory

By default each call is stateless. To let the agent remember a conversation across requests, attach a Memory provider backed by LibSQL (SQLite). Create src/memory.ts:

import { Memory } from "@voltagent/core";
import { LibSQLMemoryAdapter } from "@voltagent/libsql";
 
export const sharedMemory = new Memory({
  storage: new LibSQLMemoryAdapter({
    url: "file:./.voltagent/memory.db",
  }),
});

Attach it to the agent and pass a userId (and optional conversationId) when calling, so VoltAgent knows which thread to load and append to:

import { sharedMemory } from "../memory";
 
export const supportAgent = new Agent({
  name: "SupportAssistant",
  instructions: "You are a friendly customer-support assistant.",
  model: "openai/gpt-4o-mini",
  tools: [lookupOrderTool],
  memory: sharedMemory, // [!code highlight]
});
// First turn
await supportAgent.generateText("My name is Sami and order 1002 is late.", {
  userId: "cust-42",
  conversationId: "ticket-7",
});
 
// Later turn — same thread, the agent recalls the context
const reply = await supportAgent.generateText("What was my name again?", {
  userId: "cust-42",
  conversationId: "ticket-7",
});
console.log(reply.text); // references "Sami"

The conversation persists in the SQLite file, so it survives server restarts. Swap the LibSQL adapter for a Turso URL (libsql://your-db.turso.io) or a Postgres adapter when you move to production — the agent code does not change.

Step 5: Coordinate Sub-Agents with a Supervisor

Single agents get unwieldy as responsibilities pile up. VoltAgent's answer is supervisor agents: a coordinator that delegates to specialized sub-agents, each with its own narrow instructions and tools.

Build two specialists and one supervisor in src/agents/team.ts:

import { Agent } from "@voltagent/core";
import { lookupOrderTool } from "../tools/order";
 
const orderAgent = new Agent({
  name: "OrderAgent",
  purpose: "Look up and explain order status.",
  instructions: "Use lookup_order to answer questions about orders.",
  model: "openai/gpt-4o-mini",
  tools: [lookupOrderTool],
});
 
const policyAgent = new Agent({
  name: "PolicyAgent",
  purpose: "Answer shipping and returns policy questions.",
  instructions:
    "Answer questions about shipping, returns, and refunds. " +
    "Free returns within 30 days; standard shipping is 3 to 5 days.",
  model: "openai/gpt-4o-mini",
});
 
export const supervisor = new Agent({
  name: "SupportSupervisor",
  instructions:
    "Route each customer question to the right specialist. " +
    "Use OrderAgent for order-specific questions and PolicyAgent for policy questions.",
  model: "openai/gpt-4o-mini",
  subAgents: [orderAgent, policyAgent], // [!code highlight]
});

The optional purpose field controls how the supervisor perceives each sub-agent — it's the short summary the coordinator reads when deciding whom to delegate to, separate from the longer instructions the sub-agent itself follows.

Now a single call fans out automatically:

const answer = await supervisor.generateText(
  "Is order 1001 shipped, and can I return it if I don't like it?"
);

The supervisor delegates the order half to OrderAgent and the policy half to PolicyAgent, then merges their replies. In VoltOps you'll see the delegation tree: the supervisor span, two child delegate_task spans, and the tool call nested inside OrderAgent.

Cancelling a run

Long-running multi-agent calls should be cancellable. Pass an AbortController and the signal propagates to every sub-agent and tool:

const controller = new AbortController();
setTimeout(() => controller.abort("Deadline reached"), 10_000);
 
const response = await supervisor.streamText("Research and summarize all open tickets", {
  abortController: controller,
});

Step 6: Build a Deterministic Workflow

Agents are great when you want the model to decide what to do. Sometimes you want a fixed sequence — validate, then enrich, then notify — with the LLM used only at specific steps. That is what workflows are for. They run as typed, step-by-step chains with their own persisted run history.

Create src/workflows/triage.ts:

import { createWorkflowChain } from "@voltagent/core";
import { z } from "zod";
import { supportAgent } from "../agents/support";
 
export const triageWorkflow = createWorkflowChain({
  id: "ticket-triage",
  name: "Ticket Triage",
  input: z.object({ message: z.string() }),
  result: z.object({ category: z.string(), reply: z.string() }),
})
  .andThen({
    id: "classify",
    execute: async ({ data }) => {
      const category = data.message.toLowerCase().includes("order")
        ? "order"
        : "general";
      return { ...data, category };
    },
  })
  .andThen({
    id: "respond",
    execute: async ({ data }) => {
      const res = await supportAgent.generateText(data.message);
      return { category: data.category, reply: res.text };
    },
  });

Register the workflow on the VoltAgent instance so it shows up in the console and gets persisted run history:

import { VoltAgent, Memory } from "@voltagent/core";
import { honoServer } from "@voltagent/server-hono";
import { LibSQLMemoryAdapter } from "@voltagent/libsql";
import { supervisor } from "./agents/team";
import { triageWorkflow } from "./workflows/triage";
 
new VoltAgent({
  agents: { support: supervisor },
  workflows: { triage: triageWorkflow },
  workflowMemory: new Memory({
    storage: new LibSQLMemoryAdapter({ url: "file:./.voltagent/workflows.db" }),
  }),
  server: honoServer({ port: 3141 }),
});

Each andThen step's input and output is typed end to end via Zod, and each run is replayable in the console. This is where VoltAgent's observability really earns its place: a workflow that breaks on step 3 of 5 shows you exactly which step, with the data that flowed in.

Step 7: Turn On Full Observability

So far traces live in memory for the dev session. To persist them — and to use the hosted VoltOps console for production monitoring — add a VoltAgentObservability provider and a structured logger.

import {
  VoltAgent,
  VoltAgentObservability,
} from "@voltagent/core";
import { honoServer } from "@voltagent/server-hono";
import { createPinoLogger } from "@voltagent/logger";
import { LibSQLObservabilityAdapter } from "@voltagent/libsql";
import { supervisor } from "./agents/team";
 
const logger = createPinoLogger({ name: "support-agent", level: "info" });
 
new VoltAgent({
  agents: { support: supervisor },
  server: honoServer(),
  logger,
  observability: new VoltAgentObservability({
    logger,
    storage: new LibSQLObservabilityAdapter({
      // Local file (default): ./.voltagent/observability.db
      // Production via Turso:
      // url: "libsql://your-db.turso.io",
      // authToken: process.env.TURSO_AUTH_TOKEN,
    }),
  }),
});

To stream traces to the hosted VoltOps console, create a project there, then set the keys in .env:

# .env
VOLTAGENT_PUBLIC_KEY=pk_...
VOLTAGENT_SECRET_KEY=sk_...
OPENAI_API_KEY=sk-...

VoltAgent picks up these variables automatically and forwards traces, so you get production-grade tracing, prompt playgrounds, and run replay without instrumenting anything by hand.

Never commit .env to source control. Add it to .gitignore. The VOLTAGENT_SECRET_KEY grants full access to your observability project — treat it like a password and load it from a secret manager in production.

Testing Your Implementation

Spin up the server and hit it with curl. VoltAgent's Hono server exposes a generate endpoint per agent:

curl -X POST http://localhost:3141/agents/support/text \
  -H "Content-Type: application/json" \
  -d '{"input": "Where is order 1001?"}'

You should get JSON back with the answer, and a matching trace should appear in the console within a second. Verify each capability:

  • Tool use — ask "Where is order 1001?" and confirm a lookup_order span appears
  • Memory — send two messages with the same userId and check the second recalls the first
  • Delegation — ask a mixed order-and-policy question and confirm two sub-agent spans
  • Workflow — trigger the triage workflow and confirm both steps run in order

Troubleshooting

The agent never calls my tool. The model decides based on the tool's name and description. Make them explicit and verb-led, and make sure the user's request actually maps to the tool. Check the trace — if the model answered without a tool span, the description was probably too vague.

Memory doesn't persist across restarts. Confirm you passed a url (a file path) to LibSQLMemoryAdapter. With no URL it may use an in-memory store that resets on restart. Also confirm you pass the same userId and conversationId on each turn.

Provider/auth errors. Make sure the matching AI SDK provider package is installed (@ai-sdk/openai) and the API key is in your environment. The "provider/model" string only resolves if that provider is available.

Traces don't reach the hosted console. Double-check both VOLTAGENT_PUBLIC_KEY and VOLTAGENT_SECRET_KEY are set and that the process actually loaded the .env file.

A Note for MENA Teams

Data residency matters under Tunisia's INPDP and Saudi Arabia's PDPL. VoltAgent helps here in two ways. First, memory and observability default to local SQLite (LibSQL) files — nothing leaves your machine unless you opt into the hosted console or a remote Turso/Postgres URL, so you control where conversation data and traces live. Second, because the model layer is the standard AI SDK, you can point agents at a regionally-hosted or self-hosted model (via an OpenAI-compatible endpoint) without rewriting agent logic — keeping inference, memory, and traces inside your jurisdiction when compliance requires it.

Next Steps

  • Add a vector search tool so the agent can answer from your own knowledge base
  • Connect external tools through the Model Context Protocol (MCP) instead of hand-writing each one
  • Swap LibSQL for Postgres when you outgrow a single file
  • Wire the HTTP endpoint into a Next.js frontend with streaming UI
  • Explore the prompt playground in VoltOps to A/B test instructions before shipping

Related tutorials on noqta.tn: building agents with the Mastra framework, the Claude Agent SDK, and adding persistent memory with Mem0.

Conclusion

You built a complete, observable agent system in TypeScript: a tool-using support agent, persistent memory, a supervisor delegating to specialists, a typed workflow, and full tracing through VoltOps. The lesson VoltAgent drives home is that agents you cannot see are agents you cannot trust in production — and making every step traceable from the first line of code turns debugging an LLM system from guesswork into reading a timeline. Build small, watch the traces, and grow your agent team one observable step at a time.