Quick Start

VITNA produces Ed25519-signed evidence records that anyone can verify offline with a published public key and an open-source verifier, with no need to trust VITNA's servers. Its detection is heuristic, and those limits are documented publicly. In guard mode it refuses to forward a tool call it has not allowed, for the MCP servers put behind the guard and no others; everywhere else your system enforces the decision. Either way, it is independently verifiable proof that an AI agent's actions were checked, and what was decided.

In category terms: VITNA issues verifiable compliance receipts for agent actions. Each checked action produces a decision record, and those records are packaged into an Ed25519-signed agent action receipt that a third party can verify offline. See Verify Evidence for the two verification tiers, and What VITNA Sends for exactly what leaves your machine.

There are two ways in, and they suit different jobs. Pick one.

Start here for most people
Pre-action checks over MCP
Your agent asks VITNA before it does something dangerous and gets allowed, blocked or flagged back (flagged means hold it for a person). No credentials to start — the first check creates a trial account by itself.
MCP Quick Start →
If you are logging your own events
REST event logging
You POST events to the API from your own code. Needs an account and an API key first. Use this when you are instrumenting something that is not an MCP agent.
REST Quick Start →

MCP Compliance Quick Start

Six steps, about five minutes, and you do not need an account or an API key to begin. You end up looking at a real decision your own agent made, on your own dashboard.

Package: @costrinity/vitna-compliance-mcp. Details: the package.

Steps 1–3 — add the server to your client

The config is the same everywhere. Only where you put it and how you reload differ:

json
{
  "mcpServers": {
    "vitna": {
      "command": "npx",
      "args": ["-y", "@costrinity/vitna-compliance-mcp"]
    }
  }
}

No env block, no API key, no account. That is the point.

Claude DesktopSTEPS NOT RUN END-TO-END
1. Config file. It does not exist on a fresh install. Easiest route: open Settings from the menu bar (not the in-window account settings) → Developer → Edit Config, which creates it for you. Or by hand:
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
2. Paste the block above. If the file already has content, merge themcpServers key in rather than replacing the file.
3. Fully quit and reopen. Closing the window is not enough. On Windows, right-click the Claude icon in the system tray (it may be under “hidden icons”) and choose Quit. On macOS, Cmd+Q.
Confirm it loaded: click the + icon in the chat input, hover Connectors, and look for vitna.
Claude CodeSTEPS NOT RUN END-TO-END
1. One command, no file editing needed:
bash
claude mcp add vitna -- npx -y @costrinity/vitna-compliance-mcp
2. Or by file, if you would rather commit it for the whole team: put the JSON block above in .mcp.json at your repository root (project scope). Personal servers live in ~/.claude.json, which on Windows is %USERPROFILE%\.claude.json.
3. Restart the session if you edited a file by hand — both files are read at session start. The claude mcp add route does not need it.
Confirm it loaded: run claude mcp list and look for vitna with a Connected status.
CursorUNTESTED — NOT INSTALLED HERE
1. Config file. Per project: .cursor/mcp.json in the repo (commit it and your team gets the same tools). For every project: ~/.cursor/mcp.json in your home directory.
2. Paste the block above.
3. Restart Cursor. There is no documented reload button for MCP config.
Confirm it loaded: open Customize in the sidebar, then MCPs, and check that vitna is listed.
About those labels. The paths, restart rules and verification steps above are taken from each vendor’s current official documentation. What we have not done is sit at a fresh install of each client and walk all six steps ourselves, so we are not going to claim we did. If a step is wrong for your version, tell us and we will fix it: hello@costrinity.xyz.

Step 4 — make your first real check

Type this to your agent, exactly as written:

text
Use the vitna_preflight tool to check this action before running it: rm -rf /

The agent calls vitna_preflight with:

json
{ "action": "rm -rf /", "action_type": "shell" }

and VITNA returns this — the same answer every time, which is why we can print it here:

json
{
  "check": "action_preflight",
  "decision": "blocked",
  "effect": "block",
  "flagged": true,
  "trial": true
}

The full response also carries limits and disclaimer strings, both stating plainly that this is a heuristic pattern match and not a sandbox. trial: true means the account is not claimed yet, which is the next step.

An unclaimed trial key gets the decision label only. Once you claim (step 5) the same call also returns threat_category, severity and a reason naming the rules that matched — for this example, shell_dangerous at severity critical. The detail is withheld from trial keys because the pattern set is the part worth stealing.

What just happened. That call self-provisioned a restricted trial account for you. No signup, no key. It is capped and short-lived and it does not keep signed evidence — but the decision itself is already on a timeline waiting for you.

Step 5 — claim the trial

The claim link is in the MCP server log, printed when the trial started: the server's stderr, the channel an MCP server logs to. Where your MCP client shows it depends on the client. Your agent is never sent the link, so there is no point asking it: only a person may claim the account that oversees the agent. Its API key cannot claim it either: every claim route refuses a request that carries one. The line looks like this:

text
[vitna-compliance-mcp]   https://vitna.costrinity.xyz/claim?owner=<owner id>&t=<token>

Open it and choose Continue with GitHub or Create a passkey. It works once, for 24 hours. If it has expired, open it anyway: within 72 hours of the trial starting, the page offers a fresh link, once, kept in your browser and never sent to your agent. After that, the way on is a new trial. Set VITNA_OPEN_CLAIM=1 in the server's MCP config to have it opened in your browser as well. Claiming lifts the trial caps, stops the key expiring, and starts keeping the Ed25519-signed evidence records that make a decision provable to someone else later.

A passkey must verify you, with your fingerprint, your face or a PIN, when you create it and each time you sign in with it. VITNA refuses a passkey response that does not say this check was done. The device reports the check itself, and a software authenticator can report it with nobody there, so it does not prove a person was present.

Step 6 — see the decision you just made

Claiming drops you straight onto your dashboard. The rm -rf / check from step 4 is already on the timeline, reading:

text
Pre-flight check (blocked): shell — shell_dangerous: rm -rf /
✓ That is the whole loop. Your agent asked before acting, VITNA answered, and the answer is now a record you can point at.

Guard mode: VITNA in the execution path

Everything above is cooperative: your agent asks, VITNA answers, and your agent decides whether to listen. Guard mode is not. Put it in front of a stdio MCP server and every tool call is checked by VITNA before it reaches that server. Only an allowed call is forwarded. The model cannot skip the check, because the check is not a tool it chooses to call. Requires @costrinity/vitna-compliance-mcp 0.5.0 or later.

json
{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@costrinity/vitna-compliance-mcp", "guard", "--",
               "npx", "-y", "@modelcontextprotocol/server-filesystem", "/data"],
      "env": {
        "VITNA_GUARD_POLICY": "/path/to/policy.json",
        "VITNA_GUARD_UNWRAPPED": "github",
        "VITNA_GUARD_BUILTIN_TOOLS": "yes"
      }
    }
  }
}

Without VITNA_GUARD_POLICY, each call goes to the preflight check. With a policy file, the guard opens an engagement session and each call is judged against it:

json
{
  "allowed_actions": ["*"],
  "allowed_domains": ["example.com"],
  "hold_actions": ["delete_records", "export_users"],
  "hold_window_seconds": 120,
  "canary_interval_minutes": 1440,
  "honeytools": false,
  "honeytokens": false
}
  • Fails closed. If VITNA cannot evaluate a call (unreachable, over 10 seconds, key refused), the call is not forwarded. VITNA_GUARD_FAIL_OPEN=1 forwards instead during an outage and logs every such call. It never forwards a call VITNA blocked or held. From 0.5.1 it also never forwards a call made after its session was closed: it refuses the call and says why. 0.5.0 treats that answer as an error, so with VITNA_GUARD_FAIL_OPEN=1 it forwards such a call; upgrade, or do not use fail-open.
  • Trial limits. When a free trial key reaches a limit (checks per day, lifetime checks, the end of its trial period, checks at the same time, the shared daily trial capacity, or a pause), VITNA refuses the check and the call is not forwarded, with or without VITNA_GUARD_FAIL_OPEN. From 0.5.1 the agent is told which limit was reached, that the call was not sent, and the link a person uses to claim the account and lift the limits.
  • Hold. A call whose tool is in hold_actions waits for a person. It appears under Pending holds on the owner's dashboard, with Approve and Deny, and the alert webhook, where configured, carries the same details but no link that can approve or deny it. Hold email is off until VITNA's product-mail domain is live, and then goes only to an owner address that was verified. The agent is told, in plain words, that the call is held and until when. If nobody decides before hold_window_seconds, the call is blocked. A hold can never be decided with an API key, including the agent's own.
  • Canary holds. canary_interval_minutes inserts a drill hold that looks like a real one until someone decides it, to measure whether holds are being read.
  • Bait, off by default. honeytools adds tools no legitimate task would call (for example exfiltrate_data_to_attacker_server) to the list the agent sees; a call to one is blocked, recorded and sent to the alert webhook, where configured. honeytokens plants fake credentials in tool output that already looks like configuration; one coming back in a later call is blocked, recorded and sent to the alert webhook. Alert email waits for VITNA's product-mail domain. The agent gets the same plain refusal as any other block. The design follows the AgentShield paper (Rassul and Rashid, 2026). In our own test run, five benign calls across every tool of a test server produced no bait fires; that is a small sample, not a measured rate. Bait does not catch everything: an agent that stays inside the policy with plausible arguments is not caught by it.
  • Coverage. Declare the surfaces the guard does not see with VITNA_GUARD_UNWRAPPED and VITNA_GUARD_BUILTIN_TOOLS. Every session record and bundle states how many declared surfaces were wrapped and names the rest. That statement is your declaration; VITNA cannot see an unwrapped server and does not verify it. The wrapped server is named by VITNA_GUARD_NAME if you set it, otherwise by the file name of its command, such as npx. From 0.5.1 that name is never a folder path or one of the server's arguments.
  • Activation note. From 0.5.1, when guard mode first contacts VITNA it says that guard mode is in use, and from October 19, 2026 VITNA notes on your account, once, that it is in use, which mode, and the client version. The note has no tool name, arguments, IP address or hostname. Set VITNA_GUARD_NO_ACTIVATION=1 to stop this; checks still work the same.

What guard mode cannot do: it contains nothing outside the servers you wrap, and a blocked or held agent may try something else, through another tool or another route. When the guard closes a session, it saves the signed bundle to ~/.vitna/bundles/, verifiable offline like any other. Some MCP clients stop the guard as they exit, before it can (seen with Claude Code 2.1.299 on Windows). VITNA's decisions on each call stay in your account, and GET /api/evidence/export returns them in a signed package, without what happened to each hold: signed in, by date; with the agent's key, one session at a time, by its session_id (GET /api/engagement/list with the key lists them, and the guard logs each id when it opens a session). A session closes once: after that it takes no new actions or hold drills, and a second close issues no new bundle; it answers with the one the first close issued, unchanged, so a client that lost that answer can ask again (twice a minute per session and ten per caller; a session closed before October 30, 2026, or one whose records or keys changed since, answers 409 with no bundle). A hold still waiting when its session closes ends with it, recorded as expired: the call is blocked, and nobody can approve it afterwards. A session that is never closed (a guard stopped before it could close, or a trial key, which cannot close) leaves its holds to end when next read after their deadline, or at the latest by VITNA's daily sweep.

Shadow mode: see what your policies would stop before they stop it

Shadow mode lets a team watch its policies against real traffic before they bite. Switch it on in the dashboard under Enforcement: only you can, signed in, and switching it on needs a sign-in within the last 10 minutes. An agent's key cannot switch it or ask which mode is on, though VITNA's answer to a call it would have stopped says so, as do the records a key can read once a call has been shadowed. One switch covers every agent and session on the account. In shadow mode VITNA checks every call as when enforcing and records each decision, but answers allow to the calls it would block or hold, so the guard and the SDKs run them. Such a call is answered allow with shadow.would_have set to what enforcing would have answered: deny or hold in a policy session, blocked or flagged from the preflight check. Its record keeps the real verdict, with a reason that begins shadow, not enforced: (a preflight check is recorded unless your detection settings turn off recording of flagged checks). The dashboard timeline shows it as "shadow, would have blocked", "shadow, would have held" or "shadow, would have flagged", and the aquarium as a call VITNA let through. No hold is opened for it and no hold or block alert is sent; a burst of them in a policy session still raises the rate alert, posted to your alert webhook as a shadow_rate_alert event where you set one, and, once VITNA's product-mail domain is live, emailed to your account's address if you verified it and email notices are on. A policy session's bundle and the evidence export count these decisions under summary.shadow.

Bait is the exception: a call to a decoy tool, or a planted value coming back, is still blocked and alerted, as the clearest sign of compromise. With honeytokens on, an agent that writes back a config file it read carries the planted values and is blocked too. Every policy rule is shadowed, a session past its expires_at or over its max_writes included. A verdict in shadow mode leaves out the writes VITNA would have blocked or held but let through in shadow mode, as enforcing would have blocked or held them (a held write a person approves is not counted either); once you are enforcing again, they count toward max_writes. Canary drill holds, if the policy sets them, still run, and a hold already waiting when you switch still waits for a person. Shadow mode covers the checks the guard and the SDKs act on: policy sessions and the preflight check. The compliance checks (consent, breach, DPIA, AI Act) answer as they always do, and VITNA Desktop decides on the machine and has no shadow mode. It changes VITNA's answers, not the guard: a call VITNA cannot check is still refused unless you set VITNA_GUARD_FAIL_OPEN=1, and a closed session still refuses calls.

Switching back to enforcing takes effect on the next call. Each switch is recorded in VITNA's append-only security log with the time, IP address and user agent (it is not listed in GET /api/audit-log), posted to your alert webhook as an enforcement_mode_changed event where you set one, and, once VITNA's product-mail domain is live, emailed to your account's address if you verified it and email notices are on. GET /api/owners/enforcement tells you, signed in, which mode is on and since when; PUT switches it.

Guard mode with VITNA Desktop

VITNA Desktop 0.7.0 and later can take the cloud's place behind the guard. VITNA Desktop is Windows only: every release is a Windows (x64) installer, and macOS is coming in Phase 2. The app serves the same engagement endpoints on http://127.0.0.1:5757, reachable from your machine only, and judges each call with the same decision rules as the cloud (a Rust port held to the TypeScript by shared test vectors). Decisions, holds and bundles are made and kept on that machine. The app's Guard mode screen shows every value below and the full JSON to copy.

json
{
  "mcpServers": {
    "filesystem-guarded": {
      "command": "npx",
      "args": ["-y", "@costrinity/vitna-compliance-mcp", "guard", "--",
               "npx", "-y", "@modelcontextprotocol/server-filesystem", "/path/to/folder"],
      "env": {
        "VITNA_BASE_URL": "http://127.0.0.1:5757",
        "VITNA_OWNER_ID": "<owner id from the Guard mode screen>",
        "VITNA_API_KEY": "<API key from the Guard mode screen>",
        "VITNA_GUARD_POLICY": "<policy file path from the Guard mode screen>",
        "VITNA_GUARD_BUILTIN_TOOLS": "yes"
      }
    }
  }
}
  • VITNA_BASE_URL is the app's local server. It listens on the loopback address only.
  • VITNA_OWNER_ID and VITNA_API_KEY come from the Guard mode screen. They let the guard ask this install for decisions and work against no other server; they are not your cloud account key. The config file holds the key, so keep it out of version control.
  • VITNA_GUARD_POLICY is the policy file the app creates on first run: every tool allowed, no holds, bait off. Edit it in any editor, then restart your MCP client.
  • Where the JSON goes. Claude Desktop: claude_desktop_config.json. Cursor: ~/.cursor/mcp.json. Claude Code: .mcp.json in the project, or claude mcp add-json filesystem-guarded '<json>'.
  • Holds are decided in the app. A call whose tool is in hold_actions waits for a person in the app's Holds screen. On Windows the hold notification also has Approve and Deny buttons, which decide the hold from the notification; clicking the notification itself opens the Holds screen. If the hold already expired or was decided, a button changes nothing and the Holds screen opens. Both are on the operator's own machine. The key cannot decide a hold under any configuration, and no HTTP route decides one: the local server answers every attempt with 403.
  • Expiry blocks. If nobody decides before hold_window_seconds, the hold expires and the call is blocked.
  • When the app is not running. When VITNA Desktop is not running and no other program is using port 5757, the guard cannot reach anything there and refuses every call. If another program is listening on port 5757, the guard talks to that program instead; VITNA Desktop warns you when it cannot open the port. Leave VITNA_GUARD_FAIL_OPEN unset: with it set, the guard would forward calls unchecked (logging each one) whenever it cannot reach 127.0.0.1:5757.
  • After a restart or update. After VITNA Desktop restarts or updates, restart your MCP client so the guard opens a new session. Until then the guard refuses every call, and the earlier session has no bundle.
  • Alerts stay on the machine. Bait and rate alerts appear in the app. Nothing is emailed and no webhook is called.

Event demo: a USB deny button

For a live demo, a cheap USB button that acts as a keyboard can deny a held call. It works on the dashboard's Pending holds list and on the Holds screen of VITNA Desktop. It only denies; it never approves anything.

  • Turn it on first. The button is off until you turn it on where you will use it: tick USB deny button in the dashboard's account panel (while a hold waits, the same switch is in the Pending holds header), or on the Holds screen of VITNA Desktop. The setting is kept in that browser or app only, not in your account, so turn it on again in another browser, or after clearing the site's data. While it is off, the page does not listen for the key and shows no button status.
  • Make it send F13. Most programmable USB buttons and macro pads come with a tool that sets the key they send. Choose F13: it types nothing and Windows does not reserve it, so it works even while you are typing in a field. Do not choose Enter or Space: they press whichever button on the page has focus, and that can be Approve.
  • Two presses. The first press arms the first hold in the list that can still be denied and says which call it will deny, with a 5 second countdown. A second press within that time denies that hold and no other: a hold that arrives in between does not change it. If the hold is decided or expires first, the countdown runs out, or the window loses focus, nothing is denied and the screen says so. Holding the button down counts as one press, and a second press within 300 ms of the first is ignored, so a switch that bounces cannot deny with one press.
  • The window needs keyboard focus. Keys go to the window in front. Above the list, the screen shows "Button ready (F13)" when it will hear the button and "Click this window so the button works" when it will not. If another program, such as recording software, uses F13 as a hotkey, remove it there. In a background tab or a minimized window, the button does nothing.
  • It is the operator's own Deny. The button denies through the operator's own screen: the signed-in dashboard session in the browser, or the VITNA Desktop window. It is recorded the same way as clicking Deny there. No API key is involved, and an agent's key still cannot decide a hold.
  • Results. After a deny, the screen shows which hold was denied and what was recorded. If the deny fails, it shows the error and does not try again.

Verifying a desktop bundle

When the guard closes a session, it saves the bundle to ~/.vitna/bundles/ as usual, with the same limit: some MCP clients stop the guard as they exit, before it can. VITNA Desktop's decisions on each call stay in the app, but Desktop has no export, so there's no other signed copy. A desktop bundle is signed by an Ed25519 key generated by the app on that machine, not by VITNA. It says so inside the signed package: issuer vitna-desktop-local and the local key id, which the Guard mode screen shows as the local signing key id. Check it offline with the same verifier:

node verify-evidence.mjs bundle.json
node verify-evidence.mjs --pubkey <public key: base64 SPKI DER, or a file holding it> bundle.json

The verifier reports such a bundle as signed by a local VITNA Desktop key, not by VITNA. Without --pubkey it checks the signature against the public key the bundle carries, which shows the bundle has not changed since it was signed; compare the key id it prints with the one on the Guard mode screen. With --pubkey it checks against a key you already hold instead.

What a desktop bundle does not show: anyone with access to that machine's app data could sign a different bundle with the same key, so it records what the app on that machine decided, not that VITNA saw it. VITNA does not countersign desktop bundles in this release.

REST Quick Start

Get your agent sending events to VITNA in under 2 minutes. No SDK required, just one HTTP call. Unlike the MCP path above, this one needs an account and an API key before you start.

1
Create an account
Go to vitna.costrinity.xyz/dashboard and register with your email.
2
Create an agent
Click "New Agent", give it a name, and copy your API key.
3
Send your first event
Make a POST request from your agent:
bash
curl -X POST https://vitna.costrinity.xyz/api/ingest \
  -H "Authorization: Bearer vitna_your_key" \
  -H "Content-Type: application/json" \
  -d '{"action": "hello-world", "data": {"message": "VITNA connected!"}}'
✓ That's it. Open your dashboard and you'll see the event appear in real-time.

The package

One package, and every step above uses it. Your agent asks it before acting and it records the answer.

PackageWhat it doesWhen you want itInstall
@costrinity/vitna-compliance-mcpYour agent asks before it acts. Returns allowed, blocked or flagged (hold it for a person) on a proposed action and records a signed evidence record of the decision. In guard mode it also refuses to forward a call it has not allowed, for the servers it wraps.You want a guardrail your agent calls, and provable receipts that it did.npx @costrinity/vitna-compliance-mcp
no credentials to start
A passive observer that records existing MCP traffic without deciding anything is built in this repo but is not published, so it is not documented here yet. Nothing on this page depends on it.

REST API

The VITNA API is a simple REST API. Every endpoint accepts JSON and returns JSON.

POST /api/ingest

Log a single event from your agent. Core endpoint, call this every time your agent does something.

Headers

HeaderValue
AuthorizationBearer vitna_your_key
Content-Typeapplication/json
javascript
await fetch('https://vitna.costrinity.xyz/api/ingest', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer vitna_your_key',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    action: 'db.query',
    data: { statement: 'SELECT count(*) FROM orders', rows: 12 },
    status: 'success'
  })
})

The payload (data or payload) is usually an object. If you send text, a number, true or false, or a list instead, it is stored as {"value": <what you sent>} and checked for secrets and threats like any other field. Text, lists and objects in the payload that lie more than 32 levels deep, after the first 1,000 items of a list, or after the first 10,000 pieces of text in the payload are not read by our detectors, so each is replaced as a whole with [redacted: beyond scan limit]. At most 1,000 such parts in one request, across all its payloads, are replaced where they are; a payload whose parts would go past that is replaced whole instead, with {"value": "[redacted: beyond scan limit]"}. The keys of the objects in the payload are read too, each as a piece of text before its value, and count toward those 10,000. Where a key holds a leaked secret or a regulated identifier, the object it is in is replaced as a whole with [redacted: <label>], or, for a key at the top level of the payload, the whole payload with {"value": "[redacted: <label>]"}. A key after the first 10,000 pieces of text is not read, so it and the keys after it in its object, with their values, are replaced together by one entry, "[redacted: beyond scan limit]": <how many entries it replaces>; if the object already has a key of that name before them, that key's value is replaced by the count.

Response

json
{
  "success": true,
  "storage_mode": "cloud",
  "event_id": "550e8400-e29b-41d4-a716-446655440000",
  "timestamp": "2026-10-04T12:00:00.000Z"
}

🔒 Storage Mode

Every event request accepts a storage_mode field. When set to "local", the event is still sent to VITNA, which validates your API key, enforces rate limits, checks the event and returns it to you, but does not store the event. It does update the agent's last-seen time, and an event that names a data principal still creates that principal's record, which holds a hash of the reference. Keeping the returned event is up to your client.

☁️ Cloud (default)
Events stored in your isolated VITNA account. Accessible from any device. 7–90 day history depending on plan.
💾 Local
Events still travel to our API to be checked, and come back to you unstored. Your client keeps them.
javascript
// Cloud mode, stored in your VITNA account
body: JSON.stringify({ action: 'buy', storage_mode: 'cloud' })

// Local mode, the event is checked and returned, not stored
body: JSON.stringify({ action: 'buy', storage_mode: 'local' })
// → Server returns the enriched event with an ID + compliance tags
// → Keeping it is up to your client

POST /api/ingest (batch)

Log multiple events in a single call. Up to 500 events per request.

javascript
await fetch('https://vitna.costrinity.xyz/api/ingest', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer vitna_your_key',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    events: [
      { action: 'search_orders', data: { matched: 847 } },
      { action: 'policy_check', data: { passed: 12 } },
      { action: 'email.send', data: { template: 'refund_confirmation' } }
    ]
  })
})

GET /api/graph

Returns the activity graph for an agent, nodes are actions (or individual events), edges are causal links inferred from parent_id / trace_id / run_id / temporal proximity. Used by the dashboard’s Activity Graph. With an agent's API key you get that agent's own graph only; another agent's needs you, signed in.

agent_id*UUIDRequired.
owner_id*UUIDRequired. Must own the agent.
group"action"|"event"Default 'action'. 'event' returns one node per event (raw timeline).
limitint 1–5000Default 2000. Caps the number of events scanned.
include_noise0|1Default 0. When 0, baseline events (heartbeat, ping, health-check, *-heartbeat, idle_*) are filtered server-side BEFORE the temporal-proximity edge calculation. The dashboard passes 1 to keep the count badge live; CLI consumers usually want the default.
include_system0|1Default 0. When 1, VITNA-internal cron events (heartbeat_missed, budget_warning, key_rotated, …) are included.
bash
# Default, actions only, clean edges
curl "https://vitna.costrinity.xyz/api/graph?agent_id=...&owner_id=..."

# Include baseline noise (what the dashboard does)
curl "https://vitna.costrinity.xyz/api/graph?agent_id=...&owner_id=...&include_noise=1"
json
{
  "nodes": [
    { "id": "buy", "label": "buy", "count": 47, "color": "#4ade80", "size": 7, ... }
  ],
  "edges": [
    { "from": "scan", "to": "buy", "weight": 12, "kind": "trace" }
  ],
  "stats": {
    "event_count": 1284,
    "action_count": 6,
    "edge_count": 14,
    "window_hours": 24.0,
    "noise_event_count": 847,    // events filtered out as baseline noise
    "noise_action_count": 1      // distinct noise action types (e.g. "griid-heartbeat")
  },
  "agent": { "id": "...", "name": "deploy-agent" }
}

SDKs for agent code

For an agent you build yourself, the SDKs check every tool call with VITNA before it runs. Allowed, the tool runs. Blocked, it does not run, and the agent gets VITNA's reason as the tool's result. Held, it waits for a person to approve or deny it in VITNA; if nobody answers in time, it is blocked. With no key, VITNA unreachable or no decision, the call is not run. On a claimed account, VITNA signs a record of each decision.

  • JavaScript: @costrinity/vitna on npm, with adapters for LangChain and LangGraph, the Vercel AI SDK 7, the OpenAI Agents SDK and Mastra.
  • Python: vitna on PyPI, with adapters for LangChain and LangGraph, the OpenAI Agents SDK, Pydantic AI, Microsoft Agent Framework, Google ADK and CrewAI.
bash
npm install @costrinity/vitna
pip install vitna

Set VITNA_API_KEY (an agent's key, from your dashboard) and VITNA_OWNER_ID. With LangChain in JavaScript, for example:

typescript
import { createAgent } from 'langchain'
import { vitnaMiddleware } from '@costrinity/vitna/langchain'

const agent = createAgent({ model, tools, middleware: [...yourMiddleware, vitnaMiddleware()] })

Put vitnaMiddleware() last, so no other middleware can change a call's arguments after VITNA checked them. Each package's README on npm and PyPI shows every adapter.

ElizaOS Plugin

Zero-config plugin for ElizaOS agents. Automatically logs every action your agent takes.

Not yet published. The ElizaOS plugin is not on npm yet, so the install below will not resolve today. It is shown as a design preview. Use the REST API or @costrinity/vitna-compliance-mcp in the meantime.
bash
# PLANNED, not yet published on npm npm install @costrinity/vitna-eliza-plugin
typescript
import vitnaPlugin from '@costrinity/vitna-eliza-plugin';

export const character = {
  name: 'SupportAgent',
  plugins: [
    vitnaPlugin({
      apiKey: process.env.VITNA_API_KEY,
      // Optional: custom agent name (defaults to character.name)
      agentName: 'my-elizaos-bot',
    }),
  ],
  // All agent actions are automatically captured:
  // - Tool calls and responses
  // - Decision reasoning
  // - Memory reads/writes
  // - Errors and retries
};
💡 The ElizaOS plugin hooks into the agent runtime automatically. You don't need to add any vitna.log() calls manually.

Event Schema

Full schema of all fields accepted by the ingest endpoint.

FieldTypeDescription
action*stringWhat the agent did. Free-form string: 'buy', 'error', 'decision', 'transfer', etc. Not 'command', which only the commands you send from the dashboard carry: an event of type command, or one carrying issued_by 'dashboard', is refused with 400 and nothing in that request is stored.
dataobjectAny additional context. JSON object, up to 64KB. Fully searchable in dashboard.
cost_solnumberOptional numeric cost recorded for this action, SOL-denominated for agents that track spend. Supports up to 9 decimal places.
statusstring'success' | 'error' | 'info' | 'warning'. Defaults to 'info'.
iduuidAuto-generated UUID for the event. Returned in response.
agent_iduuidResolved from your API key. You don't need to pass this.
timestamptimestamptzAuto-set to current UTC time. Cannot be overridden.

Alerts

Smart alerts watch your agents for you. Configure them from the dashboard, no code needed.

Silence DetectionMost Popular

Trigger when an agent hasn't sent an event in N minutes. Catches stuck, crashed, or looping agents.

Error Rate

Alert when error events exceed X% of total events in a rolling window.

SOL Spend ThresholdCritical

Alert when cost_sol total exceeds your limit in 1 hour. Protects against runaway spending.

Anomaly Risk

Alert when the behavioural risk score for an agent crosses your threshold.

Delivery channels

  • ✓ Email (all plans), once VITNA's product-mail domain is live
  • ✓ Webhook POST (Pro+): a JSON body, which a Slack incoming webhook refuses (it needs a text field)

Only you, signed in, set the email address or webhook a rule sends to. An agent's API key can create a rule without them, and such a rule records when it fires but sends nothing. A key sees whether a rule has an email address or a webhook, never the address itself, and it cannot delete any rule. A webhook must be a public https address.

Webhooks

VITNA can POST to your endpoint when an alert fires. Available on Pro and above.

json
// Example alert rule webhook payload
{
  "event": "alert_fired",
  "alert_type": "no_activity",
  "agent_id": "3f1c2a9e-0b7d-4c55-9a3e-6d2f8e1b4c70",
  "agent_name": "support-bot-prod",
  "message": "No activity for 10min",
  "severity": "warning",
  "timestamp": "2026-03-24T18:00:00Z",
  "vigil_url": "https://vitna.costrinity.xyz/dashboard/3f1c2a9e-0b7d-4c55-9a3e-6d2f8e1b4c70"
}

An alert rule's webhook is set signed in, with notify_webhook on POST /api/alerts, and must be a public https address. Its requests are not signed: they carry X-Vitna-Event and X-Vitna-Agent headers but no signature. The signed webhooks are the ones below: hold and block alerts, the breach and grievance channels and compliance endpoints carry an X-Vitna-Signature header (HMAC-SHA256) when VITNA's webhook signing key is set. Until 29 October 2026 this page said every webhook was signed.

Hold and block alerts

When a call in an engagement session is held or blocked, VITNA posts a signed engagement_alert to your account's alert webhook. Only you set it, signed in, under Alert settings in the dashboard's account panel. An agent's API key cannot set or replace it, cannot read its address or the email addresses in your notification settings, and cannot switch off your email notices. An agent can add a copy for its own session with webhook_url on POST /api/engagement/open: alerts then go to your alert webhook and to that URL, and when both are the same URL it is posted once. The session URL never replaces yours. Set with a key, it must use https's default port, and the session posts to it in one try, at most 10 alerts a minute (30 across your key-opened sessions), counted on each of VITNA's server instances; over that only the session's copy is skipped, never your webhook's. No alert carries a link that can approve or deny a hold. In shadow mode only bait is posted this way. A switch between enforcing and shadow mode is posted to your alert webhook as an enforcement_mode_changed event, and a burst of calls VITNA would have held or blocked but answered allow in shadow mode as a shadow_rate_alert event. Neither is listed to an agent's key.

Breach and grievance webhooks

POST /api/webhooks/compliance/register subscribes an endpoint to one kind of compliance event. Breach and grievance events concern people's personal data, so only you, signed in, can register an endpoint for breach_notification or grievance_filed. An agent's API key is refused for those two kinds, sees their endpoints listed without the address, and cannot fire a test at them. A key can still register the other kinds.

You can also set one breach webhook and one grievance webhook, signed in, under Breach alerts and Grievance alerts in the dashboard's account panel. VITNA posts a signed breach_notification there when an incident is marked notified with POST /api/incidents/breach-notify, and a signed grievance_filed when someone files a grievance with POST /api/data-rights/grievance. The grievance event carries the person's contact email. Neither event has the text field a Slack incoming webhook needs, so point them at a receiver you run. An agent's API key cannot set either webhook or read its address.

VITNA does not email the people a breach affects: you notify them yourself. Recipients named to breach-notify come back as not emailed and the incident stays unmarked until you call again with audience: "regulator", which sends your own copy, to an address you have proved, and marks it notified. An agent's API key that names recipients for the people affected (audience data_principals or both) is refused. To a key, each webhook's result says only whether it was delivered and signed; to whoever files a grievance, only whether it was delivered. Until 29 October 2026 VITNA emailed any addresses named there, which made it a mail relay for anyone who could sign up.

Rate Limits

These limits apply to event ingest (POST /api/ingest), per API key. Exceeded requests return 429. They are not an account-wide API budget: other endpoints are governed by their own per-IP limits rather than by your plan.

PlanRate LimitEvents / MonthHistory
Free100 req/min~10k/month (guideline)7 days
Pro1,000 req/min~1M/month (guideline)90 days
Team2,000 req/min~1M/month (guideline)90 days
Enterprise6,000 req/minNegotiatedCustom
💡 Batch requests via the events[] array count as 1 request regardless of event count. Use batching to maximize throughput on high-frequency agents.

Verify VITNA evidence yourself

One minute, no account, no trust in VITNA's servers required. Download the open-source verifier and a real signed sample bundle, then check the signature offline with Node 18 or newer:

curl -sO https://raw.githubusercontent.com/COSTRINITY/vitna-compliance-mcp/main/verify-evidence.mjs
curl -sO https://vitna.costrinity.xyz/sample-evidence.json
node verify-evidence.mjs sample-evidence.json

How verification works, in two tiers

Tier 1, the evidence package. A detached Ed25519 signature covers the whole package. Anyone can verify it offline with our published public key, with no VITNA account and no VITNA secret. This is the tier that supports non-repudiation of issuance.

Tier 2, each individual record. Every decision record is committed by a sha256 hash over its canonical JSON, and that ordered list of hashes sits inside the Ed25519-signed package. A third party can therefore verify each record, not just the package: recompute the record's hash and confirm it matches at its position. The verifier does this automatically and reports PASS or FAIL per record.

Separately, each record is integrity protected at write time with HMAC-SHA256. That is an internal check, verifiable only by VITNA because HMAC is symmetric, so we never describe it as independently verifiable or as non-repudiation. Bundles exported before the per-record hashes shipped still verify at the package level, and the verifier says so.

A VALID result proves the package was issued by VITNA and has not been altered since export, and that every record matches its committed hash. It does not prove the actions were performed or that the records are factually true. Change any byte in any record and that record reports FAIL and the overall verdict is INVALID.

Recomputing payload_sha256 (the pfa-v2 scheme)

Each decision record carries a payload_sha256 and a canon_version of pfa-v2. Here is the exact scheme so you can recompute it yourself. It is a sha256 (hex) over twelve fields joined with the pipe character, in this order, UTF-8 encoded, with no whitespace and no trailing separator. Null or absent values become the empty string.

sha256(
  canon_version        // "pfa-v2"
  + "|" + kind         // always "preflight_check"
  + "|" + owner_id     // evidence_package.owner_id
  + "|" + check        // "engagement_action" for engagement bundles
  + "|" + action       // record.action, "" if null
  + "|" + category     // engagement: evidence_package.session_id
  + "|" + decision     // record.decision
  + "|" + flagged      // "1" if decision !== "allow", else "0"
  + "|" + reason       // record.reason, "" if null
  + "|" + principal_id // "" for engagement bundles
  + "|" + effect       // record.effect
  + "|" + signed_at    // record.signed_at
)

Worked example, taken verbatim from the published sample-evidence.json (record 0). Canonical string:

pfa-v2|preflight_check|f46ba5dc-b77b-4fe0-ae3d-55e6204e3d66|engagement_action|dns.read example.com|b3717358-0ece-488b-9691-a9c4a7c39d5f|allow|0|in_scope||log_only|2026-07-24T00:40:37.048Z

sha256 -> 2b0f0d22a1a3cb4980981a12e67fa72e778ca3b0d21d322ca90eac1f578e1b2f

That matches the payload_sha256 on record 0 of the published sample. Note the two consecutive pipes before log_only: that is the empty principal_id.

Being precise about what this does and does not give you. payload_sha256 is a digest, not a signature, so recomputing it proves the record fields are internally consistent, not that VITNA issued them. The per-record assurance that a third party can rely on is record_hashes, because those sit inside the Ed25519-signed package. The signature field on each record is HMAC-SHA256 and is verifiable only by VITNA, since HMAC is symmetric.

Key transparency

The signing key is Ed25519, key_id 01833acd46d06ab4. The public key is published in more than one place, including infrastructure we do not control, so the copies can be compared:

Because the verifier ships with the key embedded and the same key is published on GitHub and npm, a mismatch between any of those copies and our API would be publicly visible. We are stating the honest limit here too: this is multi-location publication, not a formal transparency log or a third-party notary.

What VITNA sends and stores

Connecting the MCP server sends data to VITNA's cloud. For a compliance product that has to be explicit, so here it is.

What is sent on a check

Whatever you pass to the tool. For vitna_preflight that is the proposed action text, an optional structured payload, and an optional short action-type label. The MCP package sends your API key in request headers and your owner id in the request address. For the compliance checks it is the fields that check needs, for example purpose and data categories.

What is stored

For a check, less than what is sent. A preflight check, which includes every call in default guard mode, stores the action type label, the threat category and the pattern name that matched, the verdict and reason, timestamps, and your owner id. The raw action text and raw payload of a preflight check are not stored. Data principals are referenced by UUID only.

A policy session (guard mode with a policy file, or an engagement session over the REST API) stores more. The session record keeps the policy, any coverage list, and the IP address and user agent that opened and closed it. Each call keeps its tool name and the web address it targeted, cut at 120 characters and replaced as a whole if our detectors find a secret or personal data in it. A held call also keeps up to 400 characters of its arguments, with detected secrets and personal data replaced. Detection is heuristic, so that excerpt can still contain personal data. Events you POST to the ingest API in cloud mode are stored as sent, except that each secret or personal identifier our detectors find is replaced first: in the message only the matching text, except that a private key in PEM form is replaced from its BEGIN line to its END line (or to the end of the message when there is no END line); in the payload the whole field. That is so whatever the field's key is called. A payload that is not a JSON object (text, a number, true or false, or a list) is stored whole as {"value": <your payload>} and checked the same way. Text, lists and objects in the payload that lie more than 32 levels deep, after the first 1,000 items of a list, or after the first 10,000 pieces of text in the payload are not read by our detectors, so each is replaced as a whole with [redacted: beyond scan limit]. At most 1,000 such parts in one request, across all its payloads, are replaced where they are; a payload whose parts would go past that is replaced whole instead, with {"value": "[redacted: beyond scan limit]"}. The keys of the objects in the payload are read too, each as a piece of text before its value, and count toward those 10,000: a match in a key counts like a match in a value, a key that holds a secret or a personal identifier has the object it is in replaced as a whole (the whole payload, for a key at its top level), and the keys of an object after the first 10,000 pieces of text are replaced, with their values, by one entry, "[redacted: beyond scan limit]": <how many entries it replaces>(an earlier key of that name keeps its place and takes the count). A preflight check or a policy session call with such a part is not allowed: the preflight check flags it as scan_limit, which your detection settings block or flag like any other match, and a policy session holds it for a person. The privacy policy lists every field.

What an agent's key can read back

Your own details stay with you. Where a generated document (privacy notice, DPA template, records of processing, SCC Annex II, cross-border notice, DPIA, consent receipt) would print your name and contact address, an agent's API key, a trial key included, gets placeholders such as [account contact email - visible when signed in]. A readiness report read with a key says whether a grievance officer is set but not who. A key cannot read or regenerate your VITNA Desktop licence key, cannot read or set the email address or webhook an alert rule sends to, and cannot subscribe to breach or grievance webhooks. A key can rotate its own agent's key, not another agent's, and it can never delete your account. In the incident list and the audit log a key sees that a grievance was filed, with its status and times, but not its title or description, which hold the contact details of the person who filed it. Signed in, you see and set all of them.

In the event list, the live stream with its commands, the exports, the activity graph, the health summary, the anomaly score, the statistics and the compliance report, an agent's key reads only its own agent's events. Asked for another agent's, a key gets 403 session_required and the stream does not open. A search made with a key finds only its own agent's name, events and alert rules. Signed in, you read every agent's.

Decision records: the evidence export and the preflight audit give a key the decisions of one policy session its own agent opened, named by its session_id (GET /api/engagement/list with the same key lists them). Decisions made outside a policy session, by preflight and compliance checks or by the guard without a policy, carry no agent, so a key cannot read them back. Signed in, you read every record. Until 29 October 2026 any key read every agent's decisions there.

What an agent's key cannot change

An agent that VITNA oversees cannot weaken its own oversight, or another agent's. With an agent's API key, a trial key included and even beside a signed-in session, it cannot change your detection settings (which checks are on, whether a match blocks, warns or only logs, and what is recorded), switch your account between enforcing and shadow mode, run an erasure, delete an agent or its events, its own included, or delete an alert rule. On its own agent a key can only tighten: lower a budget cap or set one, shorten the heartbeat interval or set one, stop blocking heartbeats, or lower an anomaly threshold. Raising or removing a cap, lengthening or clearing the heartbeat interval, blocking heartbeats or raising a threshold needs you, signed in. A key also cannot add an agent to your account: only you, signed in, can, with New Agent in the dashboard. Otherwise an agent with limits could create a new agent without them and carry on as it. Nor can a key send an agent a command, its own included, or record an event that passes for one of your commands: an event of type command, or one carrying issued_by dashboard, is refused with 400 and nothing in that request is stored. The commands you send signed in reach the agent on its own live stream, and at the webhook of its first active alert rule that has one.

A policy session belongs to the agent that opened it. Another agent's key cannot act in it, read it, poll its holds, start a drill in it or close it, and is answered as if the session did not exist. A session you opened signed in, or one opened before this rule took effect on October 5, 2026, is yours alone. A key can still read its settings, rotate its own key, open its own sessions, record events, create alert rules and tighten its own limits. Signed in, you can do all of this for any agent.

Retention by tier, against the six-month baseline

We cite the EU AI Act requirement to retain automatically generated logs for at least six months. Stated plainly, here is which tiers meet that on their own and which do not:

TierEvent payloadsIncident recordsMeets 6-month log baseline?
Free7 daysUntil you delete the accountNo, not for event payloads
Pro90 daysUntil you delete the accountNo, not for event payloads
Team90 daysUntil you delete the accountNo, not for event payloads
Enterprise90 days, unless the MSA sets another windowUntil you delete the accountOnly if contracted at 6 months or more

Event payloads are removed by a purge that runs once a day and deletes at most 5,000 rows per run, so a backlog past its window takes several runs to clear. Nothing purges incident records automatically: they are deleted with the account. Signed decision records, session and hold records, and the append-only security log are kept indefinitely.

No tier below Enterprise retains event payloads in VITNA for six months, so do not rely on our hosted retention alone to satisfy a six-month obligation. The way any tier satisfies it is by exporting the Ed25519-signed evidence package and keeping it yourself. Once exported it is a self-contained, independently verifiable artifact that does not depend on our retention or on our servers continuing to exist.

Trial keys, and the zero-cloud alternative

With no credentials, the first MCP tool call self-provisions a restricted trial key against our API and caches it at ~/.vitna/credentials.json. That creates an account on our side. Trial keys are capped, return label-only results, and do not persist signed evidence until the account is claimed.

storage_mode=local on the ingest API does not keep events off the network. Each event is still sent to our API, which checks it and returns it without storing it. It does update the agent's last-seen time, and an event that names a data principal still creates that principal's record, which holds a hash of the reference. Keeping the returned events is up to your client.

If you do not want events leaving your machine, use the desktop app. Its guard, set up as its Guard mode screen shows, gets decisions, holds and session bundles from the app on your machine and sends nothing to our servers. The tradeoff is honest: we cannot sign with VITNA's key what we never receive, so the app signs each session bundle with a key it generates on your machine instead.

FAQ

Is VITNA a verifiable compliance receipt system?

Yes, for the signed evidence package model. Each checked action produces a decision record, and those records are packaged into an Ed25519-signed evidence package that functions as a verifiable compliance receipt: a third party can confirm offline, with our published public key and an open-source verifier, that the receipt was issued by VITNA, has not been altered, and that each individual record matches its committed hash. The documented limits still apply. Detection is heuristic pattern matching, VITNA is not a sandbox (outside guard mode the agent calls it and your system enforces; in guard mode it enforces only for the MCP servers it wraps), and a valid receipt proves an action was checked and what the verdict was, not that the action was actually performed or that the underlying records are factually true.

Can I verify a single decision record on its own?

Yes. Every record is committed by sha256 over its canonical JSON inside the Ed25519-signed package, so you recompute that hash and confirm it appears at the right position in the signed list. The bundled verifier does this for you and prints PASS or FAIL per record.

Does VITNA block dangerous actions?

In guard mode, yes, for the MCP servers you put behind the guard: a tool call VITNA does not allow never reaches the server, a held call waits for a person and is blocked if nobody decides, and a call VITNA cannot check is refused. The exception is an account you have switched to shadow mode: there VITNA answers allow to what it would block or hold, records it, and blocks only bait. A call VITNA cannot check is still refused. Outside guard mode, no: the API and the MCP tools return allow, block or hold, and your system enforces the decision. Either way, VITNA cannot see or stop anything outside what you wrap, such as a client's built-in shell or an unwrapped server, and each session record says which surfaces were wrapped.