Skip to content

Advanced patterns

Production-grade techniques: composition, state, observability, testing.

View .md

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:

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)

TypeScript
import 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:

TypeScript
import { 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:

FunctionJob
support-agentReactive: handles WhatsApp inbound, has agent + tools.
daily-digestScheduled: sends daily metric digest. Triggered by cron.
cart-recoveryTriggered: fires on cart.abandoned event (custom).
dlq-watcherTriggered: 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:

TypeScript
handler: 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:

TypeScript
async 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:

TypeScript
ctx.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:

TypeScript
async 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

terminal
npx 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:

terminal
npx 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:

TypeScript
defineTool({ 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:

ItemOrder 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 callsvaries

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:

  1. Write defineAgent({...}) matching your existing config.
  2. Write defineTool({...}) for each existing tool, including the same name, description, parameters.
  3. 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:

  1. npx zavudev fn init and copy your handler into defineFunction.
  2. Set up triggers via CLI instead of webhook URLs on senders:
    terminal
    npx zavudev fn triggers add --events message.inbound --senders any
  3. 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

ResourceHard limitSoft (CLI rejects)
Function slug length23 charsAuto-enforced
Function name80 chars—
Memory1024 MB—
Timeout per invocation180 sec (default 30)—
Source size900 KB—
Bundled zip size6 MBTriggers different code path
Dependencies declared30 packages—
Secrets per function50—
Secret value4 KB—
Tools per agentunlimited in API; ~20 practicalLLM 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.

FunctionCalls used per invocation
128 MB, 200 ms1
128 MB, 1 s1
256 MB, 3 s6
1024 MB, 10 s80

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.

Next