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.
Start with the idea, in one sentence
Section titled “Start with the idea, in one sentence”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.
The smallest thing that works
Section titled “The smallest thing that works”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:
npm install @kuralle-agents/core @ai-sdk/openai ai zodimport { 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, …).
Running a turn
Section titled “Running a turn”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.
Give it hands: tools
Section titled “Give it hands: tools”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.
Give it a procedure: flows
Section titled “Give it a procedure: flows”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.
Make it remember and make it know
Section titled “Make it remember and make it know”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
knowledgeand theknowledge.autoRetrieveboolean decides who retrieves:true(default) pre-injects facts before every answer (always grounded);falsewires aknowledge_searchtool 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.
Keep it safe and bounded
Section titled “Keep it safe and bounded”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.
Reading a photo, not just text
Section titled “Reading a photo, not just text”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.
Wiring the model in: createRuntime
Section titled “Wiring the model in: createRuntime”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.
Deploy it
Section titled “Deploy it”The runtime is plain TypeScript with no required external process — it runs anywhere JavaScript runs. Three common homes:
Node (or Bun) — Hono
Section titled “Node (or Bun) — Hono”@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.
npm install @kuralle-agents/hono-server hono @hono/node-server @hono/node-wsimport { 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.
Cloudflare Workers — Durable Objects
Section titled “Cloudflare Workers — Durable Objects”@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'; }}{ "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/.
Vercel — Next.js
Section titled “Vercel — Next.js”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:
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:
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.
Where to go next
Section titled “Where to go next”You now have the whole map. Each stop has a guide that goes deeper than this tour:
- Agents — every
defineAgentfield and how behavior is derived. - Flows · Flow Execution Model — the node graph and how it runs.
- Tools · Durable Execution — effects, the journal, approvals, suspend/resume.
- Routing & Handoffs — silent dispatch and explicit transfers.
- Sessions & State · Memory — persistence and recall.
- Skills — bundled procedural playbooks.
- Multimodal Input — images, files, and voice notes end to end.
- Voice Agents · Engagement & Messaging — realtime voice and WhatsApp/Instagram.
- Deployment — the full endpoint set and production wiring.