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.
Last updated
- Claude API
- Slack
MCP tools used
campaigns_listread, campaigns:readList the organization's campaigns.campaign_analysisread, campaigns:readFunnel, replies, channels and the A/B verdict.campaign_queueread, campaigns:readBacklog, daily capacity, runway and warnings.campaign_step_funnelread, campaigns:readHow leads move through each step.campaign_sender_breakdownread, campaigns:readOutbound and replies per sender.sender_accountsread, accounts:readThe connected LinkedIn and email accounts.deals_lookupread, crm:readFind 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:readandcrm:readinVICTORIA_API_KEY, and nothing else. See Create an API key. 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
- The request names the Victoria AI MCP server in
mcp_servers, with the API key as itsauthorization_token, and adds anmcp_toolsetfor it totools. Both are required together. - The toolset's
default_configdisables every tool, andconfigsenables the seven this agent may use. That's the second guard: even with a wider key, the model sees only these. - 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_useandmcp_tool_resultblocks alongside the text, so the code can show which calls the answer rests on.
The agent
# 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))// 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 ")}`);
}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:
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. 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_resultwithis_error: trueand 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 whatmcp_serverstakes. A401from 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, the same questions through a connector instead of code.
- Check senders and runway on a schedule with Claude Code, an unattended agent without writing one.
- Connect your AI for the server's tools, scopes and limits.