permisyn
Back to homeSettingsGet key
Developer guide

AI authorization at the model boundary

Permisyn is how your whole team and its agents share one company AI key, safely: your app keeps using its existing OpenAI-compatible client, Permisyn sits in the request path, authorizes or denies the call before upstream execution — carrying who made it and which team — and signs the resulting evidence. It is not a generic model gateway or tracing library.

AuthorizeEvery call is checked against policy before it reaches the model.
DenyStop killed, unsafe, or over-budget agents.
AccountAttach user, team, purpose, risk label, tokens, and cost.
ProveEd25519-sign every call; anyone verifies with your public key.

Quickstart

Change the model base URL, keep your upstream provider key in your runtime, send the Permisyn key as authorization context, and label traffic with agent identity, sponsor, risk, purpose, and budget.

proxy-quickstart.sh
export OPENAI_BASE_URL=https://api.permisyn.com/v1
export PERMISYN_API_KEY=psyn_live_...
export OPENAI_API_KEY=$YOUR_PROVIDER_KEY

curl https://api.permisyn.com/v1/chat/completions \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Permisyn-Key: $PERMISYN_API_KEY" \
  -H "X-Permisyn-Agent: finance-report-agent" \
  -H "X-Permisyn-User: finance-owner@yourco.com" \
  -H "X-Permisyn-Team: finance" \
  -H "X-Permisyn-Purpose: monthly close report" \
  -H "X-Permisyn-Max-Cost-USD: 0.50" \
  -H "X-Permisyn-Prompt-Mode: off" \
  -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Summarize the monthly close"}]}'

If you do not want application runtimes or deployment code to hold an OpenAI/Anthropic key, paste the provider key once in the encrypted vault. After that, calls send only psyn_live_...; Permisyn injects the upstream key server-side only after authorization passes.

secretless-provider-vault.sh
# 1) Store the upstream key once from Fleet Control or API
curl https://api.permisyn.com/api/control/provider-keys \
  -H "X-API-Key: psyn_live_..." \
  -H "Content-Type: application/json" \
  -X PUT \
  -d '{"provider":"openai","api_key":"sk-your-openai-key"}'

# 2) Existing model client sends only the Permisyn key
curl https://api.permisyn.com/v1/chat/completions \
  -H "Authorization: Bearer psyn_live_..." \
  -H "Content-Type: application/json" \
  -H "X-Permisyn-Agent: finance-report-agent" \
  -H "X-Permisyn-User: finance-owner@yourco.com" \
  -H "X-Permisyn-Team: finance" \
  -H "X-Permisyn-Purpose: monthly close report" \
  -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Summarize the monthly close"}]}'

Real app integration

In production you typically keep your existing model client and route it through Permisyn proxy. The practical pattern is simple: pass your provider key as bearer token, add X-Permisyn-Key, and set X-Permisyn-Agent from your workflow identity.

anthropic-through-permisyn.sh
curl https://api.permisyn.com/v1/messages \
  -H "Authorization: Bearer $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "Content-Type: application/json" \
  -H "X-Permisyn-Key: $PERMISYN_API_KEY" \
  -H "X-Permisyn-Agent: support-ticket-agent" \
  -H "X-Permisyn-User: ops@yourco.com" \
  -H "X-Permisyn-Team: support" \
  -H "X-Permisyn-Purpose: support resolution" \
  -d '{"model":"claude-sonnet-4-20250514","max_tokens":300,"messages":[{"role":"user","content":"Summarize this ticket"}]}'

For multi-agent systems, do not duplicate headers across every call. Define one small agent profile map in the same backend module where you already configure provider base URL, auth, retries, and timeouts. Each workflow picks a profile; Permisyn still sees separate governed agents with separate users, teams, budgets, passports, and audit trails.

multi-agent-profiles.ts
type Workflow = "support" | "finance" | "legal";

const AGENTS = {
  support: {
    agent: "support-ticket-agent",
    team: "support",
    maxCost: "0.25",
  },
  finance: {
    agent: "finance-report-agent",
    team: "finance",
    maxCost: "1.00",
  },
  legal: {
    agent: "legal-contract-agent",
    team: "legal",
    maxCost: "0.50",
  },
} as const;

function permisynHeaders(workflow: Workflow, user: string) {
  const profile = AGENTS[workflow];
  return {
    Authorization: "Bearer " + process.env.PERMISYN_API_KEY,
    "Content-Type": "application/json",
    "X-Permisyn-Agent": profile.agent,
    "X-Permisyn-User": user,
    "X-Permisyn-Team": profile.team,
    "X-Permisyn-Purpose": workflow + " workflow",
    "X-Permisyn-Max-Cost-USD": profile.maxCost,
  };
}

// Same model call; only the workflow profile and calling user change.
await fetch("https://api.permisyn.com/v1/chat/completions", {
    method: "POST",
    headers: permisynHeaders("finance", "finance-owner@yourco.com"),
    body: JSON.stringify({
      model: "gpt-4o-mini",
      messages: [{ role: "user", content: "Summarize the monthly close" }],
    }),
});
multi-agent-profiles.py
import os
import requests

AGENTS = {
    "support": {"agent": "support-ticket-agent", "team": "support", "max_cost": "0.25"},
    "finance": {"agent": "finance-report-agent", "team": "finance", "max_cost": "1.00"},
    "legal":   {"agent": "legal-contract-agent",  "team": "legal",   "max_cost": "0.50"},
}


def permisyn_headers(workflow: str, user: str) -> dict[str, str]:
    profile = AGENTS[workflow]
    return {
        "Authorization": f"Bearer {os.environ['PERMISYN_API_KEY']}",
        "Content-Type": "application/json",
        "X-Permisyn-Agent": profile["agent"],
        "X-Permisyn-User": user,
        "X-Permisyn-Team": profile["team"],
        "X-Permisyn-Purpose": f"{workflow} workflow",
        "X-Permisyn-Max-Cost-USD": profile["max_cost"],
    }


# Same model call; only the workflow profile and calling user change.
response = requests.post(
    "https://api.permisyn.com/v1/chat/completions",
    headers=permisyn_headers("finance", "finance-owner@yourco.com"),
    json={
        "model": "gpt-4o-mini",
        "messages": [{"role": "user", "content": "Summarize the monthly close"}],
    },
    timeout=30,
)
Single-agent appSet one stable X-Permisyn-Agent name for the service and include user + team headers on every call.
Multi-agent workflowMap workflow/service identity to agent names server-side, then apply per-agent passport and budget policies.
Incident responseUse kill/freeze controls to halt traffic instantly while keeping signed evidence for postmortem.

2 request walkthrough

This is the exact production mental model: your backend points to the Permisyn endpoint, sends agent identity in headers, and Permisyn decides pre-execution — strictly ALLOW or BLOCK. Same provider key, two different outcomes.

1-allow-support-agent.sh
curl https://api.permisyn.com/v1/messages \
  -H "Authorization: Bearer $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "Content-Type: application/json" \
  -H "X-Permisyn-Key: $PERMISYN_API_KEY" \
  -H "X-Permisyn-Agent: support-ticket-agent" \
  -H "X-Permisyn-User: ops@yourco.com" \
  -H "X-Permisyn-Team: support" \
  -H "X-Permisyn-Purpose: support response" \
  -d '{"model":"claude-sonnet-4-20250514","max_tokens":250,"messages":[{"role":"user","content":"Summarize this customer issue"}]}'
2-block-legal-agent.sh
curl https://api.permisyn.com/v1/messages \
  -H "Authorization: Bearer $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "Content-Type: application/json" \
  -H "X-Permisyn-Key: $PERMISYN_API_KEY" \
  -H "X-Permisyn-Agent: legal-contract-agent" \
  -H "X-Permisyn-User: legal-owner@yourco.com" \
  -H "X-Permisyn-Team: legal" \
  -d '{"model":"gpt-4-turbo","max_tokens":250,"messages":[{"role":"user","content":"Draft a redline"}]}'
# legal-contract-agent's passport only allows claude-*, so gpt-4-turbo is
# blocked before Anthropic/OpenAI ever sees the request.
expected-outcomes.txt
ALLOW (support-ticket-agent)
- Request is forwarded to Anthropic
- You get normal model response body
- Response headers include X-Permisyn-Run-Id and X-Permisyn-Proxied: true

BLOCK (legal-contract-agent, out-of-passport model)
- Request is denied before reaching any provider
- Response is 403 { "error": { "code": "passport_violation", "run_id": "..." } }
- The denial itself is a signed receipt too — GET /api/verify/{run_id}

Authorization model

Permisyn is designed as AI Authorization Infrastructure: a control boundary that decides whether an agent may reach the model and then leaves proof. You can run vaultless by passing your provider key per request, or secretless by storing provider keys in the encrypted org vault. The goal is not to replace a provider gateway, cache, router, or observability tool; the goal is to make model access permissioned, accountable, and provable.

The decision model is strictly ALLOW or BLOCK — there is no pause-for-a-human approval step in the call path. If you previously integrated with an approval-gate header (X-Permisyn-Require-Approval, X-Permisyn-Approval-Models, X-Permisyn-Approval-Keywords, X-Permisyn-Approval-Timeout) or the risk-verdict header (X-Permisyn-Risk-Enforce), those are now silently inert — a call sending them still gets a normal ALLOW/BLOCK decision, never an error or a pause. X-Permisyn-Risk survives as a declarative-only label recorded on the receipt; it is never used to block a call.

AI Flight RecorderEach allowed or denied request becomes a decision receipt: identity, user, team, purpose, cost, result, and signature.
Agent PassportEvery agent has a durable operational identity: owner, risk class, budget, model permissions, kill state, and proof history.
Pre-flight AuthorizationIdentity, model, user, team, budget, and kill controls are evaluated before upstream execution.
Kill BoundaryWhen security halts an agent, Permisyn stops the next model call at the boundary even if app code is unchanged.

Authentication

Vaultless authentication is the default. Your provider key remains the bearer token your model client already understands. The Permisyn key is sent separately in X-Permisyn-Key. For stricter secret handling, use secretless mode: paste the provider key in the encrypted vault and remove OpenAI/Anthropic keys from application code.

vaultless auth
Authorization: Bearer $YOUR_PROVIDER_KEY
X-Permisyn-Key: psyn_live_xxx

You can also combine both keys into one bearer token, or go secretless: store the provider key once in the encrypted org vault and then send only your Permisyn key — the proxy injects the upstream key server-side and never records it.

combined + secretless
# Combined single token
Authorization: Bearer permisyn__psyn_live_xxx__sk-your-provider-key

# Secretless (provider key held in the org vault)
Authorization: Bearer psyn_live_xxx
# store it first, once:
PUT /api/control/provider-keys  {"provider":"openai","api_key":"sk-..."}

Provider routing

Permisyn authorizes the request before provider routing. The same authorization headers work across OpenAI-compatible providers, Anthropic messages, Gemini, Mistral, Azure OpenAI deployments, and custom public HTTPS upstreams.

OpenAIChat, responses, completions
AnthropicClaude messages
GeminiGoogle AI Studio / Vertex
MistralLa Plateforme models
Azure OpenAIEnterprise deployments
GroqOpenAI-compatible
Together AIOpenAI-compatible
HTTPSCustom HTTPSAny public upstream
OpenAI/v1/chat/completionsDefault OpenAI-compatible path. Works with standard OpenAI clients by changing base URL.
Anthropic/v1/messagesUse the Anthropic-shaped route and set provider headers when needed.
Gemini/v1/chat/completionsSet X-Permisyn-Provider: gemini for built-in routing to Gemini's OpenAI-compatible endpoint, or use custom upstream.
Mistral/v1/chat/completionsSet X-Permisyn-Provider: mistral for built-in routing, or use custom upstream.
Azure OpenAI/v1/chat/completionsSet X-Permisyn-UpstreamURL to the deployment base URL, including your Azure resource path.
Groq/v1/chat/completions or /openai/v1/chat/completionsSet X-Permisyn-Provider: groq. Works with either the openai-compatible client (OPENAI_BASE_URL) or Groq's own native SDK (GROQ_BASE_URL/GROQ_API_KEY/GROQ_CUSTOM_HEADERS) — the second path exists specifically to match Groq's SDK, which hardcodes /openai/v1/... regardless of base URL.
Together/v1/chat/completionsSet X-Permisyn-Provider: together. Works with either the openai-compatible client or Together's own native SDK (TOGETHER_BASE_URL/TOGETHER_API_KEY/TOGETHER_CUSTOM_HEADERS).
CustomAny supported pathSet X-Permisyn-UpstreamURL to a public https origin. Private IPs, localhost, and credentialed URLs are rejected.
mistral — built-in routing
curl https://api.permisyn.com/v1/chat/completions \
  -H "Authorization: Bearer $YOUR_PROVIDER_KEY" \
  -H "X-Permisyn-Key: psyn_live_xxx" \
  -H "X-Permisyn-Agent: research-agent" \
  -H "X-Permisyn-Provider: mistral" \
  -d '{"model":"mistral-small-latest","messages":[{"role":"user","content":"Summarize"}]}'
custom upstream
curl https://api.permisyn.com/v1/chat/completions \
  -H "Authorization: Bearer $YOUR_PROVIDER_KEY" \
  -H "X-Permisyn-Key: psyn_live_xxx" \
  -H "X-Permisyn-Agent: research-agent" \
  -H "X-Permisyn-UpstreamURL: https://your-private-llm-gateway.example.com" \
  -d '{"model":"your-self-hosted-model","messages":[{"role":"user","content":"Summarize"}]}'

Governance headers

X-Permisyn-AgentRequired for clean attribution. Creates or updates the governed agent.
X-Permisyn-ProfileOptional profile reference from Shareable Team Headers. The proxy resolves locked admin values server-side before authorization.
X-Permisyn-UserAccountable human user shown in reports, evidence, and /usage.
X-Permisyn-TeamTeam/department attribution — rolled up on the Usage page and filterable via GET /api/runs?team=.
X-Permisyn-RiskLOW, MEDIUM, HIGH, or CRITICAL. Declarative label recorded in the receipt — never used to block a call.
X-Permisyn-PurposeBusiness purpose recorded in the signed run metadata.
X-Permisyn-Max-Cost-USDPre-call cost cap. Blocks when the agent is over budget.
X-Permisyn-Prompt-Modeoff (default, store no prompt text) | redacted | preview for private audit views.
X-Permisyn-Attest-Outputtrue/1/yes (opt-in, off by default) binds a sha256 hash of the completion into the signed receipt — proof of exactly what came back, without storing the content itself.
X-Permisyn-Chain-IdShared label across a multi-agent pipeline — links every hop into one verifiable chain at GET /api/verify/chain/{chain_id}.
X-Permisyn-Parent-Run-IdThe previous hop's run_id (from its X-Permisyn-Run-Id response header) — extends a chain and checks input/output hash continuity.
X-Permisyn-ProviderOverride upstream provider routing (openai, anthropic, groq, together...).
X-Permisyn-UpstreamURLRoute to a custom public HTTPS upstream (e.g. Azure OpenAI, a Gemini adapter, Mistral). Private IPs, localhost, and credentialed URLs are rejected.

Prompt privacy

By default, Permisyn does not retain prompt text. The proxy inspects the live request in memory to enforce cost, passport, and kill controls before the provider call, then stores only the signed operational receipt. Public verification never includes prompt previews.

prompt retention modes
# default: no prompt text stored
X-Permisyn-Prompt-Mode: off

# optional private audit preview with common secrets removed
X-Permisyn-Prompt-Mode: redacted

# optional raw private audit preview for regulated review workflows
X-Permisyn-Prompt-Mode: preview

For strict residency, run the edge sidecar. In its default PROMPT_MODE=off mode, raw prompts and completions stay in your network; the control plane receives a prompt hash plus local risk signals, and the sidecar calls the provider directly only after authorization.

Agent passport

An agent passport is a signed identity that declares what an agent may do: allowed models, allowed providers, and data scope. It is Ed25519-signed by your org key and enforced in the proxy before the call reaches the provider — a call outside the passport is blocked with 403 passport_violation. Manage a single agent's passport from its agent detail page, or review every agent's passport fleet-wide from AI Passports.

declare + enforce a passport
PUT /api/agents/{agent_id}/passport
{
  "allowed_models": "gpt-4o-mini,claude-*",
  "allowed_providers": "openai,anthropic",
  "data_scope": "support tickets only; no PII",
  "passport_active": true,
  "region_scope": "IN",
  "expires_in_days": 30
}

# a call to a model outside the passport now returns:
# 403 { "error": { "code": "passport_violation", ... } }
# a call after passport_expires_at has passed instead returns:
# 403 { "error": { "code": "passport_expired", ... } }

expires_in_days sets passport_expires_at — checked at call time, no cron required, so a contractor or short-lived agent's access self-revokes without anyone remembering to do it. region_scope (e.g. "IN", "EU") is a declarative data-residency label — setting it requires allowed_providers to be non-empty (422 region_scope_requires_providers otherwise). By itself it's still just a label; set region_scope_enforced: true to turn it into a real pre-flight gate — see Time-boxed access & enforced residency below for exactly what that can and can't prove.

Every save that actually changes a tracked field (models, providers, data scope, cost cap, or enforcement) appends a signed, append-only revision — who changed what, from what, to what — instead of only keeping the current snapshot. Response and GET /api/verify/passport/{agent_id} both include revisions (newest first, each independently Ed25519-verifiable) plus least_privilege_score — a second, distinct number from the completeness-only readiness score, measuring how narrow the grant actually is (100 minus points for each broad, unrestricted dimension: no model allow-list, no provider allow-list, no data scope, no cost cap).

passport response shape
GET /api/agents/{agent_id}/passport
# → { ..., "expires_at": "2026-08-04T00:00:00Z", "expired": false,
#     "region_scope": "IN", "least_privilege_score": 80,
#     "revisions": [
#       { "id":"prev_...", "changed_by":"admin@yourco.com",
#         "prior": {"allowed_models": null, ...},
#         "new":   {"allowed_models": "gpt-4o-mini,claude-*", ...},
#         "signature":"ed25519:...", "verified": true, "created_at":"..." }
#     ] }

changed_by is always the authenticated admin session's own email — never a caller-supplied proxy header like X-Permisyn-User — so the change-history trail can't be spoofed by whatever identity a proxied call happens to send.

Time-boxed access & enforced residency

Two more passport dimensions, both opt-in and both independent of passport_active (same reasoning as action-scope below — a caller may want one boundary without the others):

restrict when this agent may call, and where
PUT /api/agents/{agent_id}/passport
{
  "active_hours_start": 9,
  "active_hours_end": 18,
  "active_days": "mon,tue,wed,thu,fri",
  "region_scope": "EU",
  "region_scope_enforced": true
}

# a call outside the declared window now returns:
# 403 { "error": { "code": "passport_time_restricted", ... } }
# a call to a provider not known to serve the declared region returns:
# 403 { "error": { "code": "passport_region_violation", ... } }

active_hours_start/active_hours_end (0-23, always UTC — there is no per-org timezone setting) must be set together or not at all (422 incomplete_active_hours otherwise); an end hour earlier than the start means an overnight window, e.g. 22 → 6. active_days is a csv of weekday abbreviations. Leaving both unset is fully unrestricted, matching every other passport dimension's empty-means-allow-all convention. Catches the case a static allow-list never can: a leaked key being used at 3am on a Sunday.

Read this carefully before enabling region_scope_enforced: Permisyn cannot independently verify where a call to a fixed-endpoint provider (OpenAI, Anthropic, Groq, Together, Gemini, Mistral) physically lands — none of them expose a region signal anywhere in the request or response. Enforcement instead checks the provider against a maintained, self-declared provider→region table; a provider missing from that table is treated as "residency unproven" and blocked outright rather than silently allowed. For Azure/custom upstreams it falls back to a best-effort substring match of region_scope against the resolved upstream hostname (e.g. region_scope: "eastus" matching a hostname containing eastus). This turns a cosmetic label into a real, working gate — it is not, and does not claim to be, independent network-level geo-verification.

Chain-scoped cumulative budget

max_cost_usd caps a single call. It was never enough for an autonomous multi-agent loop — a runaway chain can spend well past any sane limit while every individual hop stays under its own cap. max_cost_per_chain_usd caps the total across every hop sharing one X-Permisyn-Chain-Id.

cap total spend across a chain, not just one call
PUT /api/agents/{agent_id}/passport
{ "max_cost_per_chain_usd": 5.00 }

# the Nth call in the chain that would push cumulative spend
# over the cap returns:
# 402 { "error": { "code": "chain_budget_exceeded",
#                   "chain_spent_usd": 4.86, "max_cost_per_chain_usd": 5.00, ... } }

0 or unset means uncapped, matching max_cost_usd's own convention. Unlike the single-agent cap's atomic reserve-then-check, this is a sum-then-compare over real recorded spend for the chain — a known, accepted tradeoff: two near-simultaneous hops could both pass before either commits, bounded to at most one call's worth of overrun, not unbounded. A chain budget composes with delegation grants' own cost ceiling the same way max_cost_usd already does — the narrower of the two always wins.

Passport templates

Every passport field above is set per agent. A template is a named, org-level policy that any number of agents can subscribe to at once — edit the template once, every subscriber's passport re-signs together, instead of hand-editing N agents.

create a template, subscribe an agent
POST /api/control/passport-templates
{ "name": "customer-support-tier1", "allowed_models": "gpt-4o-mini",
  "allowed_providers": "openai", "max_cost_usd": 5.00 }
# → { "id": "tpl_...", "subscriber_count": 0, ... }

POST /api/agents/{agent_id}/passport/template
{ "template_id": "tpl_..." }
# → immediately overwrites this agent's template-owned fields and
#   re-signs — same response shape as PUT .../passport

PUT /api/control/passport-templates/{id}
{ "name": "customer-support-tier1", "allowed_models": "gpt-4o", ... }
# → re-signs EVERY subscribing agent's passport together

While subscribed, template-owned fields reject a direct PUT .../passport edit with 422 field_managed_by_template — unsubscribe first ({ "template_id": null }) to customize an individual agent, or edit the template to change every subscriber at once. Deleting a template with active subscribers returns 409 template_has_subscribers — no silent cascade. Manage templates from Fleet Control; subscribe/unsubscribe from an agent's own Passport tab.

Passport policy simulation

Tightening a passport on a live agent is scary without knowing whether it'll break something that's currently working. POST /api/agents/{agent_id}/passport/simulate replays a candidate policy — not yet saved — against the agent's own real signed call history, using the exact same enforcement function the live proxy runs, so the answer can never drift from what would actually happen.

simulate before you save
POST /api/agents/{agent_id}/passport/simulate
{ "allowed_models": "gpt-4o-mini", "window_days": 30 }

# → { "window_days": 30, "runs_scanned": 147,
#     "model_provider": {
#       "confidence": "exact", "total": 147, "allowed": 142,
#       "blocked": [ { "run_id": "run_...", "model": "gpt-4-turbo",
#                      "provider": "openai", "reason": "model 'gpt-4-turbo' is not in this agent's passport (allowed: gpt-4o-mini)" } ]
#     },
#     "action_scope": null }

The model_provider result is an exact, date-windowed backtest — both fields are recorded on every signed run, allowed or denied. If the request also includes allowed_actions, the response gains an action_scope block — but that one is only ever an all-time, non-windowed estimate (confidence: "approximate"), built from the agent's observed tool-name history rather than a per-call replay, because the proxy only ever persists action-scope violations per run, never the full set of tool names declared on an allowed call. The two confidence levels are deliberately different shapes in the response so they can never be mistaken for the same kind of number. Simulation is a pure read — nothing is saved, signed, or recorded — and free on every plan. Try it from the agent detail page's Passport tab: edit the draft fields, click Simulate this policy, before clicking Save.

Action-scope passports

Every other AI gateway and guardrails product stops at which model an agent may call. Permisyn's passport also governs which tool or function calls the model is allowed to request — send_email, refund_payment, delete_* — and enforces it in real time, in the same request/response cycle, with zero code change. This works because a tool-calling LLM never executes anything itself: it replies with an instruction to call a function, inside the exact response body that passes back through the proxy on its way to your code. That is the one place a real-time, pre-execution check on actions (not just models) is even possible without an SDK.

declare an action scope
PUT /api/agents/{agent_id}/passport
{
  "allowed_actions": "send_email,refund_payment,read_database",
  "action_enforcement_mode": "block",
  "license_level": "financial_action"
}

# a response requesting a tool call outside allowed_actions is now either:
#  - "block":    3 unauthorized attempts within 10 minutes auto-kill-
#                switches this agent (same enforcement as a human's
#                POST .../kill — pre-flight, before any upstream call,
#                works even on streaming calls). Non-streaming responses
#                also have the disallowed tool_call stripped immediately,
#                before your code ever sees it.
#  - "advisory": delivered as-is, but flagged in the signed receipt and
#                fired as an agent.action_violation webhook

allowed_actions is opt-in and independent of allowed_models/allowed_providers — leaving it unset never blocks a tool call, so declaring a model allow-list doesn't accidentally lock down every function your agent calls. Matching is glob-aware (delete_* matches delete_user, delete_invoice, …), and works against both OpenAI-style tool_calls and Anthropic-style tool_use content blocks.

action_enforcement_mode is "advisory" by default, matching Permisyn's strict allow-or-block model — there is no pause-for-a-human approval step. Set it to "block" once you trust the declared scope: 3 unauthorized tool-call attempts within a 10-minute window auto-kill-switches the agent, using the exact same kill-switch enforcement a human admin's emergency stop uses — checked before every upstream call, regardless of streaming or provider shape. On a non-streaming response the disallowed call is also stripped immediately, before your code ever sees it, but the kill-switch escalation is what makes block mode meaningful for streaming traffic too, since a streamed tool call can't be edited after the fact (its tokens have typically already reached the caller by the time it's fully assembled from SSE deltas). Violations are always detected and recorded on the signed receipt, whichever mode is set.

license_level is a declarative risk tier — unlicensed, internal_only, customer_data, financial_action, or autonomous — that makes a passport's posture legible at a glance on the AI Passports registry, without anyone having to parse the raw allow-lists. It is a label over the real, enforced allowed_actions/allowed_models rules, not independently enforced on its own.

Auto-discovered action names

allowed_actions is matched by exact/glob/substring string comparison against whatever your code literally names its tools/functions — there is no semantic understanding. Declare allowed_actions="return_request" when your code's tool schema actually names the function process_return, and that call silently mismatches — wrongly blocked in block mode, wrongly flagged in advisory mode. Instead of hand-typing (and risking mistyping) your own code's function names, the proxy passively records every tool name it ever sees declared in a request's tools array — regardless of whether the model chooses to call it, and regardless of the allow/block decision.

what gets captured
POST /v1/chat/completions
{
  "model": "gpt-4o-mini",
  "messages": [...],
  "tools": [
    { "type": "function", "function": { "name": "process_return" } },
    { "type": "function", "function": { "name": "check_order_status" } }
  ]
}
# Permisyn records BOTH names as "observed" for this agent — whether or
# not the model actually calls either one this time, and independent of
# allowed_actions / action_enforcement_mode.

GET /api/agents/{agent_id}/passport
# → { ..., "observed_actions": [
#     { "name": "process_return",    "count": 7, "first_seen": "...", "last_seen": "..." },
#     { "name": "check_order_status","count": 3, "first_seen": "...", "last_seen": "..." }
#   ] }

Capture happens on the same background thread that records the run, so it never adds latency to your response, and only fires when a request actually declares a tools array — a plain chat completion with no tools costs nothing extra. Up to 40 distinct names are kept per agent, least-recently-seen evicted first. In the dashboard, the agent's Passport tab shows these as clickable chips under the Allowed Actions field — click one (or "+ Add all") to append the exact observed string to allowed_actions, so the name your passport enforces is always identical to the name your code actually sends.

Auto-baseline, opt-in approval

Clicking chips by hand is fine for a handful of tools, but it still asks a human to notice every new one. auto_baseline_actions turns discovery fully automatic: turn it on and this agent's next proxied call is treated as trustworthy — every tool name it declares is auto-merged into allowed_actions and the baseline is locked, regardless of the approval setting below (requiring approval for the founding baseline would make auto-learn useless — the agent couldn't call anything until a human acted first). What happens to a tool name discovered after that point depends on action_baseline_requires_approval: off (the default) auto-merges it the same way, forever, no manual review step; on holds it as pending until you explicitly approve or reject it from the Passport tab. Either way, each addition (or pending flag) is still its own signed PassportRevision or notification, attributed to who and which team triggered it, so nothing is silent.

opt in
PUT /api/agents/{agent_id}/passport
{ "auto_baseline_actions": true, "action_baseline_requires_approval": false }

# next proxied call for this agent →
#   every declared tool name auto-added to allowed_actions (always, regardless
#   of action_baseline_requires_approval -- this is the founding baseline)
#   action_baseline_locked_at stamped, a signed passport revision recorded
#   notification + agent.action_baseline_captured webhook fired

# any LATER call that declares a name never seen before →
#   action_baseline_requires_approval=false (default): ALSO auto-added to
#     allowed_actions, same as the baseline call -- observed_actions entry
#     status="baseline", a signed passport revision recorded, notification +
#     agent.action_auto_approved webhook fired
#   action_baseline_requires_approval=true: held as observed_actions entry
#     status="pending" -- NOT added to allowed_actions -- notification +
#     agent.action_pending_approval webhook fired, awaiting
#     POST .../actions/approve or .../actions/reject

Neither setting ever adds a synchronous pause to the live call — Permisyn's decision model stays strictly ALLOW/BLOCK, the same as everywhere else in the proxy. A pending name is simply absent from allowed_actions until reviewed, so a block-mode agent already rejects it and an advisory-mode agent already flags it, from its very first appearance — visibility comes from the notification and the signed revision history, never from a gate on the call itself. (This workflow briefly auto-approved every post-baseline discovery unconditionally, with no approval option at all, from 2026-07-12 to 2026-07-14 — found too rigid once real usage showed some agents genuinely need the review step for sensitive tools; action_baseline_requires_approval brings it back as an explicit per-agent choice instead of a global default.)

Each observed-action entry still records discovered_by_user / discovered_by_team, taken from whatever X-Permisyn-User/X-Permisyn-Team headers rode on the call that first declared it — so every auto-approval always shows who (or which team) introduced the tool, not just which agent. Off by default: every existing agent keeps the plain, purely-informational action_discovered notice (nothing auto-added) until you opt in. Turning the toggle on for an agent that already has traffic history still works — the very next call becomes the new baseline, not "never."

User & team identity

Permisyn is the company authorization layer: one admin, one company provider key, an entire team and its agents accessing it through the same Permisyn key. X-Permisyn-User and X-Permisyn-Team attach the calling human and their team to every request, alongside X-Permisyn-Agent. Both ride into the signed receipt and are rolled up on the Usage page and via GET /api/runs?user=&team=.

identity headers
curl https://api.permisyn.com/v1/chat/completions \
  -H "Authorization: Bearer $YOUR_PROVIDER_KEY" \
  -H "X-Permisyn-Key: psyn_live_xxx" \
  -H "X-Permisyn-Agent: sales-email-agent" \
  -H "X-Permisyn-User: rep@yourco.com" \
  -H "X-Permisyn-Team: sales" \
  -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Draft a follow-up email"}]}'

GET /api/runs?team=sales           # every run this team's agents produced
GET /api/dashboard/usage           # rolled up by user, team, and agent

The decision model is strictly ALLOW or BLOCK — there is no pause-for-a-human step. X-Permisyn-Risk stays as a declarative label recorded in the receipt for audit context; it is never used to block a call.

Beyond the per-call rollup above, a team owns its agents: every Shareable team header profile you save records the team that provisioned it, so an agent's owning team is resolved automatically the moment it's created — no separate step. This powers a real Team → Agent → User hierarchy, not just a flat tag: every agent response carries a team field and a distinct-caller users_count, and the Agents, Passports, Usage, and Audit Report pages all let you pick one team and scope the whole view to it, instead of showing every team's agents at once. Agents with no provisioning profile are grouped under Unassigned rather than dropped.

team ownership + drill-down
GET /api/agents                         # every agent carries team + users_count
GET /api/agents/{id}/users              # per-user call/cost breakdown for one agent
GET /api/dashboard/usage
# → { by_user, by_team, by_agent,        # flat rollups (per-call X-Permisyn-Team)
#     by_team_hierarchy: [               # Team -> Agent -> User tree
#       { team, calls, total_cost_usd,
#         agents: [{ agent_id, agent_name, calls, users: [{ user, calls, ... }] }] }
#     ] }

Shareable team headers

Shareable team header profiles are admin-saved policy bundles dereferenced by the proxy at call time. An admin names one agent per profile along with team, purpose, cost cap, risk, locked custom headers, and (optionally) a passport lock — allowed models, allowed providers, data scope. Saving the profile provisions that agent (or reuses it if it already exists) and immediately signs its passport. The team member's snippet contains only X-Permisyn-Profile, X-Permisyn-User, and any admin-declared team-fills/runtime placeholders. X-Permisyn-Agent, X-Permisyn-Team, purpose, budget, risk, and locked headers stay server-side and are injected by Permisyn before freeze, kill, passport, and cost checks run. The agent this profile provisions is now considered owned by that team everywhere in the dashboard — see User & team identity below.

create a shareable team header profile
POST /api/control/header-profiles
{
  "label": "Finance production",
  "team": "finance",
  "agent_name": "finance-payment-support-agent",
  "purpose": "payment support",
  "max_cost_usd": 1.00,
  "risk_level": "HIGH",
  "allowed_models": "gpt-4o-mini,claude-*",
  "allowed_providers": "openai,anthropic",
  "data_scope": "payment support tickets only; no card numbers",
  "additional_headers": [
    {"name":"X-Permisyn-Environment","mode":"locked","value":"production"},
    {"name":"X-Permisyn-Ticket-ID","mode":"runtime"},
    {"name":"X-Permisyn-Region","mode":"employee"}
  ]
}
# → creates/reuses the "finance-payment-support-agent" agent and signs its
#   passport from allowed_models/allowed_providers/data_scope before returning

A second profile trying to reuse an agent_name another profile in the same org already owns returns 409 — a profile is meant to be the one place that agent's header bundle and passport are managed together. Extra additional_headers must still start with X-Permisyn-; X-Permisyn-Agent, X-Permisyn-User, X-Permisyn-Key, and X-Permisyn-Profile stay reserved there. Locked additional headers are injected server-side and captured in signed run metadata; team-fills/runtime additional headers remain visible in the snippet as placeholders. Every signed receipt includes metadata.header_profile when a profile was dereferenced. Deleting a profile removes the saved profile reference only — the agent and its signed passport are untouched. A profile can also carry region_scope and expires_in_days — the same passport fields described above — so a team's whole agent is residency-scoped or time-boxed from the moment the profile is saved.

Profile-based calls require a real X-Permisyn-User. Omitting it or leaving <your-email> unchanged returns a signed 422 profile_user_required denial instead of creating unattributed team usage.

Instead of the admin copying a snippet by hand, generate a short-lived, profile-scoped onboarding link. The team member opens it, types only their own name and email — nothing usable is shown yet. A confirmation link is emailed to that address; only after they click it does the finished snippet appear, with X-Permisyn-User already filled in as their now-verified email. This closes the one remaining place a human could type a wrong or fake identity, without adding an approval-gate-style pause to any AI call itself — the call path is untouched; only the identity's "verified" badge is deferred.

generate and complete an onboarding link
POST /api/control/header-profiles/{profile_id}/onboarding-link
{ "expires_in_days": 7 }
# → { "token": "onb_...", "url": "https://permisyn.com/onboard/onb_...", ... }

# Team member visits the public URL, no auth:
GET /api/onboard/{token}
# → { "label": "Sales Team", "team": "sales", "agent_name": "sales-outbound-agent", ... }

# Team member submits their name + email — no snippet returned yet:
POST /api/onboard/{token}/request
{ "name": "Jane Doe", "email": "jane@yourco.com" }
# → an email is sent to jane@yourco.com with a second, single-use confirm link

# Team member clicks the emailed link:
GET /api/onboard/{token}/confirm/{email_token}
# → { "verified_email": "jane@yourco.com",
#     "headers": { "X-Permisyn-Profile": "hdrp_...",
#                   "X-Permisyn-User": "jane@yourco.com",
#                   "X-Permisyn-Ticket-ID": "<runtime-value>",
#                   "X-Permisyn-Region": "<team-fills>" } }

Onboarding links are revocable and expiring, mirroring the auditor token convention exactly — a revoked, expired, or already-used link 404s rather than 403s, so a dead link never reveals that it once existed. The Permisyn key itself is never part of this flow; the admin still shares that once, out of band, the same way as any other snippet.

Multi-agent chain of custody

Link every hop of a multi-agent pipeline into one signed, tamper-evident sequence — no SDK, just two headers. Each hop's prompt is hashed as input_hash; each hop's completion is hashed as output_hash — both one-way hashes, never the content itself, riding the same Ed25519 signature every receipt already has. A chain is only "verified" when every hop's own signature checks out and hop N's input_hash equals hop N-1's output_hash — proof that hop N actually consumed what hop N-1 produced, not a substituted or tampered value.

chain a 2-hop pipeline
# Hop 1 — start the chain
curl https://api.permisyn.com/v1/chat/completions \
  -H "Authorization: Bearer $PERMISYN_KEY" \
  -H "X-Permisyn-Agent: research-agent" \
  -H "X-Permisyn-Chain-Id: pipeline-42" \
  -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"research topic X"}]}'
# response header: X-Permisyn-Run-Id: run_proxy_abc123

# Hop 2 — extend it, referencing hop 1's run id
curl https://api.permisyn.com/v1/chat/completions \
  -H "Authorization: Bearer $PERMISYN_KEY" \
  -H "X-Permisyn-Agent: writer-agent" \
  -H "X-Permisyn-Parent-Run-Id: run_proxy_abc123" \
  -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"<hop 1 output piped in>"}]}'

GET /api/verify/chain/pipeline-42
# → { "chain_verified":true, "hop_count":2, "broken_at_depth":null,
#     "hops":[{ "run_id":"...", "chain_depth":0, "continuity_ok":null, ... },
#             { "run_id":"...", "chain_depth":1, "continuity_ok":true,  ... }] }

A missing or unresolvable X-Permisyn-Parent-Run-Id never blocks the call — it just means this hop starts its own chain at depth 0 instead of extending one. Free on every plan. Public, no-auth verification at GET /api/verify/chain/{chain_id}.

Capability delegation

Chain of custody above proves who called whom. Capability delegation proves something stronger: that a delegate agent's effective permissions in that chain can never exceed what its caller actually, provably handed it — even if the delegate's own passport is broader. When Agent A calls Agent B as part of a chain, A can mint a signed delegation grant for that chain_id — a narrowed slice of A's own currently-active passport. Permisyn checks server-side that the grant is a genuine subset before it ever gets signed; it can only narrow, never widen. While that chain is active, B's enforced permissions for calls inside it are the intersection of its own passport and the grant.

Two ways to mint a grant: an admin pre-wiring a known pipeline (POST /api/agents/{id}/delegations, session auth), or an agent minting its own grant at call time with no human in the loop (POST /v1/delegations, Bearer-key auth) — the real path for a dynamic orchestrator spawning workers. Either way the delegator is resolved from the caller's own identity, never trusted from a request body field.

delegate a narrower slice of a passport, then use it
# Orchestrator (broad passport: gpt-4o-mini, claude-3-opus) delegates only
# gpt-4o-mini to a worker, scoped to this one chain
curl https://api.permisyn.com/v1/delegations \
  -H "Authorization: Bearer $PERMISYN_KEY" \
  -H "X-Permisyn-Agent: orchestrator" \
  -d '{"delegate_agent_name":"worker-1","chain_id":"pipeline-42",
       "allowed_models":"gpt-4o-mini"}'
# → { "grant_id":"dgrant_...", "signature":"ed25519:..." }

# worker-1 stays capped to gpt-4o-mini for THIS chain — claude-3-opus is
# blocked here even though worker-1's own passport would otherwise allow it
curl https://api.permisyn.com/v1/chat/completions \
  -H "Authorization: Bearer $PERMISYN_KEY" \
  -H "X-Permisyn-Agent: worker-1" \
  -H "X-Permisyn-Chain-Id: pipeline-42" \
  -d '{"model":"claude-3-opus","messages":[{"role":"user","content":"..."}]}'
# → 403 delegation_violation (signed receipt, independent of any passport_violation)

GET /api/verify/chain/pipeline-42
# → { ..., "capability_chain": {
#       "no_escalation_at_any_hop": true, "fully_delegation_scoped": true,
#       "hops": [{ "delegation_grant_id":"dgrant_...", "grant_is_genuine_subset":true, ... }] } }

Outside the chain the grant is scoped to, the delegate's own passport governs as normal — a grant never leaks into an agent's general-purpose behavior. Public verification independently re-derives every grant's subset-validity from its own signed fields, never trusting that the mint-time check was followed correctly — a tampered grant is caught, not just displayed. Free on every plan.

Behavioral drift detection

Every agent carries a live behavioral fingerprint built from three complementary, ML-free engines: statistical (Welford online mean/variance over tokens, cost, duration), semantic (SimHash drift against the agent's historical behavior centroid), and content-pattern scanning (known exfiltration, credential-harvest, and prompt-injection signatures). Together they flag when an agent starts behaving differently than its own history — not just when it violates a declared passport rule.

drift signal on a run
GET /api/verify/{run_id}
# → { ..., "behavioral": { "is_anomaly": true, "anomaly_score": 0.72 } }

GET /api/agents
# → [{ ..., "fingerprint_health": 61.4, "prev_fingerprint_health": 88.0 }]

The score is a deterministic function of data that's already part of the signed payload (tokens, cost, duration, step content) — independently recomputable from the signed history, even though the score itself is computed after signing rather than inside the Ed25519 signature. The public verifier exposes the numeric flag/score on every plan; the free-text explanation of why a run was flagged stays in the authenticated dashboard (GET /api/runs/{id}) — a direct-ingested run could otherwise echo customer-supplied content through that field on a public endpoint.

Controls

Authorization controls are enforced before the upstream model call, in this order: org freeze → kill switch → agent passport → cost cap, plus unsafe-upstream rejection, test/live isolation, and rate limits. Streaming responses are passed through while usage is estimated and persisted.

passport + cost cap
curl https://api.permisyn.com/v1/chat/completions \
  -H "Authorization: Bearer $YOUR_PROVIDER_KEY" \
  -H "X-Permisyn-Key: psyn_live_xxx" \
  -H "X-Permisyn-Agent: finance-report-agent" \
  -H "X-Permisyn-User: finance-owner@yourco.com" \
  -H "X-Permisyn-Max-Cost-USD: 1.00" \
  -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Summarize the monthly close"}]}'

Vault & freeze

The org control plane lets an operator run the whole fleet from one place — all also available in the Fleet Control page. Store provider keys in an encrypted vault for secretless calls, flip a single org-wide freeze that stops every agent, or revoke one agent instantly (kill + deactivate its passport).

Paste the provider key in Fleet Control only once. It is encrypted at rest, never shown again, and never stored on run receipts. A secretless proxy request carries Authorization: Bearer psyn_live_...; Permisyn retrieves the provider key internally after kill, passport, and cost checks pass.

Vault capacity is plan-limited: Solo stores 1 provider key (rotating that same provider's key never counts against the cap); Startup is unlimited. Adding a key past your cap returns 402 plan_limit_reached — see pricing.

control plane
PUT  /api/control/provider-keys   {"provider":"openai","api_key":"sk-..."}
POST /api/control/freeze          {"reason":"incident #42"}   # 503 all AI, org-wide
POST /api/control/unfreeze
POST /api/control/agents/{id}/revoke                          # instant kill + passport off
GET  /api/control/status

Verifiable audit

Every proxied call is recorded as a run and signed with your organization's own Ed25519 key (HMAC is a fallback only). Anyone can verify what an agent did using your public key — no Permisyn login and no shared secret — so the trail is non-repudiable. Merkle roots are periodically anchored to OpenTimestamps, so even Permisyn cannot backdate history.

verify with no auth and no secret
GET /api/verify/{run_id}
# → { "algorithm":"ed25519", "verified":true,
#     "signed_payload":{...}, "public_key_pem":"-----BEGIN PUBLIC KEY-----...",
#     "bitcoin_anchor": {"anchored":true, "ots_status":"bitcoin_confirmed",
#                         "bitcoin_block_height":872341, ...} }

GET /api/orgs/{org_id}/pubkey        # your public verification key
GET /api/verify/passport/{agent_id}  # verify an agent's signed passport
GET /transparency/proof/{run_id}     # Merkle inclusion proof
POST /api/control/anchor             # seal + OpenTimestamps external anchor

Recompute the canonical JSON (sort_keys=True, separators=(",",":")) over signed_payload and verify the signature against public_key_pem. A tampered payload fails verification. The public verification page does this in the browser.

By default the signed payload proves the decision — who was authorized, under what budget and risk posture — but deliberately excludes prompt and completion content. Set X-Permisyn-Attest-Output: true to opt into binding a sha256 hash of the actual completion into that same signed metadata, closing the gap from "we authorized this call" to "we authorized this call AND here is cryptographic proof of exactly what came back." It rides the existing Ed25519 signature — no separate verification step, no new secret.

output attestation (opt-in)
curl https://api.permisyn.com/v1/chat/completions \
  -H "Authorization: Bearer permisyn__psyn_live_xxx__sk-..." \
  -H "X-Permisyn-Agent: contract-reviewer" \
  -H "X-Permisyn-Attest-Output: true" \
  -d '{"model":"gpt-4o-mini","messages":[...]}'

GET /api/verify/{run_id}
# → signed_payload.metadata.output_hash = sha256(completion_text)
# Recompute sha256 over your own copy of the completion — a match proves
# it is exactly what was returned for this authorized call, unmodified.

A signature only proves who attested to something — it can't prove when, and a party holding the signing key could in principle sign a backdated payload. To close that gap, Permisyn hash-chains the whole transparency log into hourly (or on-demand) anchors, and submits each anchor's Merkle root to a public OpenTimestamps calendar. Once the calendar's batch is mined, the root is embedded in a real Bitcoin block — and Permisyn independently re-checks that against a public block explorer before ever calling it confirmed, rather than trusting the calendar's word for it.

Bitcoin anchor status + live re-verification
GET /transparency/anchors             # public — hash-chained anchor log
# → [{ "id":7, "ots_status":"bitcoin_confirmed",
#      "bitcoin_block_height":872341,
#      "bitcoin_block_hash":"000000000000...", ... }]

GET /transparency/anchor/{id}/proof   # portable .ots proof (base64) — verify
                                       # with the standard `ots verify` CLI,
                                       # no Permisyn trust required
GET /transparency/anchor/{id}/verify  # LIVE re-check against the real chain
                                       # right now — not a cached DB flag

Once bitcoin-confirmed, a run's verify response carries a bitcoin_anchor block pointing at the covering anchor — visible directly on the verification page. The downloaded proof is a standard .ots file: pip install opentimestamps-client && ots verify works against it without ever talking to Permisyn.

Don't want to take this on faith? Try to break it yourself — a public, no-login arena that runs the exact same passport-enforcement and signature-verification code described above against a live target, with a real-time scoreboard of every attempt.

Evidence is only a moat if it's distributable, not something a visitor has to take your word for inside a dashboard. Every org has a public, signed trust badge — an embeddable image you can drop on your own status or trust page, backed by a live, independently-verifiable endpoint.

public trust badge
GET /api/verify/badge/{org_id}.svg   # the embeddable image
GET /api/verify/badge/{org_id}       # signed JSON behind it — no auth
# → { "governed":true, "signed_runs_total":142, "active_agents":6,
#     "bitcoin_anchor_status":"bitcoin_confirmed", "signature":"ed25519:...",
#     "verified":true }

<a href="https://permisyn.com/badge/{org_id}">
  <img src="https://api.permisyn.com/api/verify/badge/{org_id}.svg" alt="Governed by Permisyn" />
</a>

The human-readable version at /badge/ORG_ID shows the same stats plus the raw signature and public key, so a visitor who clicks the badge lands somewhere that explains — and lets them independently check — what it means.

ComplianceTeam+

Compliance is a living, signed attestation generated from your real enforcement log — not a questionnaire. Readiness for EU AI Act, SOC 2, and HIPAA is computed from actual evidence (signed runs, human controls, enforced passports, redactions) and the attestation itself is Ed25519-signed by your org key, so an auditor can verify it independently.

Framework access is included on both paid plans. Solo and Startup can generate EU AI Act, SOC 2, and HIPAA attestations from their signed evidence. Trial/free accounts without a paid plan remain gated after the trial ends.

signed attestation
GET /api/control/compliance/attestation?framework=eu_ai_act
# → { "framework":"eu_ai_act", "readiness_pct":100.0,
#     "controls":[{ "control":"Art.12 Record-keeping", "status":"satisfied", ... }],
#     "signature":"ed25519:...", "public_key_pem":"..." }
# frameworks on Solo and Startup: eu_ai_act | soc2 | hipaa

AI Bill of MaterialsTeam+

A signed manifest of every model and provider your agents have actually called — call counts, first-seen, last-seen — built from the real signed enforcement log, the same way an SBOM (software bill of materials) is built from real dependency data instead of a self-reported list. Useful for the same reason EU AI Act and NIST AI RMF increasingly expect a model inventory: it's something you can hand to a regulator or a customer's security team without hand-maintaining a spreadsheet.

signed AI-BOM
GET /api/reports/bom
# → { "entries":[{ "provider":"openai", "model":"gpt-4o-mini",
#                  "call_count":142, "first_seen":"...", "last_seen":"..." }],
#     "distinct_models":3, "signature":"ed25519:...", "public_key_pem":"..." }

Team+ (same tier as compliance attestations) — Free returns 403 plan_feature_locked. Rendered live on the Compliance page.

Auditor scoped portalTeam+

Evidence is only a moat if you can actually hand it to someone outside your team. Create a revocable, expiring, scoped link — not an account, not dashboard access — that unlocks a narrow read-only view for an external auditor or regulator: compliance attestation, AI Bill of Materials, and metadata-only receipts. Prompt content, billing, and agent management are never reachable through this link.

create + share an auditor link
POST /api/control/auditor-tokens
{ "label":"PwC Q3 audit", "expires_in_days":90 }
# → { "token":"aud_...", "url":"https://permisyn.com/auditor/aud_...", ... }
# shown once — copy it now, same as an API key

GET /api/auditor/{token}/summary    # public, no auth — compliance + BOM + anchor status
GET /api/auditor/{token}/receipts   # public, no auth — metadata only, no prompt content

Revoked or expired tokens return 404, not 403 — a stale or guessed link can't be distinguished from one that never existed. Manage links from Fleet Control.

Dependency Graph

Every team, agent, provider, and model your org has actually called, in one connected picture, plus a same-tier overlay of real cryptographically-verified agent-to-agent call chains — instead of piecing it together across Agents, Usage, and one-off /verify/chain/{chain_id} lookups. GET /api/dashboard/graph returns the whole thing pre-aggregated: nodes and edges, weighted by real call volume and cost.

fetch the graph
GET /api/dashboard/graph?window_days=30
# → { "window_days": 30, "has_chain_data": true, "scan_cap_hit": false,
#     "nodes": { "teams":[...], "agents":[...], "providers":[...], "models":[...] },
#     "edges": {
#       "team_agent":[{ "source":"team:engineering", "target":"agent:agt_...", "calls":300, "total_cost_usd":6.1 }],
#       "agent_provider":[...], "provider_model":[...],
#       "chain":[{ "source":"agent:agt_abc", "target":"agent:agt_def", "hops":14 }]
#     } }

Two different time bases, by design: team_agent edges are all-time, read straight off each agent's existing running totals — no scan. agent_provider, provider_model, and the chain overlay are a capped, window_days-scoped scan (1–90 days, default 30) of the same signed run history everything else in this product is built from. The chain overlay is empty (has_chain_data: false) unless you've actually used multi-agent chain of custody headers — most orgs will, and that's fine, it just means one less overlay on an otherwise complete graph. Free on every plan. Rendered live on Dependency Graph, hand-rolled SVG with no charting library, matching the rest of this product's dataviz.

Edge deploymentNot in public plans

For strict data residency you can split the planes: run enforcement at your own edge and keep Permisyn as the control plane. A sidecar asks POST /api/control/authorize for a verdict, then calls the provider directly only if allowed — so prompt and completion data never leave your network. A single-file reference sidecar ships in edge/permisyn_edge.py.

Edge/sidecar access — POST /api/control/authorize — is not included in the public Solo or Startup plans. Hosted proxy authorization remains the supported public product.

edge authorize (no forwarding)
POST /api/control/authorize
{ "agent_name":"edge-agent", "model":"gpt-4o-mini",
  "provider":"openai", "prompt_hash":"sha256:...",
  "prompt_chars":240 }
# → { "decision":"allow" | "block" }
# allow → your sidecar calls the provider directly; data stays local.
# block → based on declared, deterministic rules only (org freeze,
#         kill switch, agent passport) — never a pattern-matched guess.

Production checklist

Use live and test keys separatelyKeep dev traffic on psyn_test_ keys so dashboard data and cost controls do not mix.
Name every agentSet X-Permisyn-Agent from service or workflow identity, not from a free-form user prompt.
Attach user and teamSend X-Permisyn-User and X-Permisyn-Team on every call so usage rolls up by accountable owner.
Set purposePurpose makes evidence understandable to auditors.
Configure cost capsStart with conservative X-Permisyn-Max-Cost-USD values on high-volume workflows.
Wire alert webhooksSend anomaly and cost-cap-warning events into Slack, Teams, Jira, or Linear.
Rotate exposed keysRotate the Permisyn key if a combined token or deployment secret is exposed.
Export evidence regularlyUse reports and proof endpoints for audits, incident review, and customer trust packets.

Rate limits

There are three separate ceilings, not one — they answer three different questions, and each returns a different error. All three are visible live on your own Overview page (the "Live Status" card) and via GET /api/status/capacity.

LayerAnswersWindowLimitError
Per-IP baseline“Is this IP address sending requests too fast?” — anti-abuse only, same for every plan and every route (except /health, /docs, /redoc, /openapi.json).Rolling 60s, resets every minute600 req/min, per IP429 rate_limit_exceeded
Plan-tiered ceiling“Is this org sending proxy calls too fast?” — a per-minute burst guard, scoped to your org (not your IP), only on /v1/... proxy calls.Rolling 60s, resets every minuteSolo: 60 req/min · Startup/Enterprise: no extra ceiling429 plan_rate_limited
Monthly quota“How many authorized calls has this org made this month, total?” — a hard cap, unrelated to speed.Calendar monthSolo: 2,000/mo · free trial: 10,000/mo (15 days) · Startup/Enterprise: unlimited402 plan_limit_reached

"Per minute" is a rolling 60-second window, not a running total — it counts only what happened in the current window and resets to zero the moment that window rolls over, the same as a speed limit rather than an odometer. Sending 1 request every couple of seconds all day never comes close to either per-minute ceiling; it only matters when a burst of calls (a tight retry loop, a mis-set concurrency setting) lands inside the same 60-second window. A blocked 429 always includes a Retry-After header — wait that many seconds and retry.

The per-IP baseline and the plan-tiered ceiling are independent and layered: a proxy call is checked against the per-IP baseline first, then — only if you're on a plan with its own rate_limit_per_min (Solo today) — against the org-scoped ceiling. Whichever one is hit first blocks the call. A plan_rate_limited denial is still a real, signed receipt — independently verifiable at /api/verify/{run_id}, exactly like an allowed call.

API reference

POST/v1/chat/completionsAuthorized OpenAI-compatible chat endpoint
POST/v1/completionsAuthorized OpenAI-compatible completions endpoint
POST/v1/messagesAuthorized Anthropic-style messages endpoint
GET/api/agentsGoverned agent roster
PUT/api/agents/{id}/passportDeclare + sign an agent passport
GET/api/runsSigned audit trail — filterable by ?user= and ?team=
GET/api/dashboard/usageUsage rolled up by user, team, and agent
POST/api/agents/{id}/killHalt an agent at proxy level
GET/api/verify/{run_id}Public — verify a run (no auth, no secret)
GET/api/verify/passport/{agent_id}Public — verify a passport, incl. expiry, region scope, least-privilege score, and signed change history
GET/api/verify/chain/{chain_id}Public — verify a multi-agent chain of custody
GET/api/orgs/{org_id}/pubkeyPublic — your Ed25519 verification key
GET/api/verify/badge/{org_id}.svgPublic — embeddable signed trust badge
POST/api/control/freezeFreeze all AI for the org
POST/api/control/unfreezeResume AI traffic for the org
GET/api/control/statusFreeze state + vaulted provider list
POST/api/control/agents/{id}/revokeInstant kill + passport off for one agent
POST/api/control/header-profilesCreate a shareable team header profile — provisions the named agent + signs its passport
GET/api/control/header-profilesList this org's shareable team header profiles
POST/api/control/header-profiles/{id}/onboarding-linkGenerate a one-click, email-verified onboarding link for a profile
GET/api/control/header-profiles/{id}/onboarding-linksList onboarding links generated for a profile
POST/api/control/onboarding-links/{id}/revokeRevoke an onboarding link
GET/api/onboard/{token}Public — onboarding link summary (no identity yet)
POST/api/onboard/{token}/requestPublic — submit name/email, triggers a confirmation email
GET/api/onboard/{token}/confirm/{email_token}Public — confirms email, returns the finished snippet
PUT/api/control/provider-keysStore a provider key in the vault
POST/api/control/authorizeEdge verdict without forwarding
GET/api/control/compliance/attestationSigned compliance attestation
POST/api/control/anchorSeal + anchor the transparency log now
GET/transparency/anchorsPublic — list transparency log anchors
GET/transparency/anchor/{id}/verifyPublic — live re-check an anchor against the real Bitcoin chain
GET/api/reports/bomSigned AI Bill of Materials
POST/api/control/auditor-tokensCreate a scoped auditor/regulator link
GET/api/auditor/{token}/summaryPublic — auditor portal (no auth)
GET/api/auditor/{token}/receiptsPublic — metadata-only receipts for the auditor portal
GET/api/reports/governanceGovernance report export

Errors

Most Permisyn API errors have a stable code, message, docs_url, and status (e.g. agent_not_found, plan_limit_reached, plan_feature_locked). The proxy's own pre-flight blocks — passport_violation, passport_expired, ai_frozen, cost_cap_exceeded, agent_killed, header_profile_not_found, no_upstream_key, and invalid_api_key — carry just type/code/message, no docs_url or in-body status. The seven that represent an actual authorization decision on a real call — passport_violation, passport_expired, ai_frozen, cost_cap_exceeded, agent_killed, header_profile_not_found, and plan_rate_limited (see Rate limits) — also include error.run_id, since each one is itself a signed receipt. no_upstream_key and invalid_api_key fire before any agent identity resolves, so there is no run to attach — they never carry run_id. Setting a passport's region_scope without allowed_providers returns 422 region_scope_requires_providers at save time, before any call is ever affected.

Plan-gated capabilities use two more codes: 402 plan_limit_reached (a numeric cap — agents, monthly runs, vaulted provider keys, team seats) and 403 plan_feature_locked (a capability your plan doesn't include at all — e.g. the HIPAA attestation or edge/sidecar access). Both messages link to pricing to upgrade.