mcpField
A
ai-agent

ai-agent

github.com/zavudevAgent workflows1234 installsUpdated 1d ago
Repository

About ai-agent

A skill for configuring Zavu AI agents through API calls or a dashboard, aimed at imperative setup and updates. It covers messaging channels, conversational flows, tools, knowledge bases, and voice calls; the `functions` skill is recommended for most new integrations.

Key features

  1. 01Create and update agents through the API
  2. 02Configure providers, models, and prompts
  3. 03Manage senders and channel connections
  4. 04Set up flows, tools, and knowledge bases
  5. 05Enable voice and manage phone calls

Use cases

  1. 01Create a support agent from a backend script
  2. 02Update agent settings through a dashboard
  3. 03Configure keyword-triggered messaging flows
  4. 04Set up an agent for inbound or outbound calls

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/zavudev/zavu-skills --skill ai-agent

After installing, type/ai-agentin Claude Code to confirm it loaded.

Details

From the project README

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 deployfunctions 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: "openai/gpt-5.6-luna",
  // `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. Creating a second one on a sender that already answers with an agent (created on it, or connected to it with POST /v1/agents/{agentId}/senders) returns 409 conflict:

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: ["*"],
  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

ProviderModelsAPI Key Required
openaigpt-4o, gpt-4o-mini, gpt-4-turboYes
anthropicclaude-3-5-sonnet-latest, claude-3-5-haiku-latestYes
googlegemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-liteYes
mistralmistral-large, mistral-smallYes
zavuZavu-hosted modelsNo (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,
});

Switching provider needs a key for the new one. A stored provider key belongs to one provider. An update that moves provider to openai, anthropic, google or mistral while the agent holds a key for a different provider is refused with 400 invalid_request ("This API key is for anthropic, but the agent uses openai."), instead of saving an agent that would fail on its first message. Send provider and apiKey in the same request; both PATCH /v1/senders/{senderId}/agent and PATCH /v1/agents/{agentId} accept apiKey. Moving to zavu needs no key.

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 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:
    npx zavudev senders update snd_abc123 --enable-voice
    
    Confirm with npx zavudev senders get snd_abc123: channels must contain voice. 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"
    }
  }'
FieldDescription
enabledWhether the agent handles calls. Required. When false, its number is not answered and outbound calls are rejected.
greetingOpening line spoken when the call connects (max 1000). If omitted, the agent waits for the caller to speak first.
languageBCP-47 code for recognition and synthesis (e.g. en, es, pt-BR). Auto-detected from the recipient when omitted.
ttsVoiceIdVoice 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.
interruptibleCaller can barge in while the agent is speaking. Default true.
maxCallDurationMinutesHard call-length cap, 1-120. Default 15.
maxIdleSecondsSilence before the agent ends the call, 5-300. Default 30.
voicemailActionOn an answering machine (outbound): hangup or leave_message. Default hangup.
voicemailMessageSpoken when voicemailAction is leave_message (max 1000). Falls back to greeting.
transferPhoneNumberE.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 (queued, ringing or in-progress) call; an ended call answers 409
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. Once a sender is connected to it with POST /v1/agents/{agentId}/senders, the sender-scoped routes reach it too. POST /v1/agents takes apiKey like the sender-scoped create, so a standalone agent can use openai, anthropic, google or mistral as well as zavu. 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-5.6-luna","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,
});

A flow is created disabled unless the request sends enabled: true. On the list endpoints for flows and tools, ?enabled= accepts only true or false; anything else is 400.

Trigger Types
TypeDescription
keywordMatches specific keywords in message (case-insensitive substring)
intentMessage expresses the intent described in trigger.intent, judged for meaning rather than words
alwaysRuns on every inbound message not already inside a flow

For intent, write one plain sentence describing what the contact wants, in any language: { type: "intent", intent: "asks where their order is" }. Rules that decide which flow starts:

  • A keyword or always flow with a higher priority wins without the intent being considered. Give intent flows the higher number, or an always flow above them means they never start.
  • An intent flow starts only on a clear match. Otherwise the next keyword or always flow in priority order starts, and if there is none the agent answers.
  • At most 12 intent flows are considered per message (the 12 highest-priority). An intent is cut at 300 characters.
  • If the classifier is unavailable, the message is handled as if no intent matched: the contact is still answered, the flow does not start.
  • Exact tokens such as "STOP" or an order number carry no intent. Use keyword.

When a flow says nothing. A flow that ends on the message that started it, without replying, hands that message to the agent, and the agent answers it. Three things are silence on purpose and stay silent: a transfer step (even with an empty message), an llm step that chose not to reply, and a flow that already sent the contact something earlier.

  • To keep the agent quiet while a person handles a contact, end a flow in a transfer step. It silences the agent for that contact until someone sends them a message from the inbox or the API.
  • An always flow that replies claims every message, so the agent behind it never answers. Use it for a bot that is only a flow, not as a gate in front of an agent.
  • A condition cannot be the first step unless it has a defaultGoto: the API returns 400 invalid_request. Nothing has been collected yet, so its conditions come out the same on every message.
  • {{ }} reads only variables collected earlier in the same flow (collect, or a tool step's storeResultAs). Contact fields and metadata are not available: {{contact.plan}} is not replaced and the condition never matches. Names take no dots, so {{lead.status}} does not reach inside a stored object either. Look the value up in a tool step that returns a plain value (string, number or boolean) and compare that.

manual is accepted by the API and stored on the flow, and nothing starts it: a flow created with it never triggers, and nothing reports that.

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
TypeDescription
messageSend a message
collectCollect user input into a variable
conditionBranch based on conditions
toolCall a webhook tool
llmMake an LLM call
transferTransfer 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. If the flow had not said anything yet, the plain agent answers that message; otherwise it picks the conversation up from their next one.

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 tool step. 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: 0 on 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}`);
// successCount, errorCount and avgLatencyMs cover the 100 most recent
// executions, `filtered` ones excluded. avgLatencyMs is null when there are none.
console.log(stats.successCount, stats.errorCount, stats.avgLatencyMs);

// 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
StatusDescription
successAgent generated response successfully
errorExecution failed (LLM error, tool error, etc.)
filteredMessage dropped before the agent answered (channel or type not in its triggers, group reply cap), or the agent answered NO_REPLY and nothing was sent. errorMessage says which.
rate_limitedProvider rate limit exceeded
balance_insufficientAccount 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-5.6-luna
qd7ck28evcskdc3xx4wzevtmeh8b6bcm  Pizza Desk  text   no       0        gpt-4o-mini

Over REST:

GET /v1/agentsList, including agents with no sender
POST /v1/agentsCreate standalone — no sender required
GET /v1/agents/{agentId}Fetch one
PATCH /v1/agents/{agentId}Update
DELETE /v1/agents/{agentId}Delete
POST /v1/agents/{agentId}/testRun it, return the reply, deliver nothing

The older /v1/senders/{senderId}/agent routes still work. They resolve a sender to the ONE agent that answers on it: the agent created on it, else the agent connected to it with POST /v1/agents/{agentId}/senders. They cannot reach an agent that has no sender. GET /v1/senders/{senderId}/agent on a sender with no agent returns 404.

To exercise one tool rather than the whole agent:

POST /v1/senders/{senderId}/agent/tools/{toolId}/testCall the tool with your params, return what it answered
GET /v1/senders/{senderId}/agent/tools/{toolId}/test-runsThe 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 and no execution is logged.

A test run is billed like a live reply. On an agent with the zavu provider it is charged to your balance at the same per-token price (the response's cost), and refused with 402 insufficient_balance at a balance of zero. On a bring-your-own-key agent it costs your provider. Runs are rate limited per project (429). Test-mode keys are billed the same way, and executeTools with one still reaches your real handlers and writes real memory.

The model is offered the agent's tools but none runs: if it picks one, the reply stops there and plannedToolCalls says which, with what arguments. Pass executeTools to run them for real — that reaches your handlers.

It also warns about what a dry run cannot prove: an agent that is disabled, a sender without the channels the agent triggers on, 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" });

Channels an agent answers on

triggerOnChannels is a whitelist, and it is the most common way to end up with an agent that looks finished and is silent. An inbound message whose channel is not in the list is dropped before the agent runs: no reply and no error, while the dashboard Playground keeps answering, because it bypasses the filter. Look for it in GET /v1/senders/{senderId}/agent/executions, where the drop appears with status: "filtered" and an errorMessage naming the channel that arrived and the channels the agent listens on.

["*"] means every channel the sender carries, and is the right default. Narrow it only to deliberately exclude a channel, and remember that adding a channel to the sender later does NOT add it here. Unknown strings are accepted and match nothing, so a typo behaves exactly like a channel you meant to exclude.

Staying silent: NO_REPLY

When the agent's whole answer, trimmed, is exactly NO_REPLY, nothing is sent. This holds on every channel, and for a flow's llm step too. The execution is recorded with status: "filtered" and an errorMessage saying the agent chose not to reply; the tokens it used are still billed and counted.

Use it by telling the agent in its prompt when to answer NO_REPLY ("If the message is a thank-you or does not need an answer, reply NO_REPLY"). The model decides; nothing else about the conversation changes.

Constraints

  • One agent per sender. POST /v1/senders/{senderId}/agent returns 409 conflict when the sender already answers with an agent, and connecting a sender that is in use returns 400. Every read resolves a sender to exactly ONE agent.
  • Tool names are unique per agent. Creating a second tool with the same name returns 409 conflict.
  • 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 zavu doesn't require an API key (uses Zavu-hosted models)
  • All other providers require your own API key

Comments

Markdown supported