Surface 06 · CanaryAgent

Connect an agent

CanaryAgent adds heartbeat monitoring and skill scans to your agents. Post a heartbeat from your agent's loop, and submit each skill's code for a scan before it ships:

bash
# heartbeat: call it on a timer from the agent
curl -sS -X POST https://api.canaryvaults.com/canaryagent/heartbeat \
  -H "X-API-Key: $CV_KEY" -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "user_id": "<your-user-uuid>",
  "agent_id": "billing-agent",
  "status": "running",
  "actions_last_60s": 12,
  "memory_tokens": 48210
}
JSON

# skill scan: submit a skill before the agent loads it (export_invoices.py
# holds its code; jq turns any text into JSON)
jq -n --rawfile code export_invoices.py \
  '{user_id: "<your-user-uuid>", skill_name: "export-invoices", skill_code: $code}' |
curl -sS -X POST https://api.canaryvaults.com/canaryagent/skill-scan \
  -H "X-API-Key: $CV_KEY" -H "Content-Type: application/json" \
  --data-binary @-

Heartbeats build a timeline per agent. A scheduled check runs every two minutes and flags any agent whose last heartbeat is older than five minutes, so a stopped agent is flagged at the first check after its five quiet minutes, not when it stops. Silent-agent alerts go to every channel on the account (built-in Telegram, built-in email, and every enabled Slack, Discord and signed-webhook channel) through the same shared dispatcher CanaryShield and CanaryHoneypot use, each delivery isolated so one failing channel never blocks the rest. A skill scan that comes back HIGH or CRITICAL raises its own alert down the same path.

While alerts are paused, every channel skips, not just the two built-in ones. The pause is checked once, in the shared dispatcher, before any channel is attempted, so Slack, Discord and every signed webhook are silenced along with Telegram and email. The check lives there rather than inside each sender because the per-sender version muted Telegram and some of the email paths while every configured webhook carried on paging. Plan for it the other way round: pausing alerts pauses the webhook you were relying on as the backstop, so a pause is a decision to hear nothing from this account until it lapses, not a decision to quieten the noisy channels.

Get an API key

Both calls above authenticate with a workspace API key sent in the X-API-Key header. Mint one in the app at Settings → API keys → Generate API key, on any plan, Free included. The full key is shown once, at creation, so copy it off that screen, keep it server-side, and read it into CV_KEY with the line below, which keeps it out of your shell history. Five active keys per account, and a leaked one is revoked from the same card while it is still on screen.

bash
# A key from Settings > API keys. It is shown once, when you create it.
# Run this line on its own, in bash or zsh, then paste the key and press
# Enter: read -s keeps it off the screen and out of your shell history,
# and takes the next line it is given as the key. In CI, set CV_KEY in
# the environment instead.
printf 'CanaryVaults API key: ' && read -rs CV_KEY && export CV_KEY && echo
bash
# both calls above take it in the same header:
#   -H "X-API-Key: $CV_KEY"

You don't have to write the client yourself. The CanaryAgent console has a Download canary_middleware.py button that hands you a ready-made Python client: it posts heartbeats from a background thread via start_heartbeat() and wraps the CanaryShield and CanaryAudit calls. It reads CANARY_HEARTBEAT_URL, CANARY_SHIELD_URL, CANARY_AUDIT_URL, CANARY_USER_ID and CANARY_API_KEY from the environment, and the console prints the matching .env block next to the button. It does not scan skills. Run scans from the console or the endpoint above.

Read the timeline

GET /canaryagent/timeline/{user_id} returns the whole agent view for one workspace. It takes the same X-API-Key header (a session JWT in Authorization: Bearer works too), and the id in the path must be the caller's own user: another workspace's id returns 403.

bash
curl "https://api.canaryvaults.com/canaryagent/timeline/<your-user-uuid>?agent_id=billing-agent&limit=100" \
  -H "X-API-Key: $CV_KEY"

Every query parameter is optional. agent_id and action_type match exactly; from_date and to_date take YYYY-MM-DD and are inclusive at both ends; limit defaults to 50 and accepts 1–500. They filter entries only: agents covers every agent that has ever sent a heartbeat, and skill_scans is always the 20 most recent scans.

json
{
  "count": 1,
  "limit": 100,
  "entries": [
    {
      "id": "…",
      "agent_id": "billing-agent",
      "action_type": "invoice_export",
      "input_trigger": "cron:invoice-run",
      "trigger_preview": "cron:invoice-run",
      "action_detail": "Exported invoice batch 2026-08 to S3",
      "detail_preview": "Exported invoice batch 2026-08 to S3",
      "outcome": "success",
      "outcome_preview": "success",
      "blockchain_tx_hash": null,
      "basescan_url": null,
      "logged_at": "2026-08-17T09:14:02Z"
    }
  ],
  "agents": [
    {
      "agent_id": "billing-agent",
      "status": "online",
      "reported_status": "running",
      "last_seen": "2026-08-17T09:15:41Z",
      "actions_last_60s": 12,
      "actions_today": 41,
      "memory_tokens": 48210,
      "uptime_seconds": 9321,
      "uptime_label": "2h 35m"
    }
  ],
  "skill_scans": [
    {
      "id": "…",
      "skill_name": "export-invoices",
      "risk_level": "LOW",
      "issues": [],
      "recommendation": "Skill scan finished. Review findings before installing.",
      "safe": true,
      "scanned_at": "2026-08-17T08:02:10Z"
    }
  ]
}

count is the number of entries in this response, not the total that matched. entries are CanaryAudit records: the actions your agent logged to /canaryaudit/log. agents is derived from the latest heartbeat per agent: status reads online while a heartbeat has arrived in the last two minutes and silent after that, and actions_today counts audit records logged since midnight UTC.

The default view shows only your own agents' actions. audit_logs is a shared table, and two other surfaces seal their evidence into it under fixed agent ids of their own. Neither is an agent you deployed. The canary tripwire writes agent_id=canary-tripwire with action_type=canary.tripwire_event, and CanaryShield writes canaryshield with canaryshield.block every time you read your Shield log. A CanaryRAG leak trigger is hashed under a third id, canaryrag with canaryrag.leak_trigger, but it is not written to this table, so no row carries that id. All three are excluded from entries by default and refused as the agent_id of your own CanaryAudit records, which is why a block you can see in your Shield log does not also appear here as an agent action.

Nothing becomes unreachable. Pass the id explicitly and you get those rows and only those: ?agent_id=canaryshield returns the sealed Shield blocks and ?agent_id=canary-tripwire the threat events, while ?agent_id=canaryrag returns nothing, because no leak trigger is written here. agents is unaffected either way: it is built from heartbeats, and a sealed sentinel never sends one.

Skill scan limits

Heartbeats do not spend plan quota. Skill scans do: agent_skill_scans_per_month allows 5 a month on Free, 50 on Shield, 500 on Pro and 1,500 on Enterprise, and a scan past that answers a structured 402 naming the metric, your plan and your reset date.

A shorter per-account daily allowance sits underneath it (3 scans a day on Free, 10 on Shield, 40 on Pro and 100 on Enterprise), so a CI job that queues a month's worth of scans in one afternoon is refused long before the monthly quota runs out. The daily 402 names the same metric, with resets_at set to the next UTC day. This meter fails closed: if your usage cannot be checked the scan answers 503 with daily_quota_unavailable rather than running unchecked.

A per-minute burst limit sits above both: 3 scans a minute on Free, 10 on Shield, 30 on Pro and 60 on Enterprise. It answers 429 with retry_after_seconds and a Retry-After header rather than a 402, and the burst check runs before either meter, so a scan it turns away spends no quota. On every plan this figure is at or below the daily one (the two are equal on Free and Shield), so a CI job firing scans back to back meets the 429 first, and the daily 402 only once the minute has rolled over.

Decoy tools

A decoy tool server is an MCP server whose tools no honest task needs: service credentials, a customer export, a shell, an external share and an admin grant. You add its address to an agent beside the tools it really uses. Nothing the agent is asked to do should ever reach for them, so a call to one is worth a look. Usually it means something steered the agent: instructions hidden in a web page, a document or another tool's output, or a person driving it.

Create one in the CanaryAgent console: name the agent it goes to, pick the decoys, and keep or change the server name the agent sees (internal-tools unless you pick another). The console gives you the address and the lines to paste for Claude Code, the Claude app, Cursor, VS Code and the OpenAI Agents SDK. Any client that connects to MCP servers over HTTP works the same way.

Put the address in a file in your home folder, not in the project. A coding agent reads the files of the project it works in, and a project's .cursor/mcp.json, .vscode/mcp.json or .mcp.json is often committed with the code. The address is the server's key, and it names our API host, so an agent that reads it can tell what the server is.

In Claude Code it is one command. Claude Code keeps the server in your home folder, for that project only; add --scope user to have it in every project, and leave out --scope project, which writes it into the repository's .mcp.json.

bash
claude mcp add --transport http internal-tools https://api.canaryvaults.com/dt/<token>

In Cursor it goes in ~/.cursor/mcp.json, and in VS Code in your user mcp.json (run MCP: Open User Configuration from the Command Palette), merged into any servers already there:

json
{
  "mcpServers": {
    "internal-tools": { "url": "https://api.canaryvaults.com/dt/<token>" }
  }
}
json
{
  "servers": {
    "internal-tools": { "type": "http", "url": "https://api.canaryvaults.com/dt/<token>" }
  }
}

In the Claude app, open Customize, then Connectors, and choose + Add, then Add custom connector. On a Team or Enterprise plan an owner adds it in Organization settings, under Connectors, and the Free plan allows one custom connector. Claude calls the server from Anthropic's servers, so each call shows their address rather than yours, and those calls share the per-address limit below with everyone else's Claude connectors.

Keep the decoys named as the console names them, and do not tell the agent what they are: an agent that knows a tool is a trap will not call it. If your client asks you to approve each tool call, a request to approve one of these is the warning itself. Decline it and look at what led the agent there. The decoys earn their keep on agents that run without anyone approving each step.

The server answers like any other MCP server, for clients on the current protocol and on the earlier ones. A call gets a plausible answer that is useless outside the call: credentials that open nothing, customer rows with example.com addresses and 555 numbers, a shell job that never runs, a share link on example.com, an admin grant left waiting for an approval that never comes. Arguments a tool's schema does not allow get the error a real tool would give. Listing the tools is not a call. It only marks the server as connected, which is how the console shows that the agent picked it up.

Each call is recorded before it is answered, including the calls in a batch of requests on the earlier protocol versions that allow batches (a batch keeps its first ten calls and counts the rest): the time, the tool (or the name asked for, when it is not one of the server's tools), the arguments, the client's own name and version when it states them, the protocol version, the caller's IP address and user agent. The arguments are kept as the server read them, written out as JSON with its keys sorted and every character outside ASCII escaped: the first 4 KiB of that text, with its full length and a SHA-256 of all of it, which the console shows with the call. The record is hashed and sealed with a key our servers hold and the records never carry, and the console marks each call Sealed; Changed when the stored record no longer matches its seal or has lost it, because it was edited after it was written or the key that seals records has changed since; or Not sealed when no sealing key is set up to seal or check it with. A seal shows that a record was changed, not that one was removed.

The console shows what the caller sent as text. A character that would hide or reorder the text around it, such as a bidirectional control, a zero-width character or one of the tag characters that can spell out a whole hidden sentence, is written out as its code point, like <U+202E>, so nothing in the text can hide or reorder what is around it. A few things change as a call is read and stored. In the tool name, the client's name and version, the protocol version and the user agent, a NUL character is dropped, a lone surrogate becomes a question mark and white space at either end is trimmed. In the arguments, a key given twice keeps its last value, and a number with a fraction or an exponent becomes the nearest floating-point value: 1e2 is kept as 100.0, and 1e400, too large for one, as Infinity.

A call alerts you on every channel on the account, through the same shared dispatcher as a silent agent, at most once every ten minutes per server, and no more than three times in ten minutes for the whole account. Calls to several servers at the very same moment can each still send one. The alert names the tool, the time, the client and the caller's address, and counts the other calls to that server since its previous alert. It never carries the arguments, which are whatever the caller chose to send, or the server's address, which is its key. Every call that was kept is listed in the console, newest first, with older ones a click away.

To see where an alert for a server reaches you before anything calls it, open the server in the console and choose Send a test alert. It goes to Telegram, email and each alert channel you have set up, and says it is a test. The console tells you whether Telegram and email took it, and how many of your other channels did. It waits up to six seconds for them; one that has not answered by then is shown as not answered yet, and the test may still arrive there. Slack, Discord and webhooks get the canary.test_alert event with "test": true and no caller in it, so an integration that acts on alerts can leave it alone. Each channel is sent the test the way it is sent an alert, and the result is kept with the channel, as an alert's is. No call is recorded and no alert window is used, so a test never holds back the alert for a real call that follows it. While your alerts are paused, nothing is sent. An account can send 5 test alerts an hour, counted over the last hour; one refused before it is sent (for a server that is off, or while your alerts are paused) does not count. When many tests are being sent at the same time, a channel may not be sent yours; the console says so, and you can try again in a minute.

The address is the server's only key. Anyone who has it can make the alert fire, though it reads nothing of yours. If it leaks, turn the server off (its address then answers nothing) and create another. Over MCP an agent is given only the server name and its tools, but an agent that reads the file the address is kept in learns the address too. A file in your home folder is out of the way of an agent working in a project, and out of its repository.

Decoy tool limits

decoy_tool_servers counts servers that are turned on: 1 on Free, 5 on Shield, 25 on Pro and 100 on Enterprise. Creating one past that, or turning one back on, answers a structured 402 naming the metric. Turning a server off frees its place and keeps its calls. Calls do not spend quota.

A server keeps up to 60 calls an hour and 250 a day, and an account up to 500 calls a day and 5,000 in all. Past any of these a call is still answered and counted on its server, so the console shows that more happened, but the call itself is not kept. The 5,000 counts the calls an account holds at any one time, so it frees up as calls pass your account's data retention period, or when you delete a server and its calls. Requests that arrive at the very same moment each see the same room under a limit, so a burst can pass it, by at most ten calls for each request in the burst.

A server can be deleted once it is turned off, a day after it was made and ten minutes after its last alert. Before then the console says when. Deleting it deletes every call kept for it, frees their share of the 5,000, and cannot be undone, so export your account data first if you want a copy.

An account can create 20 servers a day. One address may call a server 120 times a minute, and all of them together 1,200 times; past that it is told to retry, and the call is not recorded. A large or batched call counts for more: one more for each 64 KiB it sends, and one more for each extra message in a batch. One address may also send no more than 8 MiB a minute to all of them together, in calls larger than 4 KiB. Smaller calls count only toward the number of calls, so a few large ones cannot turn away the small ones others send from the same address. A call may be up to 4 MiB: one that declares a larger size is refused before it is read. A call sent in chunks declares no size: it is refused once it passes 4 MiB or what its address has left, and before any of it is read, however small, once its address has less than 64 KiB of the 8 MiB left. Calls are kept for your account's data retention period, are part of the account export, and are erased with the account.

The console's calls go to GET /canaryagent/decoys/{user_id}, POST /canaryagent/decoys, PATCH /canaryagent/decoys/{user_id}/{server_id}, DELETE /canaryagent/decoys/{user_id}/{server_id}, GET /canaryagent/decoys/{user_id}/{server_id}/calls and, for a test alert, POST /canaryagent/decoys/{user_id}/{server_id}/test, which take the same X-API-Key header as the calls above. The calls come 50 at a time, newest first, with has_more set when there are older ones; ask for those with ?before=<id>, the id of the oldest call you have.