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

```ts
// 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
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:

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

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

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

## Next

  - [Restaurant example](https://www.zavu.dev/docs/guides/functions/examples/restaurant): Complete booking agent with persistence.
  - [Runtime versions](https://www.zavu.dev/docs/guides/functions/runtime): Pinning, upgrades, security patches.
