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

# defineAgent

> Configure the LLM brain that drives your conversation.

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

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

| Field | Type | Notes |
| - | - | - |
| `name` | string | Display name. Shown in `npx zavudev agents executions` and the dashboard. |
| `provider` | `zavu \| openai \| anthropic \| google \| mistral` | LLM provider. |
| `model` | string | Model id (depends on provider — see below). |
| `prompt` | string | System prompt. `systemPrompt` is accepted as an alias. Multi-line OK (use backticks). |

<Warning>
  A declaration missing any of these four **throws**. `defineAgent` runs at module
  load, so the error surfaces during the deploy's manifest probe: the function
  itself still goes live, but nothing declared in your source syncs — agents and
  tools stay exactly as they were. The CLI says so in the deploy output and exits
  non-zero, so a script or CI job that only reads the exit code still catches it.

  Only those field names are read. `description` and `instructions` are not
  fields — an agent whose prompt is written under one of them has no `prompt`, and
  throws the same way.
</Warning>

## Optional fields

| Field | Default | Description |
| - | - | - |
| `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`, … |
| `groups` | `false` | Answer in group chats, on channels that carry them. Off means the agent answers one-to-one conversations only. See [Group chats](#group-chats). |
| `apiKey` | — | Required when `provider !== "zavu"` (or pre-create a secret in the dashboard). |
| `contextWindowMessages` | `10` | How many previous messages to include in each LLM call. |
| `temperature` | provider default | 0–2. |
| `maxTokens` | unbounded | Cap on response length. |
| `sessionTimeoutMinutes` | `60` | After this idle gap, the agent starts a new conversation. |
| `includeContactMetadata` | `true` | If true, contact name / metadata is included as context. |
| `enabled` | `true` | Set 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.

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

<Note>
  Voice requires a phone number assigned to the sender. The fastest way to start is to pull a ready-made
  voice agent: see [Factory agents](/guides/functions/factory-agents).
</Note>

## Knowledge base

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

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

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

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

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

```ts theme={null}
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](/guides/ai-agents/flows). 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:

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

### `zavu` — managed gateway (recommended for getting started)

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

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

Available models on the gateway:

| `model` | Backend | Best for |
| - | - | - |
| `openai/gpt-5.6-luna` | OpenAI GPT-5.6 Luna | Default for new agents. Low cost, tool use |
| `openai/gpt-4o-mini` | OpenAI GPT-4o mini | Lower price per token, older model |
| `openai/gpt-4o` | OpenAI GPT-4o | High-quality reasoning |
| `anthropic/claude-3-5-haiku-20241022` | Anthropic Haiku | Fast, good tool-use |
| `anthropic/claude-3-5-sonnet-20241022` | Anthropic Sonnet | Best multi-step reasoning |
| `google/gemini-2.5-flash-lite` | Google Flash Lite | Cheapest multilingual |
| `google/gemini-2.5-flash` | Google Flash | Cheap multilingual |
| `google/gemini-2.5-pro` | Google Pro | Long 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).

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

<Tip>
  On first deploy, if you pass `apiKey`, we create a row in `apiSecrets`
  encrypted with AES-256-GCM and reference it from the agent. Subsequent
  deploys without `apiKey` reuse the same stored secret. To rotate, pass a
  new `apiKey` and redeploy.
</Tip>

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

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

<Warning>
  **Don't** put prices, menu items, or any data that changes in the prompt. Put
  them in tool handlers so they stay current without redeploys.

  **Don't** make the prompt longer than \~2,000 characters. LLMs lose focus on
  the rules with verbose prompts; tools are how you scope behavior.
</Warning>

## Triggers (which messages reach the agent)

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

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

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

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

<Note>
  `groups` needs a current runtime. A function pinned to an older runtime
  deploys with the field ignored; deploy it with `npx zavudev deploy --update-runtime`.
</Note>

## Several files

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

```ts theme={null}
// index.ts
import { formatOrder } from "./lib/orders"

defineTool({
  name: "order_status",
  handler: async ({ id }) => formatOrder(await lookup(id)),
})
```

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

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

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

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

| State | Reconcile behavior |
| - | - |
| No agent exists yet | Create 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 function | Update fields to match the manifest. Model settings follow the rule below. |
| Agent managed by a DIFFERENT function | Skipped 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.

<Warning>
  Deleting the agent takes **everything attached to it**, and that is wider than
  the rest of this table: its managed tools and flows, and **every knowledge base
  on that agent, including one you built in the dashboard**, with its documents.

  Not an oversight. A knowledge base belongs to one agent, and every route
  resolves the agent before it. Once the agent is gone nobody can list, read or
  delete what was attached to it, so leaving it behind hides customer content
  instead of preserving it.

  If a knowledge base has to outlive the agent, move it to another one before you
  stop declaring the agent. The same applies to deleting the whole function.
</Warning>

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

<AccordionGroup>
  <Accordion title="Multi-language agent">
    Don't hard-code a language. Tell the prompt to match the user:

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

  <Accordion title="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:

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

  <Accordion title="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:

    ```ts theme={null}
    defineAgent({
      ...,
      name: process.env.BRAND_NAME!,
      prompt: `You are the assistant for ${process.env.BRAND_NAME}…`,
    })
    ```
  </Accordion>

  <Accordion title="Reading contact metadata">
    With `includeContactMetadata: true` (default), the LLM sees:

    * Contact's display name (if known).
    * Custom metadata fields on the contact.
    * Channel they wrote from.

    Set metadata via `client.contacts.update(contactId, { metadata: {...} })`.
    Useful for "Hello \$name" style personalization without a tool call.
  </Accordion>
</AccordionGroup>

## Next

<CardGroup cols={2}>
  <Card title="defineTool" icon="wrench" href="/guides/functions/defining-tools">
    Give the agent actions to execute.
  </Card>

  <Card title="Secrets" icon="key" href="/guides/functions/secrets">
    Store SENDER\_ID, API keys, and other env vars.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.