Skip to content

defineAgent

Configure the LLM brain that drives your conversation.

View .md

defineAgent

defineAgent declares the AI agent that will run on a specific WhatsApp / SMS sender. Calling it inside your function source binds the agent to that sender on every npx zavudev deploy.

TypeScript
import { defineAgent } from "@zavudev/functions" defineAgent({ senderId: process.env.SENDER_ID!, name: "Bella", provider: "zavu", model: "openai/gpt-5.6-luna", prompt: "You are Bella, host of Bella Pizzeria…", channels: ["whatsapp"], })

After npx zavudev deploy:

  • The agent is created (or updated) under that sender.
  • Tagged as managed by this function — the dashboard disables manual edits to prevent drift.
  • Marked enabled: true automatically.

Required fields

FieldTypeNotes
namestringDisplay name. Shown in npx zavudev agents executions and the dashboard.
providerzavu | openai | anthropic | google | mistralLLM provider.
modelstringModel id (depends on provider — see below).
promptstringSystem prompt. systemPrompt is accepted as an alias. Multi-line OK (use backticks).

Optional fields

FieldDefaultDescription
senderId—The _id of an existing sender, usually process.env.SENDER_ID. Omit it to declare the agent standalone and connect senders afterwards; it answers nothing until one is connected.
channels["*"]Which channels trigger the agent: whatsapp, sms, sms_oneway, telegram, email, instagram, messenger, voice, or * for every channel its sender carries. A channel the sender receives on but this list omits is dropped before the agent sees it, silently — prefer * unless you are deliberately excluding one. Unknown strings are accepted and match nothing, so a typo deploys green and never fires.
messageTypes["text"]Message types: text, image, audio, …
groupsfalseAnswer in group chats, on channels that carry them. Off means the agent answers one-to-one conversations only. See Group chats.
apiKey—Required when provider !== "zavu" (or pre-create a secret in the dashboard).
contextWindowMessages10How many previous messages to include in each LLM call.
temperatureprovider default0–2.
maxTokensunboundedCap on response length.
sessionTimeoutMinutes60After this idle gap, the agent starts a new conversation.
includeContactMetadatatrueIf true, contact name / metadata is included as context.
enabledtrueSet to false to deploy the agent but keep it inactive.
voice—Voice config. When present with enabled: true, the agent answers phone calls. See below.

Voice

Add a voice block to make the agent answer calls. The LLM runs co-located in the voice network for the lowest latency, independent of the model used for text. npx zavudev deploy provisions everything; removing the block turns the agent back into text-only.

TypeScript
defineAgent({ senderId: process.env.SENDER_ID!, name: "Fermi", provider: "zavu", model: "openai/gpt-5.6-luna", // text model channels: ["voice", "whatsapp"], voice: { enabled: true, model: "openai/gpt-4o", // co-located voice model (optional; derived from `model` if omitted) greeting: "Hi, I'm Fermi. How can I help?", language: "en", // BCP-47; auto-detected if omitted interruptible: true, // caller can barge in maxCallDurationMinutes: 10, }, prompt: `…`, })

Knowledge base

Documents the agent answers from, declared with the agent. Retrieval runs before every reply.

TypeScript
defineAgent({ senderId: process.env.SENDER_ID!, name: "Ada", provider: "zavu", model: "openai/gpt-5.6-luna", prompt: "Answer from the store policies. If they do not cover it, say so.", knowledgeBase: { name: "Store policies", documents: [ { title: "Returns", content: `# Returns Unopened items: 30 days, full refund. Opened items: 14 days, only if faulty. Not returnable: gift cards, final sale.`, }, { title: "Shipping", content: `# Shipping Free over $50, 3 to 5 business days. Express is $12, next business day if ordered before 2pm. US and Canada only. No PO boxes.`, }, ], }, })

The declaration is the source of truth, like everything else here. On deploy:

  • a document you added is created and embedded
  • a document whose content changed is re-embedded
  • a document you removed is deleted
  • a document that did not change is left alone, so a redeploy costs nothing

The deploy tells you which happened:

Code
Knowledge synced: + Returns ~ Shipping (re-embedded) - Old holiday hours

Titles are identity

Documents are matched by title. Renaming one replaces it: the old document is deleted and a new one embedded. That is usually what you want, but it is a re-embed rather than an edit.

Writing documents that retrieve

Retrieval is similarity over chunks, not keyword search.

  • Headings and short paragraphs chunk cleanly. A wall of text produces chunks that each hold half an answer.
  • Use the customer's words. A document that says "return merchandise authorization" does not retrieve for "can I send it back".
  • State facts. "14 days" retrieves and answers. "within a reasonable period" retrieves and says nothing.
  • One base per subject. Mixing the refund policy with the API reference retrieves from both and dilutes the context.

Check that it is actually being used

terminal
npx zavudev agents test --sender "$SENDER_ID" --message "can I return an opened item?"

The reply comes with knowledge chunk(s) used. Zero on an agent that has documents means the answer was not grounded in them, which is otherwise indistinguishable from a correct answer.

The size limit is your source file

Documents live inline, and the deploy caps source at about 900KB. A knowledge base larger than that belongs on the API:

terminal
npx zavudev agents knowledge-bases create --sender "$SENDER_ID" --name "Manuals" npx zavudev agents knowledge-bases documents add --sender "$SENDER_ID" --kb "$KB_ID" \ --title "Manual" --content-file ./manual.md

Both paths write to the same place. Declaring in code is better when the documents belong with the agent and change with it; the API is better when they are large, or maintained by someone who does not deploy.

Flows

defineFlow declares a deterministic conversation: the steps you wrote, in the order you wrote them. Reach for it where the model must not improvise, and leave the rest of the conversation to the agent.

TypeScript
import { defineAgent, defineTool, defineFlow } from "@zavudev/functions" defineAgent({ name: "Support", provider: "zavu", model: "openai/gpt-5.6-luna", prompt: "Answer support questions. Be brief.", }) defineTool({ name: "create_ticket", description: "Open a support ticket.", parameters: { type: "object", properties: { email: { type: "string" }, issue: { type: "string" } }, required: ["email", "issue"], }, handler: async ({ email, issue }) => ({ id: "TCK-1", email, issue }), }) defineFlow({ name: "Repair intake", trigger: { type: "keyword", keywords: ["broken", "repair"] }, steps: [ { id: "ask_issue", type: "collect", config: { variable: "issue", prompt: "What is wrong with it?" }, nextStepId: "ask_email", }, { id: "ask_email", type: "collect", config: { variable: "email", prompt: "What is your email?", validation: { type: "email" } }, nextStepId: "open", }, { id: "open", type: "tool", config: { toolName: "create_ticket", params: { email: "{{email}}", issue: "{{issue}}" }, }, nextStepId: "done", }, { id: "done", type: "message", config: { text: "Logged. We will email {{email}}." } }, ], })

The step shapes, the validation types and what each trigger does are in the flows guide. Three things are specific to declaring one in code:

A new flow arrives disabled

A flow intercepts real conversations, so writing one and turning it on are separate decisions. Pass enabled: true to have the first deploy enable it, or turn it on afterwards:

terminal
npx zavudev agents flows update <flowId> --sender "$SENDER_ID" --data '{"enabled":true}'

Later deploys never change enabled. Pausing a flow from the dashboard during an incident has to survive a redeploy.

The tool a step calls must exist on the agent

Declaring it with defineTool in the same file is enough, because tools reconcile before flows. A step naming a tool the agent does not have is reported and that flow is skipped; the rest of the deploy still lands.

The declaration is the source of truth, with two exceptions

A flow you stop declaring is deleted on the next deploy. Two things are left alone:

  • A flow this function did not create. One built in the dashboard with the same name is not adopted and not overwritten. The deploy says so.
  • A flow someone is standing in. If a contact is mid-conversation inside it, it is kept and removed on a later deploy, rather than stranding them.

Providers

No API key needed. LLM costs are billed directly from your Zavu balance at pass-through rates.

TypeScript
defineAgent({ ..., provider: "zavu", model: "openai/gpt-5.6-luna", // provider/model id, the gateway picks the right backend })

Available models on the gateway:

modelBackendBest for
openai/gpt-5.6-lunaOpenAI GPT-5.6 LunaDefault for new agents. Low cost, tool use
openai/gpt-4o-miniOpenAI GPT-4o miniLower price per token, older model
openai/gpt-4oOpenAI GPT-4oHigh-quality reasoning
anthropic/claude-3-5-haiku-20241022Anthropic HaikuFast, good tool-use
anthropic/claude-3-5-sonnet-20241022Anthropic SonnetBest multi-step reasoning
google/gemini-2.5-flash-liteGoogle Flash LiteCheapest multilingual
google/gemini-2.5-flashGoogle FlashCheap multilingual
google/gemini-2.5-proGoogle ProLong context windows

Bring your own key (BYOK)

Pass provider matching the vendor and apiKey (or pre-create a secret in the dashboard and rely on it being already stored).

TypeScript
defineAgent({ ..., provider: "openai", model: "gpt-4o-mini", apiKey: process.env.OPENAI_API_KEY!, // set via `npx zavudev fn secrets set` })

Models follow each vendor's own naming (gpt-4o-mini, claude-3-5-sonnet-20241022, gemini-2.5-flash, etc) — no provider/ prefix when using BYOK.

Prompts

The prompt field is the system message every conversation starts with. It's where you set the persona, rules, and guardrails.

Patterns that work

TypeScript
prompt: `You are Bella, host of Bella Pizzeria. Your job: - Help guests view the menu and book reservations. - ONLY use the tools provided — never invent prices or availability. - Confirm every reservation by reading back the time and code before closing. Tone: - Friendly, brief (WhatsApp messages should fit one screen). - Match the language the customer writes in. Edge cases: - If asked something outside your scope (delivery, jobs, complaints), say: "Para eso te paso con un humano." and stop responding. `

Anti-patterns

Triggers (which messages reach the agent)

By default the agent fires on every inbound message to its sender. Restrict with channels and messageTypes:

TypeScript
defineAgent({ ..., channels: ["whatsapp"], // only WhatsApp, ignore SMS to the same sender messageTypes: ["text"], // ignore images, audio, etc })

If you need fine-grained event triggers (e.g. only when a specific sender fires message.inbound), use explicit triggers in addition.

Staying silent with NO_REPLY

When the agent's whole answer, trimmed, is exactly NO_REPLY, nothing is sent, on any channel. The execution is recorded as filtered with a message saying the agent chose not to reply. The tokens it used are still billed.

Tell the agent in its prompt when to use it:

TypeScript
defineAgent({ ..., prompt: "... If the message is a thank-you or needs no answer, reply NO_REPLY.", })

Group chats

By default an agent answers one-to-one conversations only. Set groups: true to have it answer group chats too, on channels that carry them:

TypeScript
defineAgent({ ..., prompt: "Answer only when someone addresses you by name. Otherwise reply NO_REPLY.", groups: true, })
  • The reply goes to the group, not privately to the person who wrote.
  • The agent is asked about every message. Each line reaches it as Name (+number): text, or the number alone when the author has no profile name. Anyone can set any profile name, so if it matters who is speaking, have the prompt rely on the number.
  • The agent is told it is in a group and that NO_REPLY posts nothing. Put your own rule for when to stay quiet in the prompt.
  • Groups have no flows or handoffs, and the agent can reply at most 12 times per group per minute, so two agents in one group cannot loop forever.
  • Group messages reach your webhooks and Functions whether groups is on or off. Removing groups turns it off on the next deploy.

Several files

Split the agent across as many files as it needs. Imports between them are resolved when you deploy:

TypeScript
// index.ts import { formatOrder } from "./lib/orders" defineTool({ name: "order_status", handler: async ({ id }) => formatOrder(await lookup(id)), })
TypeScript
// lib/orders.ts export function formatOrder(order: Order) { return `${order.id} — ${order.status}` }

npx zavudev deploy starts at your entrypoint (index.ts by default) and uploads every file it reaches through relative imports. Files nothing imports stay on your machine, so tests and scratch work are not deployed.

npm packages are not part of that tree: declare them under dependencies in package.json and they are installed and bundled.

Code
my-agent/ ├── index.ts ← entrypoint, deployed ├── lib/orders.ts ← imported, deployed ├── lib/orders.test.ts ← nothing imports it, stays local └── package.json ← dependencies, not uploaded as a file

A few paths are refused: anything above the project root (../shared/x.ts), node_modules/, and names starting with __zavu. A project can hold up to 200 files totalling 900,000 bytes. If you import something from outside the project directory, either move it inside or publish it as an npm package.

The dashboard editor works the same way — add files in the sidebar and import between them.

To deploy straight from a repository instead, link it:

terminal
npx zavudev fn git link acme/my-agent --branch main

Every push to that branch redeploys.

One agent per file

defineAgent is allowed multiple times only when each call has a different (senderId, name) pair, in which case each tool must explicitly bind to one:

TypeScript
defineAgent({ senderId: process.env.SENDER_A!, name: "Sales", ... }) defineAgent({ senderId: process.env.SENDER_B!, name: "Support", ... }) defineTool({ name: "lookup_order", agent: "Support", // explicit binding description: "...", parameters: { ... }, handler: async (args) => { ... }, })

For most use cases you want one agent per function file. Multiple senders sharing identical logic? Use the same code in multiple functions, each with its own SENDER_ID secret.

Updates and ownership

Every npx zavudev deploy reconciles the live agent to match what's in the code.

StateReconcile behavior
No agent exists yetCreate it, mark as managed by this function.
Manually-created agent with same (senderId, name)Takes ownership. What the code declares is applied; model settings the code leaves out (temperature, maxTokens, ...) keep their current values. From then on only the model settings can be edited in the dashboard.
Agent managed by THIS functionUpdate fields to match the manifest. Model settings follow the rule below.
Agent managed by a DIFFERENT functionSkipped with a warning. Rename in code or delete the other function first.

When you remove defineAgent from your code and redeploy, the managed agent is deleted. Manual agents are never touched.

Model settings changed outside the code

provider, model, temperature, maxTokens, contextWindowMessages, includeContactMetadata and sessionTimeoutMinutes can also be changed in the dashboard or with PATCH /v1/agents/{agentId}, to try another model without a deploy. A deploy writes one of them only when the code changed it since the previous deploy:

  • You change model in the code and deploy: the new model is applied.
  • You pick a model in the dashboard and later deploy for an unrelated reason: the dashboard's model stays, and the deploy output says the code declares a different one.
  • You remove temperature from the code: it is cleared.

provider and model count as one setting. Everything else the code declares (name, prompt, channels, voice) is written on every deploy.

Disabling without deleting

enabled is read once, when the deploy creates the agent. Declare enabled: false to create it paused.

After that, deploys never change it, so a pause survives a redeploy. Pause or resume an existing agent with the Active switch in the dashboard, or PATCH /v1/agents/{agentId} with { "enabled": false }. While it is paused the agent stays in the database and messages to its sender still reach your webhooks.

Common patterns

Multi-language agent

Don't hard-code a language. Tell the prompt to match the user:

TypeScript
prompt: `… Always reply in the language the customer wrote in. If unclear, default to Spanish.`

The LLM handles language detection per turn — no need for routing logic.

Escalation to human

Add a tool that signals escalation (e.g., creates a ticket in your CRM or notifies an operator on Slack). Tell the prompt when to use it:

TypeScript
prompt: `… If the customer asks for a refund, asks to "speak to a human", or expresses strong frustration, call escalate_to_agent and tell them someone will reach out within 15 minutes.`
Different agent per sender (multi-tenant)

Same code, different SENDER_ID secret in each function. The prompt can even include process.env.BRAND_NAME to customize per tenant:

TypeScript
defineAgent({ ..., name: process.env.BRAND_NAME!, prompt: `You are the assistant for ${process.env.BRAND_NAME}…`, })
Reading contact metadata

With includeContactMetadata: true (default), the LLM sees:

  • The contact's profile name, as the channel reported it (profileName).
  • Custom metadata fields on the contact.

It does not see the contact's phone number. To look something up by who is writing (their appointment, their order), read the number in the tool handler, not from the model: ctx.contactPhone in a defineTool handler. Do not make the phone a tool parameter; the model would have to guess it.

Set metadata via client.contacts.update(contactId, { metadata: {...} }). Useful for "Hello $name" style personalization without a tool call.

Next