AI Agent
When to Use
Use this skill when the user wants to configure AI agents through API calls (no source files, no deploy step) — typically from a dashboard or a backend script that creates/updates the agent imperatively.
If the user wants a code-first agent with custom tool handlers in TypeScript, deployed via the zavu CLI, route them to the functions skill instead. That path is more ergonomic, version-controlled, and has built-in deployment + debugging.
Quick routing:
| User says… | Use |
|---|---|
"I want my agent's tool to query my database" / "I want to write the tool handler in code" / defineTool / npx zavudev deploy |
functions skill |
| "Set up an agent that calls a webhook on my server" / "Configure from the dashboard" / "Create an agent via API" | this skill |
| "I'm starting from scratch and want the simplest path" | functions skill (recommended default) |
The imperative API documented here is fully supported and won't be deprecated, but Functions is the recommended path for most new integrations.
Senders vs accounts (one paragraph)
A Sender is the API handle you pass as Zavu-Sender; accounts (a WhatsApp Business Account, a Facebook Page, a Telegram bot, a phone number) are the connections it routes — and what bills. Senders are free. Connecting an account in the dashboard auto-creates its sender; find it with GET /v1/senders and trust its channels array for what it can send. See the channel-setup skill for the full model.
Architecture
Inbound message -> Flow check (keyword match, or an `always` flow?)
-> YES: Execute flow steps
-> NO: LLM call with system prompt + context + KB
-> Agent generates response -> Send reply
Two ways to build one
As code, with defineAgent. The agent lives in a Zavu Function, next to the
tools it calls, and npx zavudev deploy reconciles it. Prefer this when the agent has
tools, when it should be reviewable in a pull request, or when it is part of an
app you already deploy. See the functions skill for the full shape.
import { defineAgent, defineTool } from "@zavudev/functions"
export const support = defineAgent({
name: "Customer Support",
senderId: process.env.SENDER_ID!,
provider: "zavu",
model: "deepseek/deepseek-v4-flash-0731",
// `prompt` here, `systemPrompt` over the REST API. The two paths name this
// field differently and only `prompt` compiles against @zavudev/functions.
prompt: "You are a helpful support agent for Acme Corp. Be concise.",
tools: [orderStatus],
})
Deploy it with npx zavudev deploy. The reconcile output names every agent and
tool it created or updated, so read it rather than assuming: a deploy can
succeed while the declarations fail to sync, and it says so when that happens.
Through the API, shown below. Prefer this for one-off setup, for agents managed by a dashboard you are building, or when there is no function to attach the agent to.
Whichever you pick, an agent is created disabled. It answers nothing until you enable it.
Create Agent
Each sender can have one agent:
const result = await zavu.senders.agent.create({
senderId: "snd_abc123",
name: "Customer Support",
provider: "openai",
model: "gpt-4o-mini",
systemPrompt: "You are a helpful customer support agent for Acme Corp. Be friendly, concise, and helpful. If you don't know the answer, say so.",
apiKey: process.env.PROVIDER_API_KEY,
contextWindowMessages: 10,
includeContactMetadata: true,
triggerOnChannels: ["sms", "whatsapp"],
triggerOnMessageTypes: ["text"],
});
console.log(result.agent.id); // agent_xxx
Python:
result = zavu.senders.agent.create(
sender_id="snd_abc123",
name="Customer Support",
provider="openai",
model="gpt-4o-mini",
system_prompt="You are a helpful customer support agent...",
api_key=os.environ["PROVIDER_API_KEY"],
)
Go:
result, err := client.Senders.Agent.Create(context.TODO(), zavudev.AgentCreateParams{
SenderID: zavudev.String("snd_abc123"),
Name: zavudev.String("Customer Support"),
Provider: zavudev.String("openai"),
Model: zavudev.String("gpt-4o-mini"),
SystemPrompt: zavudev.String("You are a helpful customer support agent..."),
APIKey: zavudev.String(os.Getenv("PROVIDER_API_KEY")),
})
Ruby:
result = client.senders.agent.create(
sender_id: "snd_abc123",
name: "Customer Support",
provider: "openai",
model: "gpt-4o-mini",
system_prompt: "You are a helpful customer support agent...",
api_key: ENV["PROVIDER_API_KEY"],
)
PHP:
$result = $client->senders->agent->create([
'senderId' => 'snd_abc123',
'name' => 'Customer Support',
'provider' => 'openai',
'model' => 'gpt-4o-mini',
'systemPrompt' => 'You are a helpful customer support agent...',
'apiKey' => getenv('PROVIDER_API_KEY'),
]);
Provider & Model Selection
| Provider | Models | API Key Required |
|---|---|---|
openai |
gpt-4o, gpt-4o-mini, gpt-4-turbo |
Yes |
anthropic |
claude-3-5-sonnet, claude-3-haiku |
Yes |
google |
gemini-1.5-pro, gemini-1.5-flash |
Yes |
mistral |
mistral-large, mistral-small |
Yes |
zavu |
Zavu-hosted models | No (included) |
Update & Toggle Agent
// Update configuration
await zavu.senders.agent.update({
senderId: "snd_abc123",
systemPrompt: "Updated prompt...",
temperature: 0.7,
maxTokens: 500,
});
// Enable/disable
await zavu.senders.agent.update({
senderId: "snd_abc123",
enabled: false,
});
Voice
The agent can also answer and place phone calls through Zavu's co-located voice network (speech recognition, the agent's LLM, and speech synthesis, with real-time interruption handling). The systemPrompt, tools, and knowledge bases all apply on a call — voice just adds a spoken channel on top. For a code-first voice agent declared in TypeScript, use the functions skill instead.
Requirements
- The Voice Agents feature must be enabled for your team (call endpoints return
403otherwise). - The agent must have
voice.enabled: true. - The sender must have the voice channel on. An agent with a voice on a sender that cannot take calls looks configured and never rings. The sender needs a phone number your project owns, plus
enableVoice:
Confirm withnpx zavudev senders update snd_abc123 --enable-voicenpx zavudev senders get snd_abc123:channelsmust containvoice. That array is computed from the sender's real configuration, so it is the answer, not a stored flag. - Not available with test-mode keys — use a live (
zv_live_...) key. - Calls are billed per connected minute plus telephony, deducted from your prepaid balance.
Enable voice on an agent
Voice lives under the agent's voice object on POST/PATCH /v1/senders/{senderId}/agent. The SDK does not type the voice field yet, so set it over REST:
curl -X PATCH https://api.zavu.dev/v1/senders/snd_abc123/agent \
-H "Authorization: Bearer $ZAVU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"voice": {
"enabled": true,
"greeting": "Hi, thanks for calling Acme. How can I help you today?",
"language": "en",
"ttsVoiceId": "aura-2-thalia-en",
"interruptible": true,
"maxCallDurationMinutes": 15,
"maxIdleSeconds": 30,
"voicemailAction": "hangup",
"transferPhoneNumber": "+14155551234"
}
}'
| Field | Description |
|---|---|
enabled |
Whether the agent handles calls. Required. When false, its number is not answered and outbound calls are rejected. |
greeting |
Opening line spoken when the call connects (max 1000). If omitted, the agent waits for the caller to speak first. |
language |
BCP-47 code for recognition and synthesis (e.g. en, es, pt-BR). Auto-detected from the recipient when omitted. |
ttsVoiceId |
Voice used for synthesis. List the ids with npx zavudev agents voices (or GET /v1/agents/voices); a name that is not in that list is ignored. Neutral default when omitted. |
interruptible |
Caller can barge in while the agent is speaking. Default true. |
maxCallDurationMinutes |
Hard call-length cap, 1-120. Default 15. |
maxIdleSeconds |
Silence before the agent ends the call, 5-300. Default 30. |
voicemailAction |
On an answering machine (outbound): hangup or leave_message. Default hangup. |
voicemailMessage |
Spoken when voicemailAction is leave_message (max 1000). Falls back to greeting. |
transferPhoneNumber |
E.164 number the agent can transfer the call to. Setting it gives the agent a transfer tool. |
Once enabled, the sender's number answers inbound calls automatically.
Place an outbound call
The SDK has a calls resource since @zavudev/sdk 0.56.0. to is the only required field; senderId defaults to the project's default sender (whose agent must have voice enabled).
const { call } = await zavu.calls.create({
to: "+56912345678",
senderId: "snd_abc123",
greeting: "Hi, this is Acme calling about your appointment.",
maxDurationMinutes: 10,
metadata: { campaign: "appointment_reminders" },
})
Over REST (or on an older SDK):
curl -X POST https://api.zavu.dev/v1/calls \
-H "Authorization: Bearer $ZAVU_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+56912345678",
"senderId": "snd_abc123",
"greeting": "Hi, this is Acme calling about your appointment.",
"maxDurationMinutes": 10,
"metadata": { "campaign": "appointment_reminders" }
}'
Returns 202 with the call object as it starts dialing. greeting and maxDurationMinutes override the agent's config for this call only.
Fetch a call and its transcript
SDK: zavu.calls.retrieve(callId) (includes the transcript), zavu.calls.list({ status, direction }) (auto-paginating), zavu.calls.hangup(callId). Over REST:
# Single call, including the ordered transcript
curl https://api.zavu.dev/v1/calls/call_abc123 \
-H "Authorization: Bearer $ZAVU_API_KEY"
# List recent calls (filter by status / direction)
curl "https://api.zavu.dev/v1/calls?direction=outbound&status=completed&limit=50" \
-H "Authorization: Bearer $ZAVU_API_KEY"
# Hang up an active (ringing or in-progress) call
curl -X POST https://api.zavu.dev/v1/calls/call_abc123/hangup \
-H "Authorization: Bearer $ZAVU_API_KEY"
The transcript is a list of turns, each { seq, role, text } where role is user, assistant, or tool. It is included when fetching a single call and omitted from the list. durationSeconds, endReason, turnCount, and cost populate once the call ends.
Addressing an agent
Tools, flows and knowledge bases are reachable two ways, and they are the same resource:
/v1/senders/{senderId}/agent/tools via the sender that answers with it
/v1/agents/{agentId}/tools via the agent itself
Use the agent-scoped form when the agent has no sender yet, which is what
POST /v1/agents creates. The sender-scoped form cannot address it, so an
agent you were told to assemble standalone used to be un-assemblable.
AGENT=$(curl -s -X POST https://api.zavu.dev/v1/agents \
-H "Authorization: Bearer $ZAVUDEV_API_KEY" \
-d '{"name":"Ada","provider":"zavu","model":"openai/gpt-4o-mini","systemPrompt":"..."}' \
| jq -r .agent.id)
curl -X POST https://api.zavu.dev/v1/agents/$AGENT/tools -d '{...}'
curl -X POST https://api.zavu.dev/v1/agents/$AGENT/knowledge-bases -d '{...}'
curl -X POST https://api.zavu.dev/v1/agents/$AGENT/senders -d "{\"senderId\":\"$SENDER_ID\"}"
curl -X PATCH https://api.zavu.dev/v1/agents/$AGENT -d '{"enabled":true}'
Conversational Flows
Flows handle structured conversations (keyword triggers, data collection):
const result = await zavu.senders.agent.flows.create({
senderId: "snd_abc123",
name: "Lead Capture",
description: "Capture lead information from interested prospects",
trigger: {
type: "keyword",
keywords: ["info", "pricing", "demo"],
},
steps: [
{
id: "welcome",
type: "message",
config: { text: "Thanks for your interest! Let me get some info." },
nextStepId: "ask_name",
},
{
id: "ask_name",
type: "collect",
config: { variable: "name", prompt: "What's your name?" },
nextStepId: "ask_email",
},
{
id: "ask_email",
type: "collect",
config: { variable: "email", prompt: "What's your email?" },
nextStepId: "confirm",
},
{
id: "confirm",
type: "message",
config: { text: "Thanks {{name}}! We'll reach out at {{email}}." },
},
],
enabled: true,
priority: 10,
});
Trigger Types
| Type | Description |
|---|---|
keyword |
Matches specific keywords in message |
always |
Runs on every inbound message not already inside a flow |
intent and manual are accepted by the API and stored on the flow, and the
matcher has no branch for either: a flow created with one never triggers, and
nothing reports that. Use keyword or always.
A transfer step sends its message, marks the session transferred, and
silences the agent for that contact until a person answers. Their messages
still arrive and show in the inbox; the agent just stops replying. Any outbound
message you send them ends the handoff and the agent resumes. Nobody is notified
for you, though: notifyWebhook and reason are accepted and never read, so put
a tool step before the transfer if your team needs a ping.
A collect step's date validation accepts any text including tomorrow. Constrain a date with choice, or
validate it in your own tool.
Flow sessions do not expire. A contact who stops answering halfway resumes at the same step whenever they write again, with the variables they had.
Step Types
| Type | Description |
|---|---|
message |
Send a message |
collect |
Collect user input into a variable |
condition |
Branch based on conditions |
tool |
Call a webhook tool |
llm |
Make an LLM call |
transfer |
Transfer to human agent |
The tool step, and the field that looks like an id
{
id: "save_lead",
type: "tool",
config: {
toolName: "create_lead", // the tool's name, or its id
action: "call",
params: { email: "{{email}}", source: "whatsapp" },
storeResultAs: "lead",
},
nextStepId: "confirm",
}
config.toolId is the original spelling and still works. Either field is
accepted, and either one may hold the tool's name or its id: toolId was read
as a name for a long time while the dashboard wrote real ids into it, so both
shapes exist in the wild and both resolve.
Values wrapped in {{ }} are replaced with data collected earlier in the flow;
anything else is passed through literally. Omit params for a tool that takes
none. The tool must already exist on the agent before the flow runs.
A step pointing at a tool the agent does not have ends the session as abandoned and logs the misconfiguration. It is not counted as a completed flow, and nothing is said to the contact: the plain agent picks the conversation up from their next message.
Flow Operations
// List flows
const flows = await zavu.senders.agent.flows.list({ senderId: "snd_abc123" });
// Update flow
await zavu.senders.agent.flows.update({
senderId: "snd_abc123",
flowId: "flow_abc123",
enabled: false,
});
// Duplicate flow
await zavu.senders.agent.flows.duplicate({
senderId: "snd_abc123",
flowId: "flow_abc123",
newName: "Lead Capture (Copy)",
});
// Delete flow
await zavu.senders.agent.flows.delete({
senderId: "snd_abc123",
flowId: "flow_abc123",
});
Booking skills you do not have to build
check_availability and book_meeting are hosted by Zavu: no endpoint, no
hosting, no secret. They read and write one calendar per project (Cal.com or
Google Calendar), and they behave identically on a call and in a thread.
They are added from the dashboard, in the agent's Tools tab under Library, along with the calendar connection itself. There is no REST or SDK surface for them yet, so do not tell a user to add one with the API.
What matters when a booking agent uses them: book_meeting only reports a
booking when the calendar accepted it. A Cal.com event type that requires
confirmation answers pending, and the skill tells the agent to say the time is
held but not confirmed. Never write a prompt that instructs the agent to
announce a confirmation before the tool result says so.
Webhook Tools
Tools let the agent call your backend during conversations.
Which channels call them: all of them. Tools are offered to the model on voice, on plain text (WhatsApp, SMS, Telegram, email), and inside a flow's
toolstep. All three dispatch through the same executor, so a tool cannot behave differently per channel. The model may chain up to five tool rounds in one reply, and you are billed for the tokens the whole loop uses.Reach for a flow when the sequence must be deterministic, not because text cannot call tools. It can.
Verify per execution rather than assuming:
toolCalls: 0on an agent that has tools means it answered without calling any, which is the case where the reply says "let me check that" and nothing happened.
const result = await zavu.senders.agent.tools.create({
senderId: "snd_abc123",
name: "get_order_status",
description: "Get the current status of a customer order",
webhookUrl: "https://api.example.com/webhooks/order-status",
webhookSecret: process.env.WEBHOOK_SECRET,
parameters: {
type: "object",
properties: {
order_id: { type: "string", description: "The order ID to look up" },
},
required: ["order_id"],
},
});
// Test tool
await zavu.senders.agent.tools.test({
senderId: "snd_abc123",
toolId: "tool_abc123",
testParams: { order_id: "ORD-12345" },
});
Knowledge Bases (RAG)
A prompt that says "only state what the documentation returns" with no documents
attached does not refuse — it invents. Attach the documents, then verify with
agents test, which reports how many chunks it actually retrieved.
From the CLI:
npx zavudev agents knowledge-bases create --sender snd_abc123 --name "Product docs"
npx zavudev agents knowledge-bases documents add --sender snd_abc123 --kb <kbId> \
--title "Pricing" --content-file ./pricing.md
npx zavudev agents knowledge-bases documents list --sender snd_abc123 --kb <kbId>
Processing takes a few seconds; isProcessed flips to true and chunkCount
fills in.
Add documents for the agent to reference via retrieval-augmented generation:
// Create knowledge base
const kb = await zavu.senders.agent.knowledgeBases.create({
senderId: "snd_abc123",
name: "Product FAQ",
description: "Frequently asked questions about our products",
});
// Add document
await zavu.senders.agent.knowledgeBases.documents.create({
senderId: "snd_abc123",
kbId: kb.knowledgeBase.id,
title: "Return Policy",
content: "Our return policy allows returns within 30 days of purchase...",
});
// List documents
const docs = await zavu.senders.agent.knowledgeBases.documents.list({
senderId: "snd_abc123",
kbId: kb.knowledgeBase.id,
});
Monitoring
// Get agent stats
const stats = await zavu.senders.agent.stats({ senderId: "snd_abc123" });
console.log(`Invocations: ${stats.totalInvocations}`);
console.log(`Tokens: ${stats.totalTokensUsed}`);
console.log(`Cost: $${stats.totalCost}`);
// List executions
const executions = await zavu.senders.agent.executions.list({
senderId: "snd_abc123",
status: "error",
limit: 20,
});
for (const exec of executions.items) {
console.log(exec.id, exec.status, exec.errorMessage);
}
Execution Statuses
| Status | Description |
|---|---|
success |
Agent generated response successfully |
error |
Execution failed (LLM error, tool error, etc.) |
filtered |
Response blocked by safety filters |
rate_limited |
Provider rate limit exceeded |
balance_insufficient |
Account balance too low to process |
Agents are addressed by their own id
An agent is a standalone object. It can answer on several senders, and it can exist with none while you build it.
npx zavudev agents list # every agent in the project, with ids
npx zavudev agents list --json
id name kind enabled senders model
qd75detym58c6has4sfrye3vws8b6ygt Atlas voice yes 1 openai/gpt-4o-mini
qd7ck28evcskdc3xx4wzevtmeh8b6bcm Pizza Desk text no 0 gpt-4o-mini
Over REST:
GET /v1/agents |
List, including agents with no sender |
POST /v1/agents |
Create standalone — no sender required |
GET /v1/agents/{agentId} |
Fetch one |
PATCH /v1/agents/{agentId} |
Update |
DELETE /v1/agents/{agentId} |
Delete |
POST /v1/agents/{agentId}/test |
Run it, return the reply, deliver nothing |
The older /v1/senders/{senderId}/agent routes still work, but they resolve a
sender to exactly ONE agent — so they cannot reach an agent that has no sender,
or the second agent on a shared one.
To exercise one tool rather than the whole agent:
POST /v1/senders/{senderId}/agent/tools/{toolId}/test |
Call the tool with your params, return what it answered |
GET /v1/senders/{senderId}/agent/tools/{toolId}/test-runs |
The recent runs, newest first |
From the CLI:
npx zavudev agents tools test <toolId> --sender <senderId> --params '{"orderId":"ORD-4417"}'
npx zavudev agents tools test-runs <toolId> --sender <senderId>
The test is synchronous and returns the tool's status, body, and duration, so a
result is evidence the tool ran. A tool that answers with an error comes back as
run.success: false and the endpoint still returns 200 — read the field, not the
status. It fires the real webhook, so it has whatever side effects the tool has.
test-runs covers manual tests only. A tool called by the agent during a real
conversation is not listed there; for that, agents executions shows how many
tools each reply called.
Connecting senders
npx zavudev agents senders connect --agent <agentId> --sender <senderId>
npx zavudev agents senders disconnect --agent <agentId> --sender <senderId>
POST /v1/agents/{agentId}/senders with {"senderId": "..."}, and
DELETE /v1/agents/{agentId}/senders/{senderId}.
A sender answers with at most one agent. Connecting one that is already in
use returns 400 naming the agent that holds it — the alternative would be an
agent that looks connected and never receives a message.
Test an agent without sending anything
npx zavudev agents test --agent <agentId> --message "where is order ORD-001?"
Runs the real prompt, model and knowledge base and prints what the agent would reply, plus tokens, latency and how many knowledge chunks were used. Nothing is delivered, nothing is charged, no execution is logged — safe to run in a loop while iterating on a prompt.
It also warns about what a dry run cannot prove: an agent that is disabled, tools its channels will never call, and contact metadata that will exist live but not here. Treat those warnings as part of the result.
Multi-turn and isolating the prompt from retrieval:
npx zavudev agents test --agent <agentId> \
--turn "I need to change my booking" --turn "Sure — which one?" \
--message "the one on Friday"
npx zavudev agents test --agent <agentId> --message "what do you cost?" --no-knowledge
Delete Agent
await zavu.senders.agent.delete({ senderId: "snd_abc123" });
Constraints
- One agent per sender. Writes do not enforce it — a second agent can be
created on a sender — but every read resolves a sender to exactly ONE agent,
so the extra one answers nothing and is invisible to
agents get.zavu deploywarns when it happens. - System prompt: max 10,000 characters
- Context window: 1-50 messages
- Temperature: 0-2
- Max tokens: 1-4,096
- Tool name: max 100 characters
- Tool description: max 500 characters
- Document content: max 100,000 characters
- Knowledge base name: max 100 characters
- Provider
zavudoesn't require an API key (uses Zavu-hosted models) - All other providers require your own API key