# Give your own agent Victoria AI tools

A small agent on the Claude API that attaches the Victoria AI MCP server through the MCP connector, kept read-only by its key and an allow-list of tools.

Works with: Claude API, Slack.

MCP tools used (https://docs.versionseven.ai/guides/connect-your-ai):

- `campaigns_list` (read) List the organization's campaigns.
- `campaign_analysis` (read) Funnel, replies, channels and the A/B verdict.
- `campaign_queue` (read) Backlog, daily capacity, runway and warnings.
- `campaign_step_funnel` (read) How leads move through each step.
- `campaign_sender_breakdown` (read) Outbound and replies per sender.
- `sender_accounts` (read) The connected LinkedIn and email accounts.
- `deals_lookup` (read) Find deals by stage, owner or lead.

The connectors in Claude and ChatGPT are the easy way to talk to Victoria AI. When the conversation should live somewhere else, in a Slack bot, an internal tool or a nightly job that writes a paragraph, you build the agent yourself on the Claude API. The MCP connector makes that short: the API connects to Victoria AI's MCP server on Anthropic's side, so your code sends a question and gets an answer, with the tool calls in between. This recipe is that agent, kept read-only by two independent means.

## Before you start

- An Anthropic API key in `ANTHROPIC_API_KEY`. The MCP connector is a beta feature of the Messages API; the SDK call below sets the beta header.
- A Victoria AI API key with `campaigns:read`, `accounts:read` and `crm:read` in `VICTORIA_API_KEY`, and nothing else. See [Create an API key](https://docs.versionseven.ai/help/api-keys). The server lists only the tools the key's scopes allow, so a read-only key can't reach a write tool whatever the model asks.
- Python 3.10 or later with `anthropic`, or Node.js 18 or later with `@anthropic-ai/sdk`.

## How it works

1. The request names the Victoria AI MCP server in `mcp_servers`, with the API key as its `authorization_token`, and adds an `mcp_toolset` for it to `tools`. Both are required together.
2. The toolset's `default_config` disables every tool, and `configs` enables the seven this agent may use. That's the second guard: even with a wider key, the model sees only these.
3. Claude reads the question, calls the tools it needs (the API makes those calls to Victoria AI server-side and feeds the results back), and answers. The response carries `mcp_tool_use` and `mcp_tool_result` blocks alongside the text, so the code can show which calls the answer rests on.

## The agent

**Python**

```python
# ask_victoria.py
import os
import sys

import anthropic

VICTORIA_MCP_URL = "https://api.versionseven.ai/mcp"
READ_TOOLS = (
    "campaigns_list",
    "campaign_analysis",
    "campaign_queue",
    "campaign_step_funnel",
    "campaign_sender_breakdown",
    "sender_accounts",
    "deals_lookup",
)
SYSTEM = (
    "You answer questions about the user's outbound campaigns in Victoria AI, using the tools. "
    "Quote the numbers you read and name the campaign they belong to. Everything is per lead unless a metric says messages. "
    "If a tool answers with an error, say what it was. You cannot change anything; say so if asked to."
)

client = anthropic.Anthropic()


def ask(question: str) -> tuple[str, list[str]]:
    """The answer, and one line per tool call it rested on."""
    response = client.beta.messages.create(
        model="claude-opus-5-5",
        max_tokens=4000,
        betas=["mcp-client-2025-11-20"],
        system=SYSTEM,
        messages=[{"role": "user", "content": question}],
        mcp_servers=[
            {
                "type": "url",
                "url": VICTORIA_MCP_URL,
                "name": "victoria",
                "authorization_token": os.environ["VICTORIA_API_KEY"],
            }
        ],
        tools=[
            {
                "type": "mcp_toolset",
                "mcp_server_name": "victoria",
                "default_config": {"enabled": False},
                "configs": {name: {"enabled": True} for name in READ_TOOLS},
            }
        ],
    )
    calls = []
    answer = []
    for block in response.content:
        if block.type == "mcp_tool_use":
            calls.append(f"{block.name}({', '.join(f'{k}={v!r}' for k, v in block.input.items())})")
        elif block.type == "mcp_tool_result" and block.is_error:
            calls.append(f"  ↳ error: {block.content[0].text if block.content else 'unknown'}")
        elif block.type == "text":
            answer.append(block.text)
    if response.stop_reason == "refusal":
        return "The model declined to answer this question.", calls
    return "\n".join(answer).strip(), calls


if __name__ == "__main__":
    question = " ".join(sys.argv[1:]) or "Which campaigns got positive replies in the last 7 days, and how many each?"
    answer, calls = ask(question)
    print(answer)
    if calls:
        print("\nBased on:\n  " + "\n  ".join(calls))
```

**Node.js**

```javascript
// ask-victoria.mjs
import Anthropic from "@anthropic-ai/sdk";

const VICTORIA_MCP_URL = "https://api.versionseven.ai/mcp";
const READ_TOOLS = [
  "campaigns_list",
  "campaign_analysis",
  "campaign_queue",
  "campaign_step_funnel",
  "campaign_sender_breakdown",
  "sender_accounts",
  "deals_lookup",
];
const SYSTEM =
  "You answer questions about the user's outbound campaigns in Victoria AI, using the tools. " +
  "Quote the numbers you read and name the campaign they belong to. Everything is per lead unless a metric says messages. " +
  "If a tool answers with an error, say what it was. You cannot change anything; say so if asked to.";

const client = new Anthropic();

// The answer, and one line per tool call it rested on.
export async function ask(question) {
  const response = await client.beta.messages.create({
    model: "claude-opus-5-5",
    max_tokens: 4000,
    betas: ["mcp-client-2025-11-20"],
    system: SYSTEM,
    messages: [{ role: "user", content: question }],
    mcp_servers: [
      { type: "url", url: VICTORIA_MCP_URL, name: "victoria", authorization_token: process.env.VICTORIA_API_KEY },
    ],
    tools: [
      {
        type: "mcp_toolset",
        mcp_server_name: "victoria",
        default_config: { enabled: false },
        configs: Object.fromEntries(READ_TOOLS.map((name) => [name, { enabled: true }])),
      },
    ],
  });
  const calls = [];
  const answer = [];
  for (const block of response.content) {
    if (block.type === "mcp_tool_use") {
      const args = Object.entries(block.input).map(([key, value]) => `${key}=${JSON.stringify(value)}`);
      calls.push(`${block.name}(${args.join(", ")})`);
    } else if (block.type === "mcp_tool_result" && block.is_error) {
      calls.push(`  ↳ error: ${block.content?.[0]?.text ?? "unknown"}`);
    } else if (block.type === "text") {
      answer.push(block.text);
    }
  }
  if (response.stop_reason === "refusal") return { answer: "The model declined to answer this question.", calls };
  return { answer: answer.join("\n").trim(), calls };
}

if (process.argv[1]?.endsWith("ask-victoria.mjs")) {
  const question = process.argv.slice(2).join(" ") || "Which campaigns got positive replies in the last 7 days, and how many each?";
  const { answer, calls } = await ask(question);
  console.log(answer);
  if (calls.length) console.log(`\nBased on:\n  ${calls.join("\n  ")}`);
}
```

```bash
pip install anthropic && python ask_victoria.py "Which campaign is closest to running out of leads?"
npm install @anthropic-ai/sdk && node ask-victoria.mjs "How did Q3 outbound do this week?"
```

The output is the answer, then the calls it rested on:

```text
Q3 outbound got 3 positive replies from 25 leads first contacted in the last 7 days (12%), …

Based on:
  campaigns_list()
  campaign_analysis(campaign_id="550e8400-…", date_filter="7d")
```

## Putting it in Slack

A Slack slash command is the natural home: a user types `/victoria how did Q3 do this week?`, Slack posts the text to your endpoint, your endpoint calls `ask()` and replies. Slack wants an acknowledgement within 3 seconds and the agent takes longer, so answer `200` at once and post the result to the `response_url` Slack included. Verify Slack's request signature (its signing secret, over the timestamp and body) before trusting the text, the same discipline as the [webhook receiver](https://docs.versionseven.ai/cookbook/webhook-receiver). Slack's own docs cover the slash-command handshake; the agent above is the only Victoria-specific part.

## Why read-only, twice

The connector runs the tool calls on Anthropic's side, inside the one API request. Nothing stops between the model deciding to call a tool and the call happening, which is fine for reads and not fine for a write: there is no moment for a person to confirm, which is what Claude and ChatGPT provide for the `create_campaign`, `update_sequence` or `activate_campaign` tools. So this agent is kept to reads in two places that fail independently: the Victoria key's scopes (the server won't list or run a write for a read key) and the toolset allow-list (the model never sees anything but the seven). An agent that should write needs its own confirmation step, in your code, before each write; the simplest form is to run reads through this connector and route any requested write to a person, or to a connected assistant where the host asks.

## What to expect

- **Credits**: none of the seven tools spends Victoria AI credits; the Anthropic side bills your API usage. The two lead-database tools are not in the allow-list and the key doesn't permit them.
- **Limits**: each API key gets 120 tool calls a minute and 2,000 a day on the Victoria side. A question uses two to five.
- **Errors come back as data**: a refused tool (over the limit, or outside the key's scopes) is an `mcp_tool_result` with `is_error: true` and the code in its text, which the agent reports rather than crashing. Victoria AI being unreachable surfaces the same way.
- **Transport**: the connector needs a public HTTP MCP server; Victoria AI's is Streamable HTTP at `https://api.versionseven.ai/mcp`, which is what `mcp_servers` takes. A `401` from the server means the key is wrong or revoked.
- **Model**: the examples name `claude-opus-5-5`. Any current model with the connector works; the call shape is the same.

## Next steps

- [Run a weekly campaign review from Claude or ChatGPT](https://docs.versionseven.ai/cookbook/weekly-review-with-claude), the same questions through a connector instead of code.
- [Check senders and runway on a schedule with Claude Code](https://docs.versionseven.ai/cookbook/scheduled-claude-code-check), an unattended agent without writing one.
- [Connect your AI](https://docs.versionseven.ai/guides/connect-your-ai) for the server's tools, scopes and limits.
