Advanced patterns
Production-grade techniques: composition, state, observability, testing.
Advanced patterns
You've shipped your first Function. Now what holds up at scale.
Persistent state
Functions are stateless. Cold starts wipe in-process variables. Pick one:
Convex tables (recommended for Zavu users)
Tightly integrated. The auto-provisioned ZAVU_API_KEY doesn't grant
arbitrary table access — for that, create a project API key with broader
scopes and inject it as a secret. Or use Convex deployments outside our
managed scope.
Postgres (managed: PlanetScale, Neon, Supabase)
TypeScriptimport postgres from "postgres" let sql: ReturnType<typeof postgres> | null = null function getDb() { if (!sql) sql = postgres(process.env.DATABASE_URL!, { ssl: "require" }) return sql } defineTool({ name: "save_lead", handler: async (args, ctx) => { const db = getDb() await db`INSERT INTO leads (phone, email, source) VALUES (${ctx.contactPhone}, ${args.email}, 'whatsapp')` return { saved: true } }, })
The let sql = null pattern lets the same connection survive across warm
invocations — saves the connection handshake (~50ms) on subsequent calls.
Redis (Upstash for serverless-friendly)
For rate-limiting, deduplication, and short-lived state:
TypeScriptimport { Redis } from "@upstash/redis" const redis = new Redis({ url: process.env.UPSTASH_URL!, token: process.env.UPSTASH_TOKEN!, }) defineTool({ name: "send_otp", handler: async (args, ctx) => { const rateLimited = await redis.set( `otp:${ctx.contactPhone}`, "1", { ex: 60, nx: true } ) if (rateLimited !== "OK") { return { error: "rate_limited", message: "Espera 1 minuto antes de pedir otro código." } } // ... }, })
Composing multiple functions
One project can have many functions. Use this for separation of concerns:
| Function | Job |
|---|---|
support-agent | Reactive: handles WhatsApp inbound, has agent + tools. |
daily-digest | Scheduled: sends daily metric digest. Triggered by cron. |
cart-recovery | Triggered: fires on cart.abandoned event (custom). |
dlq-watcher | Triggered: fires on message.failed, retries with fallback. |
Functions don't directly call each other today — they communicate via Zavu events (triggers) or your own database / queue.
Observability
Structured logs
Use the framework's ctx.log so the dashboard's logs panel can highlight
your output among the runtime's ceremony lines:
TypeScripthandler: async (args, ctx) => { ctx.log("processing", { customer: ctx.contactPhone, args }) try { const result = await doStuff(args) ctx.log("ok", { ms: Date.now() - t0 }) return result } catch (err) { ctx.log("error", { err: err.message, stack: err.stack }) throw err } }
Metrics → external sinks
Send important business events to a metrics service:
TypeScriptasync function metric(name: string, props: Record<string, unknown>) { await fetch("https://api.posthog.com/capture", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ api_key: process.env.POSTHOG_KEY, event: name, properties: props, distinct_id: props.customerPhone ?? "anonymous", }), }) } defineTool({ name: "create_reservation", handler: async (args, ctx) => { const result = await reserve(args) await metric("reservation_created", { customerPhone: ctx.contactPhone, partySize: args.partySize, time: args.time, }) return result }, })
fetch runs in parallel — don't await it if you don't care about delivery
guarantees. Use a fire-and-forget:
TypeScriptctx.log("queued metric", { name: "reservation_created" }) metric("reservation_created", { /* ... */ }).catch(() => {}) return result
Error budgets and retries
The agent retries failed tool calls up to 2 times (LLM's choice — it sees the error message and may try again). Beyond that, the LLM gives up and tells the customer.
For tools that touch unreliable systems (3rd-party APIs), add your own retry-with-backoff:
TypeScriptasync function withRetry<T>(fn: () => Promise<T>, tries = 3): Promise<T> { for (let i = 0; i < tries; i++) { try { return await fn() } catch (err) { if (i === tries - 1) throw err await new Promise((r) => setTimeout(r, 2 ** i * 200)) } } throw new Error("unreachable") } defineTool({ name: "lookup_order", handler: async (args) => { const res = await withRetry(() => fetch(`https://flaky-pos.com/orders/${args.orderId}`).then(r => r.ok ? r.json() : Promise.reject()) ) return res }, })
Testing
Local invoke
terminalnpx zavudev fn invoke --event message.inbound \ --data '{"from":"+14155551234","text":"I need a pizza","channel":"whatsapp"}'
Runs your default handler with a synthetic event. Useful for trigger-based
functions and the defineFunction fallback path. Doesn't simulate LLM
tool calls — for that, deploy + use the real WhatsApp sender.
Unit tests for handlers
Tool handlers are plain functions. Extract them, test them:
TypeScript// shared/menu.ts export async function viewMenuImpl(args: { filter?: string }) { const items = args.filter === "vegan" ? MENU.filter(m => m.vegan) : MENU return { menu: items, count: items.length } } // index.ts import { viewMenuImpl } from "./shared/menu" defineTool({ name: "view_menu", description: "...", parameters: { /* ... */ }, handler: (args) => viewMenuImpl(args), }) // shared/menu.test.ts import { viewMenuImpl } from "./menu" test("vegan filter only returns vegan items", async () => { const result = await viewMenuImpl({ filter: "vegan" }) expect(result.menu.every(m => m.vegan)).toBe(true) })
Use bun test or vitest locally — they don't need to ship with the function.
Integration with the LLM
To test how the LLM ACTUALLY picks tools, you need a live agent. The fastest loop:
terminalnpx zavudev fn push # save draft, fast # (run prompts manually in dashboard's Agent Playground) # OR npx zavudev deploy # real deploy npx zavudev messages send --to my-test-phone --text "what's vegan?" --sender $SENDER_ID --channel whatsapp npx zavudev fn logs --tail # watch tool calls
Multi-agent on one sender
Not directly supported — one agent per sender. But you can simulate it with flows OR by having a "router" tool:
TypeScriptdefineTool({ name: "switch_persona", description: "Switch the conversation mode. Use when the user's intent clearly changes " + "(e.g. 'now I want to talk about returns instead').", parameters: { type: "object", properties: { mode: { type: "string", enum: ["shopping", "support", "feedback"] }, }, required: ["mode"], }, handler: async ({ mode }, ctx) => { await redis.set(`mode:${ctx.contactPhone}`, mode, { ex: 3600 }) return { mode, summary: `Modo cambiado a ${mode}.` } }, })
Then prefix the system prompt with logic that reads mode from Redis at
each turn. (You'd inject mode into the agent via custom contact metadata,
which the agent reads automatically with includeContactMetadata: true.)
In practice: one focused agent > one mega-agent juggling modes. Multiple senders / multiple functions is the canonical way.
Cost optimization
Per-conversation cost breaks down as:
| Item | Order of magnitude |
|---|---|
| LLM tokens (gpt-5.6-luna, 3-turn convo) | $0.0002–0.001 |
| Function invocations (1 per tool call) | $0.000001 each |
| WhatsApp conversation | $0.005–0.04 depending on country |
| Your DB / API calls | varies |
LLM is rarely the bottleneck. What kills budgets:
- Long prompts. Every turn sends the full system prompt + last N messages. A 1000-token system prompt at 10 turns of history = 10k tokens per reply. Trim relentlessly.
- High
contextWindowMessages. Default 10 is overkill for transactional agents. Drop to 4-6 if your conversations are short. - Re-reading large tool returns. If a tool returns 500 items, the LLM re-reads them every turn. Trim server-side.
Migration paths
From dashboard-configured AI Agent → Function
You already have an agent and tools created from the dashboard. To move them under code-managed control:
- Write
defineAgent({...})matching your existing config. - Write
defineTool({...})for each existing tool, including the samename,description,parameters. npx zavudev deploy. The reconciler sees existing rows with matching(senderId, name)and takes ownership — patches them to match your code AND marks them managed.
The summary shows + ToolName (took over manual) for each.
From that point on, dashboard edits are blocked. Code is source of truth.
From a custom webhook receiver → Function
You have a Vercel function listening for Zavu webhooks. To move:
npx zavudev fn initand copy your handler intodefineFunction.- Set up triggers via CLI instead of webhook URLs on senders:
terminal
npx zavudev fn triggers add --events message.inbound --senders any - Disable the webhook on the sender (or leave it — both work in parallel during migration).
Native triggers use Zavu-internal signed invocations (no HMAC), retry automatically, and have lower latency than a typical webhook through the internet.
Limits to know
| Resource | Hard limit | Soft (CLI rejects) |
|---|---|---|
| Function slug length | 23 chars | Auto-enforced |
| Function name | 80 chars | — |
| Memory | 1024 MB | — |
| Timeout per invocation | 180 sec (default 30) | — |
| Source size | 900 KB | — |
| Bundled zip size | 6 MB | Triggers different code path |
| Dependencies declared | 30 packages | — |
| Secrets per function | 50 | — |
| Secret value | 4 KB | — |
| Tools per agent | unlimited in API; ~20 practical | LLM degrades past ~10 |
For higher limits, contact support.
What a call costs
Functions are billed by memory and time, the same two things the infrastructure underneath charges for. One call is 128 MB running for one second. A function with more memory, or one that takes longer, uses several; anything under a second counts as one, so a fast function costs exactly what it always did.
| Function | Calls used per invocation |
|---|---|
| 128 MB, 200 ms | 1 |
| 128 MB, 1 s | 1 |
| 256 MB, 3 s | 6 |
| 1024 MB, 10 s | 80 |
300,000 calls a month are included on every plan, then $5 per million. Deploys, logs, rollbacks and triggers are never charged.
Choosing a timeout
The ceiling is 180 seconds and the default is 30, but the number that matters depends on how the function is invoked:
- Event and cron invocations are asynchronous. Nothing is waiting on the response, so the timeout only bounds what one invocation can cost you. A slow nightly job is fine here.
- A tool called during a conversation is synchronous. The agent's reply waits for it, and so does the person who wrote the message. Keep these well under the ceiling: a tool that takes 60 seconds is a conversation that looks broken.
- HTTP-exposed functions are additionally bounded by the platform's own response limit, which is lower than 180 seconds and is not something a function setting can raise.
Raise the timeout when the work genuinely takes longer, not to paper over a slow dependency in a live path.