Skip to content

Build an Agent (from idea to production)

You have an idea: “I want an assistant that can do X.” This guide walks the whole distance — from that one sentence to an agent running in production. We’ll build one concrete example and add capability only when the idea actually needs it, so every primitive shows up with a reason attached rather than as a feature list.

If you just want the fastest possible “hello agent,” read the Quickstart instead. This guide is the slower, fuller tour: it touches every primitive and ends with three real deploy targets.

Pin the idea to a single sentence before you write code. Ours:

“A pharmacy assistant that reads a customer’s prescription, tells them what’s in stock, builds a cart, and takes payment.”

That sentence already tells us what we’ll need, and we’ll meet each piece as it comes up: read a prescription (multimodal input), tell them what’s in stock (a tool that checks inventory), build a cart (durable tools + session state), take payment (a flow that suspends for a human action and resumes). We’ll also route, remember, and ground along the way. Don’t worry about all those words yet — they’ll each earn their place.

Everything in Kuralle is one primitive: defineAgent. There is no FlowAgent, TriageAgent, or ToolAgent class — an agent’s behavior is derived from which fields you fill in. So the smallest agent is just an identity, an instruction, and a model:

Terminal window
npm install @kuralle-agents/core @ai-sdk/openai ai zod
import { openai } from '@ai-sdk/openai';
import { defineAgent, createRuntime } from '@kuralle-agents/core';
const agent = defineAgent({
id: 'pharmacy',
instructions: 'You are a friendly pharmacy assistant. Be concise.',
model: openai('gpt-4o-mini'),
});
const runtime = createRuntime({ agents: [agent], defaultAgentId: 'pharmacy' });

createRuntime is the thing that actually runs turns. model is any Vercel AI SDK language model — Kuralle is built on the AI SDK, so you bring your own provider (@ai-sdk/openai, @ai-sdk/anthropic, @ai-sdk/google, @ai-sdk/xai, …).

runtime.run() hands back a TurnHandle. You stream from handle.events (a property, not a method) and you can await handle for the final result:

const handle = runtime.run({ input: 'Hi, do you sell ibuprofen?' });
for await (const part of handle.events) {
if (part.type === 'text-delta') process.stdout.write(part.delta);
if (part.type === 'done') console.log('\nsession:', part.sessionId);
}
await handle;

The interesting events as they stream by:

| Event | Meaning | |---|---| | text-start / text-delta / text-end | the assistant message, chunk by chunk | | tool-call / tool-result | a tool ran | | paused | the run suspended (waiting for a signal — we’ll get here) | | done | the turn finished; carries sessionId |

Keep that sessionId — pass it back on the next turn and the conversation continues:

runtime.run({ input: 'And paracetamol?', sessionId });

The runtime persists history for you. By default that’s an in-process MemoryStore; in production you’ll swap it for Redis or Postgres — more on that in Sessions & State.

Our assistant can chat, but it can’t check inventory — it has no way to touch the real world. That’s what a tool is: a typed function the model can call.

import { z } from 'zod';
import { defineTool } from '@kuralle-agents/core';
const checkInventory = defineTool({
name: 'check_inventory',
description: 'Check whether a medicine is in stock and at what price.',
input: z.object({ name: z.string(), strength: z.string().optional() }),
execute: async ({ name, strength }) => {
const item = await db.lookup(name, strength); // your code
return item
? { inStock: item.stock > 0, price: item.price }
: { inStock: false };
},
});

A tool is a Zod input schema plus an async execute. Whatever execute returns goes back to the model as the tool result. Attach it to the agent under tools:

const agent = defineAgent({
id: 'pharmacy',
instructions: 'You are a pharmacy assistant. Use check_inventory before quoting stock.',
model: openai('gpt-4o-mini'),
tools: { check_inventory: checkInventory },
});

tools are model-callable in an answering turn. There’s a sibling field, globalTools, for safe, always-available tools you want visible in every speaking turn — even inside a flow (think: a returns-policy lookup the customer might ask about mid-checkout). Keep mutating/consequential tools in tools; keep globalTools to a small read-only allow-list. Full detail: Tools.

Here’s a trap worth naming early. The moment your idea has steps — “first check the prescription, then confirm the cart, then take payment” — the temptation is to write those steps as a long paragraph in instructions. Don’t. Prompts can’t be tested and models wander.

Rule of thumb: more than ~20 lines of procedure belongs in a flow, not a prompt.

A flow is a small graph of typed nodes. Each node owns one slice of the conversation and returns a transition to the next node:

import { defineFlow, reply, collect, action, decide, buildToolSet } from '@kuralle-agents/core';
const confirm = reply({
id: 'confirm',
instructions: 'Read back the cart and total. When the user clearly says yes, call place_order.',
tools: () => buildToolSet({ place_order: placeOrder }),
next: (turn) =>
turn.toolResults.some((r) => r.name === 'place_order') ? { end: 'ordered' } : 'stay',
});
const flow = defineFlow({
name: 'checkout',
description: 'Confirm the cart and place the order.',
start: confirm,
nodes: [confirm],
});

The four node kinds, each for one job:

| Node | Does | Returns | |---|---|---| | reply | speaks, then moves on | a transition from next(turn, state) | | collect | gathers a Zod schema over one or more turns | onComplete(data, state) | | action | runs a side effect, no user-facing text | a transition (and can suspend — see below) | | decide | asks the model for a structured choice (no reply) | decide(data, state) |

A node’s next/onComplete returns a transition: a node to go to, { end: 'label' }, { handoff: 'agentId' }, { escalate: 'reason' }, or 'stay'. Attach the flow with defineAgent({ flows: [flow] }). The flow remembers its current node in the session and resumes there on the next turn — you don’t wire that up.

The trust primitive: durable human-in-the-loop

Section titled “The trust primitive: durable human-in-the-loop”

Taking payment is the part you cannot let the model “decide” went fine. The customer leaves the chat, pays on an external page, and only then should the order complete. The conversation needs to pause and resume on a real-world event.

Two ways to do this, both durable (they survive restarts and replay exactly once):

1. Approve a tool before it runs. Flag a tool needsApproval and the runtime suspends before executing it until a human decision arrives:

const refund = defineTool({
name: 'refund',
description: 'Refund an order.',
input: z.object({ orderId: z.string(), amount: z.number() }),
needsApproval: true, // pauses for a human yes/no before executing
execute: async ({ orderId, amount }) => api.refund(orderId, amount),
});

2. Suspend on a named signal. Inside an action node, ctx.signal('payment') parks the run until you deliver that signal:

const checkout = action({
id: 'checkout',
run: async (state, ctx) => {
const token = await ctx.uuid(); // durable: stable across replay
ctx.emit(/* ...send the customer a payment link carrying `token`... */);
await ctx.signal('payment'); // ⏸ run pauses here
return orderComplete; // ▶ resumes here after payment
},
});

When the payment page is hit, you resume by handing the runtime the matching signal. signalId is the delivery’s idempotency key — mint it durably inside the action (the ctx.uuid() above) and round-trip it through the payment link, so a double-clicked link delivers the same id and is deduplicated:

runtime.run({ sessionId, signalDelivery: { signalId, name: 'payment', payload: { paid: true } } });

The run picks up exactly where it left off and continues to orderComplete. This is the backbone of any regulated or money-moving workflow. Full detail: Durable Execution.

Many jobs, one front door: routing & handoffs

Section titled “Many jobs, one front door: routing & handoffs”

If your idea grows past one persona — say a pharmacist for clinical questions and a billing agent for payments — you don’t cram them into one prompt. You give each its own agent and let a router send each message to the right one.

const triage = defineAgent({
id: 'triage',
model: openai('gpt-4o-mini'),
routes: [
{ agent: 'pharmacist', when: 'Clinical questions, dosage, interactions' },
{ agent: 'billing', when: 'Payments, refunds, invoices' },
],
agents: [pharmacist, billing],
});

routes alone is enough — the dispatcher behavior is derived from the field being present. The optional routing policy exists only for tuning (pin a control model, dispatch: 'strict' buffering for compliance text).

A pure router (routes only, no instructions/tools) dispatches silently — the hand-off never leaks to the user as prose. An agent that also answers gets host-control tools plus a lazy guard so it can route mid-conversation without narrating it. Agents can also handoff to each other directly. See Routing & Handoffs.

Two different needs, two different primitives:

  • Memory — facts about this user that should persist across turns (allergies, a delivery address). Turn it on with memory.workingMemory; the model maintains durable USER/MEMORY blocks via an auto-wired tool. Backends: in-memory, file, Redis/Upstash, Postgres, or Cloudflare DO SQLite. See Memory.

  • Knowledge — grounding the agent in your documents (a drug formulary, a returns policy). Declare knowledge and the knowledge.autoRetrieve boolean decides who retrieves: true (default) pre-injects facts before every answer (always grounded); false wires a knowledge_search tool the model calls only when it needs facts (routing turns pay zero retrieval cost). See the Agents guide.

const pharmacist = defineAgent({
id: 'pharmacist',
instructions: 'Answer clinical questions. Ground every answer in the formulary.',
model: openai('gpt-4o'),
knowledge: { autoRetrieve: true }, // always grounded
memory: { workingMemory: { /* blocks */ } },
});

You can also give an agent a workspace (a portable filesystem it can read/write via a durable workspace tool) and skills (bundled procedural playbooks loaded on demand). Those are power-ups for document-heavy and multi-step agents — see Skills.

A pharmacy agent shouldn’t leak PII or loop forever. defineAgent has fields for both:

  • guardrails — input/output checks (PII redaction, prompt-injection, moderation) that run around each turn.
  • limits — step/turn ceilings so a misbehaving loop can’t run away.
  • validate / refine — post-turn validation (e.g. a grounding or confidence gate) and pre-turn refinement policies.
defineAgent({
id: 'pharmacist',
// …
limits: { maxSteps: 8 },
// guardrails / validate / refine: see the relevant guides for the exact policy builders
});

These are opt-in — an agent with none of them just runs unconstrained, which is fine for a prototype. Add them as the idea moves toward production.

Our pharmacy idea opened with “reads a customer’s prescription.” That’s an image, not text — and a runtime that only accepts strings would silently drop it.

Kuralle’s user input is the AI SDK’s own content shape: a string or an array of parts. So a turn can carry text and a file together:

runtime.run({
input: [
{ type: 'text', text: 'Here is my prescription — what can you fill?' },
{ type: 'file', mediaType: 'image/jpeg', data: 'https://…/rx.jpg' }, // URL, data URL, or base64
],
});

A plain string still works exactly as before — text-only callers don’t change. Images go to a vision-capable model (gpt-4o, gemini-2.x); voice notes can be transcribed automatically by setting a transcriptionModel on the runtime. The web and messaging front doors map uploads into this shape for you. This is enough to know it exists — the full treatment (web uploads, WhatsApp media, voice notes, the one durability rule) is its own guide: Multimodal Input.

You’ve met createRuntime already. Here’s the fuller shape, so you know what’s available:

const runtime = createRuntime({
agents: [triage, pharmacist, billing],
defaultAgentId: 'triage',
defaultModel: openai('gpt-4o-mini'), // fallback when an agent omits `model`
sessionStore, // Memory (default) | Redis | Postgres | DO SQLite
transcriptionModel, // optional: transcribe inbound voice notes
knowledge, // shared KnowledgeProvider for agents that declare `knowledge`
hooks, // lifecycle hooks for logging/metrics/tracing
compaction, // optional: auto-summarize long histories
escalation, // optional: human-handoff handler (see Engagement guide)
});

That’s the whole runtime surface you’ll touch day to day. Now let’s get it online.

The runtime is plain TypeScript with no required external process — it runs anywhere JavaScript runs. Three common homes:

@kuralle-agents/hono-server mounts a full chat API (single-turn, SSE, WebSocket, session CRUD, health) onto a Hono app in one call. POST /api/chat/sse returns a native AI SDK UIMessageStream, so a React useChat client needs no bridge.

Terminal window
npm install @kuralle-agents/hono-server hono @hono/node-server @hono/node-ws
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { createNodeWebSocket } from '@hono/node-ws';
import { createKuralleChatRouter } from '@kuralle-agents/hono-server';
const app = new Hono();
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
app.route('/', createKuralleChatRouter({ runtime, upgradeWebSocket }));
const server = serve({ fetch: app.fetch, port: 3000 });
injectWebSocket(server);

On Bun, drop @hono/node-server/createNodeWebSocket and use hono/bun’s upgradeWebSocket. See Deployment.

@kuralle-agents/cf-agent runs an agent as a Durable Object. One DO per conversation gives you per-conversation persistence, multi-client sync, and stream resumability for free — Cloudflare owns the SQLite, you own the agent. Subclass KuralleAgent and implement two methods:

import { KuralleAgent } from '@kuralle-agents/cf-agent';
import { defineAgent } from '@kuralle-agents/core';
import { createOpenAI } from '@ai-sdk/openai';
interface Env { OPENAI_API_KEY: string }
export class PharmacyAgent extends KuralleAgent<Env> {
protected getAgents() {
const openai = createOpenAI({ apiKey: this.env.OPENAI_API_KEY });
return [defineAgent({ id: 'pharmacy', instructions: '…', model: openai('gpt-4o-mini') })];
}
protected getDefaultAgentId() { return 'pharmacy'; }
}
wrangler.jsonc
{
"durable_objects": { "bindings": [{ "name": "PharmacyAgent", "class_name": "PharmacyAgent" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["PharmacyAgent"] }]
}

Flows, tools, routing, multimodal, and durable suspend/resume all behave identically on Cloudflare — same defineAgent, no runtime differences. (Durable HITL on CF: deliver a signal by POSTing to the agent’s …/resume route, which resumes the suspended run and broadcasts the result.) See Deployment.

The complete pharmacy agent from this guide — prescription image intake, inventory tools, cart, and the durable pay-link checkout on a Durable Object — is implemented end to end in the repo at apps/playground/pharmacy-rx-agent/.

The simplest path is a Next.js App Router route handler that drives the runtime directly and returns a native AI SDK stream. Because input is UserInputContent, the same handler serves text and multimodal — the client decides what to send:

app/api/chat/route.ts
import { runtime } from '@/lib/runtime';
export async function POST(req: Request) {
const { input, sessionId } = await req.json(); // `input`: string OR content parts
const handle = runtime.run({ input, sessionId });
return handle.toUIMessageStreamResponse({ sessionId });
}

Want the full batteries-included endpoint set (and automatic useChat-style UIMessage → multimodal mapping)? Hono runs on Vercel via the hono/vercel adapter. Mount the router at the app root and let a catch-all route forward /api/* to it — the router’s own paths (/api/chat/sse, …) match the incoming path, so don’t add a basePath:

app/api/[...route]/route.ts
import { Hono } from 'hono';
import { handle } from 'hono/vercel';
import { createKuralleChatRouter } from '@kuralle-agents/hono-server';
import { runtime } from '@/lib/runtime';
const app = new Hono();
app.route('/', createKuralleChatRouter({ runtime }));
export const POST = handle(app);
export const GET = handle(app);

WebSocket endpoints don’t run on Vercel serverless functions; use the POST /api/chat/sse SSE stream (the useChat default) there.

You now have the whole map. Each stop has a guide that goes deeper than this tour: