> ## 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

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

```ts theme={null}
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:

```ts theme={null}
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:

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

```ts theme={null}
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:

```ts theme={null}
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:

```ts theme={null}
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:

```ts theme={null}
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

```sh theme={null}
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:

```ts theme={null}
// 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:

```sh theme={null}
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:

```ts theme={null}
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:

| Item                                   | Order of magnitude                |
| -------------------------------------- | --------------------------------- |
| LLM tokens (gpt-4o-mini, 3-turn convo) | \$0.0001–0.0005                   |
| 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:

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:
   ```sh theme={null}
   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 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 | 30 sec                           | —                            |
| 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.

## Next

<CardGroup cols={2}>
  <Card title="Restaurant example" icon="pizza-slice" href="/guides/functions/examples/restaurant">
    Complete booking agent with persistence.
  </Card>

  <Card title="Runtime versions" icon="layers" href="/guides/functions/runtime">
    Pinning, upgrades, security patches.
  </Card>
</CardGroup>
