Using ACPpotamia.
ACPpotamia sits between your agents and the model provider. You keep your SDK and your provider account; you change the base URL and add two headers.
Quickstart
- Create an account and a key on the API keys page. Keys start with
acp_. - Point your SDK at
https://acp.examopotamia.comand send your provider key inX-Upstream-Key. - Name each agent with
X-ControlPlane-Agent-Id. Calls then show up in the console within a few seconds.
OpenAI SDK (TypeScript)
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://acp.examopotamia.com/v1",
apiKey: process.env.ACP_KEY,
defaultHeaders: {
"X-Upstream-Key": process.env.OPENAI_API_KEY,
"X-ControlPlane-Agent-Id": "support-bot",
},
});
const reply = await client.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: "Hello" }],
});
OpenAI SDK (Python)
import os
from openai import OpenAI
client = OpenAI(
base_url="https://acp.examopotamia.com/v1",
api_key=os.environ["ACP_KEY"],
default_headers={
"X-Upstream-Key": os.environ["OPENAI_API_KEY"],
"X-ControlPlane-Agent-Id": "support-bot",
},
)
Anthropic SDK (TypeScript)
The Anthropic SDK sends your Anthropic key as x-api-key, which ACPpotamia passes through. Put the ACPpotamia key in X-ACP-Key.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
baseURL: "https://acp.examopotamia.com",
apiKey: process.env.ANTHROPIC_API_KEY,
defaultHeaders: {
"X-ACP-Key": process.env.ACP_KEY,
"X-ControlPlane-Agent-Id": "research-agent",
},
});
const message = await client.messages.create({
model: "claude-opus-5-5",
max_tokens: 4096,
messages: [{ role: "user", content: "Hello" }],
});
Anthropic SDK (Python)
import os
import anthropic
client = anthropic.Anthropic(
base_url="https://acp.examopotamia.com",
api_key=os.environ["ANTHROPIC_API_KEY"],
default_headers={
"X-ACP-Key": os.environ["ACP_KEY"],
"X-ControlPlane-Agent-Id": "research-agent",
},
)
Streaming works unchanged on both APIs. ACPpotamia proxies POST /v1/chat/completions and POST /v1/messages.
Request headers
| Header | Meaning |
|---|---|
Authorization: Bearer acp_… or X-ACP-Key | Your ACPpotamia key. Required. |
X-Upstream-Key | Your provider key. Passed through and never stored. Anthropic calls may use x-api-key instead. |
X-ControlPlane-Agent-Id | Agent name for the console and audit log. Default default. |
X-ControlPlane-Session-Id | Scope for loop stop and the kill switch, such as one ticket or one job. Defaults to the agent id. |
X-ControlPlane-Tenant-Id | Your end-customer the call is for, so they get their own budget and rate limit. See limits per tenant. |
X-ControlPlane-FailMode | open or closed. See fail mode. |
X-ControlPlane-Upstream: custom | Send the call to your team's custom endpoint. |
Ids may use letters, digits and . _ : @ -, up to 128 characters.
Response headers
| Header | Meaning |
|---|---|
X-ControlPlane-Decision | allow, block, fail-open or fail-closed. |
X-ControlPlane-Block-Reason | Why a call was blocked. See errors. |
X-ControlPlane-Budget-Remaining-USD | Budget left after this call's reservation. |
X-ControlPlane-Overhead-Ms | Time ACPpotamia spent before sending the call on. |
X-ControlPlane-Request-Id | The id of this call in the audit log. |
Errors
Errors use one JSON shape that both the OpenAI and Anthropic SDKs understand: {"type":"error","error":{"type":"…","code":"…","message":"…"}}. Provider errors are passed through unchanged.
| Status | Code | What to do |
|---|---|---|
| 401 | missing_api_key, invalid_api_key, expired_api_key | Check the acp_ key, or create a new one. |
| 401 | missing_upstream_key | Send your provider key in X-Upstream-Key. |
| 402 | budget_exhausted, token_limit_reached | Raise the limit on the Limits page, or wait for the 1st of the month (UTC). |
| 402 | key_budget_exhausted, tenant_budget_exhausted, session_budget_exhausted | That key, tenant or session reached its own budget. Raise it, or wait for the 1st of the month (sessions: start a new one). |
| 429 | key_rate_limited, tenant_rate_limited, session_rate_limited | Wait for the seconds in Retry-After. |
| 429 | rate_limited | Wait for the number of seconds in Retry-After. |
| 429 | loop_detected, session_killed, team_killed | Fix the loop, then revive the session or team. Retrying does not help. |
| 403 | model_not_allowed | The model is outside your team's or key's model policy. Use an allowed model. |
| 403 | custom_upstream_unavailable, admin_scope_required | Your plan or key does not include this. |
| 413 | request_too_large | Requests can be up to 20 MB. |
| 502 | upstream_unreachable | The provider could not be reached. Nothing was charged. Retry. |
| 503 | control_plane_unavailable | Limits could not be checked and fail mode is closed. Retry after Retry-After. |
Budgets
Before a call is sent, ACPpotamia reserves its estimated cost from the team's monthly budget: the input, plus the output allowed by max_tokens (or the "output reserved per call" setting when the call sets none). When the call finishes, the reservation is replaced by the usage the provider reported.
- If the remaining budget cannot pay for the full output,
max_tokensis lowered to what it can pay for. - If a streamed response runs past what the budget allows, the stream ends with an error event in the provider's format.
- When nothing is left, calls get a 402 until the budget is raised or the month resets (the 1st, UTC).
Input tokens are estimated before the call (about four characters per token). A team can therefore go slightly over its budget, by the gap between estimate and real count of calls already running when the budget runs out. Costs use list prices per model. Models we do not recognise are charged at $10 in and $50 out per million tokens, so an unknown model never drains a budget unseen.
Loop stop
Each call's last three messages are compared with the session's recent calls. Timestamps, numbers and ids are ignored in the comparison. If a session sends the same request, or one at least as similar as your threshold, the set number of times within the time window, the session is stopped and further calls get 429 loop_detected.
Defaults: 5 matching requests within 60 seconds, similarity 0.92. Change them on the Limits page. Use a separate X-ControlPlane-Session-Id per task so one task's loop stops only that task.
Limits per key, tenant and session
On top of the team's budget and rate limit, every call is also checked against three narrower limits. All of them are checked and reserved together before the call is sent, so many calls arriving at once can never overspend any of them.
| Limit | Applies to | Set it |
|---|---|---|
| Per API key | Monthly budget and requests a minute for one key, for example a CI key or a single product. | Console → API keys → Set limits |
| Per tenant (Pro, Enterprise) | Each of your customers, named by X-ControlPlane-Tenant-Id. A default for every tenant, plus each customer's own limit when needed. A budget of 0 blocks a tenant. | Console → Limits and Tenants, or the Tenant API |
| Per session | Every session (one task, X-ControlPlane-Session-Id): requests a minute and a maximum spend over the session's life. | Console → Limits |
Key and tenant budgets reset on the 1st of each month (UTC). A call blocked by one key, tenant or session does not affect the others.
curl https://acp.examopotamia.com/v1/chat/completions -H "Authorization: Bearer acp_..." -H "X-Upstream-Key: $OPENAI_API_KEY" -H "X-ControlPlane-Tenant-Id: customer_4821" -H "X-ControlPlane-Session-Id: ticket-99812" -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}'
Tenant API
Manage your customers' limits from your own code, for example when a customer signs up or changes plan. Use a key with admin access. Pro and Enterprise.
| Call | Does |
|---|---|
PUT /v1/controlplane/tenants/{tenantId} | Body {"monthlyBudgetUsd": 25, "requestsPerMinute": 60}. Use null for no limit. Takes effect on the next call. |
GET /v1/controlplane/tenants/{tenantId} | This month's spend, money held for calls in progress, requests, tokens and the limits that apply. |
GET /v1/controlplane/tenants | All tenants by spend. Optional ?search= and ?limit=. |
DELETE /v1/controlplane/tenants/{tenantId} | Removes the tenant's own limit; the default applies again. |
curl -X PUT https://acp.examopotamia.com/v1/controlplane/tenants/customer_4821 -H "Authorization: Bearer acp_your_admin_key" -H "content-type: application/json" -d '{"monthlyBudgetUsd": 25, "requestsPerMinute": 60}'
Organizations
An organization groups several teams, such as departments, while each keeps its own keys, limits and billing. A team owner creates one on Organization in the console, and that team becomes its first member.
- One view: spend, calls and blocked calls for every team, a 30-day chart and recent calls across teams.
- One model policy: applies to every team on top of their own rules, including teams added later.
- One kill switch: stops every agent in every team at once; one click revives them.
- One export: a CSV, JSONL or raw archive covering all teams, for compliance.
- Roles: viewers see everything; admins can also stop teams, set the model policy, export and join any team; owners also manage people. Every change is kept in the organization log.
Kill switch
Stop one session from the Agents page, or every agent on the team from the Overview page. Calls already streaming finish; new calls get a 429 until you revive. Reviving a session clears its loop history.
Fail mode
If ACPpotamia cannot check your limits within 1.5 seconds, fail mode decides what happens. Closed blocks the call with a 503. Open sends it unchecked and records it as "sent unchecked" in the audit log. Free plans always use closed. A caller can always ask for closed; asking for open works only when your team allows it on the Limits page. Your key is always checked, whatever the fail mode.
Custom endpoints
Pro and Enterprise teams can add one HTTPS base URL on the Limits page, for example a self-hosted model server that speaks the OpenAI or Anthropic API. Send X-ControlPlane-Upstream: custom to use it. The address comes only from your team settings, never from the request.
Admin API
Keys with admin access can manage agents without the console.
| Call | Does |
|---|---|
GET /v1/controlplane/state | Spend, held reservations, stopped sessions. |
POST /v1/controlplane/kill | Body {"sessionId":"…","reason":"…"}. Without sessionId, stops the whole team. |
POST /v1/controlplane/revive | Body {"sessionId":"…"}, or {} for the whole team. |
GET /v1/controlplane/policy/export | Enterprise: your effective limits as JSON. |
Webhooks
Enterprise teams can receive a POST when a session or the team is stopped. Events are session.killed and team.killed. Each delivery carries x-acppotamia-signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of t + "." + body with your signing secret. Reject deliveries older than five minutes.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(body: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.${body}`).digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""));
}
Audit logs
Every call, blocked ones included, is recorded with time, agent, session, model, tokens, cost, outcome and overhead. Records appear in the console within a few seconds and are kept for 365 days. Pro and Enterprise teams can also keep the first 64 KB of each request and response, turned on from the Limits page. Download them yourself any time; see Export logs.
Test keys
For development and CI, create a key with Test key ticked. It starts with acp_test_. Every check runs exactly as for a real key (limits, loop stop, model policy, kill switch, audit), but ACPpotamia answers with a mock response in the provider's own format, streamed or not. No provider key is needed, no provider is called, and nothing is charged.
| Header | Effect |
|---|---|
X-ControlPlane-Mock-Content | The text the mock replies with. |
X-ControlPlane-Mock-Status | Return that provider error instead, such as 429, 500 or 529, to test retry logic. |
Test calls appear in Activity marked Test, cost $0 and do not use the team's budget or tokens. Responses carry x-controlplane-test: true.
Export logs
Owners and admins can download call records from Activity → Export for compliance, filtered by agent, tenant or outcome:
- CSV for spreadsheets, or JSONL with one record per line, up to 366 days per file.
- Raw daily archive (
.jsonl.gz), up to 31 days per file, with request and response text when payload logging is on.
Security tools and SIEM pipelines can pull the same files with a key that has admin access:
curl "https://acp.examopotamia.com/v1/controlplane/audit/export?format=jsonl&from=2026-11-01&to=2026-11-30" -H "Authorization: Bearer acp_your_admin_key" -o calls.jsonl
Parameters: format (jsonl, csv or raw), from and to (UTC dates, inclusive), and optional agent, tenant and status.
Model access
Pro and Enterprise teams choose which models may be called, on Limits → Model access: all models, only a list, or all except a list. Patterns use * as a wildcard, such as claude-*, gpt-5* or *-mini. One API key can be narrowed further on the API keys page. Organization, team and key rules all apply together. A refused call gets HTTP 403 model_not_allowed and never reaches the provider.
Plan limits
| Free | Pro | Enterprise | |
|---|---|---|---|
| Requests a minute | 20 | 600 | 10,000 |
| Monthly budget ceiling | $10 | $50,000 | None |
| Requests remembered for loop stop | 16 | 64 | 256 |
| Request size | 20 MB | 20 MB | 20 MB |
Getting help
Write to acp@examopotamia.com with the X-ControlPlane-Request-Id of the call. Check service status first if many calls fail at once.