Skip to main content

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

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:

Composing multiple functions

One project can have many functions. Use this for separation of concerns: 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:

Metrics → external sinks

Send important business events to a metrics service:
fetch runs in parallel — don’t await it if you don’t care about delivery guarantees. Use a fire-and-forget:

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:

Testing

Local invoke

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

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

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

Complete booking agent with persistence.

Runtime versions

Pinning, upgrades, security patches.