Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.zavu.dev/llms.txt

Use this file to discover all available pages before exploring further.

Advanced patterns

You’ve shipped your first Function. Now what holds up at scale.

Persistent state

Lambda is 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)

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:
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 Lambda’s ceremony lines:
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:
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:
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:
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

zavu fn invoke --event message.inbound \
  --from +14155551234 --text "I need a pizza"
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:
// 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:
zavu fn push                            # save draft, fast
# (run prompts manually in dashboard's Agent Playground)

# OR
zavu deploy                             # real deploy
zavu messages send --to my-test-phone --text "what's vegan?" --sender $SENDER_ID --channel whatsapp
zavu 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:
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-4o-mini, 3-turn convo)$0.0001–0.0005
Lambda invocations (1 per tool call)$0.000001 each
WhatsApp conversation (Meta)$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. zavu 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. zavu fn init and copy your handler into defineFunction.
  2. Set up triggers via CLI instead of webhook URLs on senders:
    zavu 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 are signed by AWS IAM (no HMAC), retry automatically, and have lower latency than a Vercel cold start through the internet.

Limits to know

ResourceHard limitSoft (CLI rejects)
Function slug length23 charsAuto-enforced
Function name80 chars
Memory1024 MB
Timeout per invocation30 sec
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.

Next

Restaurant example

Complete booking agent with persistence.

Runtime versions

Pinning, upgrades, security patches.