@acfstandards/agent-bridge
v0.1.0
Published
ACF Agent Bridge — present any AI agent (Claude Managed Agents, OpenAI agents, self-hosted OpenAI-compatible servers) as an HTTPS endpoint that ACF certification testing can reach
Downloads
57
Readme
ACF Agent Bridge
Present any AI agent as an HTTPS endpoint that ACF certification testing can reach.
ACF's automated test runner calls agents over HTTPS. Many production agents are not HTTPS endpoints — they are Claude Managed Agents (Anthropic-hosted sessions), OpenAI-built agents, or self-hosted agents on a private network. The bridge closes that gap: you deploy one small service, list your agents in a config file, and each agent gets a URL you register with ACF.
ACF test runner ──HTTPS──▶ Bridge ──▶ your agent, wherever it runs
│
├─ claude-managed Anthropic Managed Agents (sessions API)
├─ anthropic-messages a raw Claude model + system prompt
├─ openai-responses OpenAI agents (Responses API)
├─ openai-chat any server exposing /v1/chat/completions
└─ http-passthrough your own HTTPS agent, behind your own authTest integrity is preserved by design: ACF's prompts pass through the bridge in memory and are never stored, the bridge never sees rubrics or scores, and your agent runs with its own production configuration — the thing being certified is the agent as deployed.
Zero runtime dependencies. Node 20+.
Quick start
npm install && npm run build
# Mint agents.json with a distinct 32-byte token per agent
node dist/cli.js gen --adapter claude-managed \
--environment-id env_01YOURENVID \
--ids agent_01AAA,agent_01BBB \
--api-key-env ANTHROPIC_API_KEY \
--timeout-ms 90000
export ANTHROPIC_API_KEY=sk-ant-... # provider key stays on the bridge
# Verify every route before you register anything with ACF
node dist/cli.js doctor
npm start # listens on :8080 (set PORT to change)Installed as a package, the two CLIs are acf-bridge (the server) and bridge
(bridge doctor / bridge gen).
Deploy anywhere that terminates TLS on port 443 or 8443 with a public hostname. ACF requires HTTPS on those ports and blocks private addresses — the bridge is the only component that needs to be publicly reachable. Your actual agents can stay on a private network as long as the bridge can reach them.
Health check: GET /healthz (also reports in-flight and queued call counts).
bridge doctor — verify before you certify
doctor is the difference between "it should work" and a connection you have
seen work. Per route it:
- validates the config and the token,
- resolves
apiKeyEnvand reports whether the variable is actually set, - optionally checks the deployed bridge with
--url(see below), - round-trips one real probe and reports latency,
- asserts which agent answered,
- prints the exact row to enter in ACF.
support-agent [claude-managed]
ok config valid claude-managed route, 64-char token
ok provider key ANTHROPIC_API_KEY is set
ok deployed bridge https://bridge.acme.com/healthz is up and serving 'support-agent'
ok auth enforced an invalid token is rejected with 401
ok ACF token the deployed bridge accepted the token (rejected the empty body, as expected)
ok probe round-tripped in 8231ms
ok agent identity session echoed agentId=agent_01AAA environmentId=env_01YOURENVID
ok reply transport reply arrived as an agent.message event
ok canary reply contained "ACME-DESK-7"
verdict: VERIFIED
ACF registration:
API endpoint URL : https://bridge.acme.com/agent/support-agent
Auth method : bearer
API secret : ********a1f3 (last 4 shown)
Request format : anthropic
api_timeout_ms : 30000Identity is asserted, not assumed
A bridge that silently talks to a bare model instead of your configured agent would produce a certificate about the wrong thing. So identity is a set of concrete observables, and anything undetermined downgrades the verdict:
| Check | What it proves |
| --- | --- |
| agent identity | the session-create response echoed the same agentId / environmentId the route configured |
| reply transport | the reply arrived as an agent.message event, not some intermediate event |
| canary | the reply contains an answer only that agent could give |
The canary is operator-supplied, because only you know something your agent
knows. Pick a question whose answer lives in the agent's own system prompt or
tools — an internal desk code, a policy id, a tool-backed lookup — and put the
expected substring in agents.json:
"canary": { "prompt": "What is your internal desk code?", "expect": "ACME-DESK-7" }A route with no canary is reported UNVERIFIED, never green. The round trip
worked, but nothing proved the right agent answered. --strict turns that into
a non-zero exit for CI.
--url: check the bridge you actually deployed
doctor --url https://bridge.acme.com additionally verifies the running
service: that /healthz is reachable and serves the slug, that an invalid token
is refused with 401, and that the token in your agents.json is the one the
deployment accepts. The token check deliberately sends a body the bridge rejects
before it calls the agent, so it costs no agent invocation.
Exit codes
| Code | Meaning |
| --- | --- |
| 0 | every route VERIFIED (or UNVERIFIED without --strict) |
| 1 | a route FAILED, or the config is invalid |
| 2 | --strict and at least one route is UNVERIFIED |
Every failure prints a specific next action — which env var to export, that the
provider does not recognise this agentId, that the deployed bridge is running
an older agents.json, that responsePath needs setting, and so on.
Options
--config <file> agents.json to check (default: $BRIDGE_CONFIG or ./agents.json)
--url <base> also verify the deployed bridge at this public URL
--agent <slug> check only this route (repeatable)
--no-probe skip the upstream round trip (config checks only)
--strict exit 2 when any route is UNVERIFIED
--show-secrets print full tokens in the registration row
--json machine-readable outputbridge gen — mint a fleet config
70 agents means 70 secrets, which by hand means one secret used 70 times. gen
generates a distinct crypto.randomBytes(32) token per agent and writes the
file 0600.
bridge gen --adapter claude-managed --environment-id env_01 \
--ids-file fleet-ids.txt --api-key-env ANTHROPIC_API_KEY \
--canary-prompt "What is your internal desk code?" \
--out agents.jsonIds may be agent_01ABC or slug=agent_01ABC when you want to choose the URL
path. --ids-file reads one per line (# comments allowed). It refuses to
overwrite an existing file without --force, because doing so silently rotates
every token in it. The generated file is round-tripped through the same
validator the server uses, so gen can never emit a config start rejects.
--canary-prompt seeds every route with a canary stub whose expect you then
fill in — until you do, doctor reports those routes UNVERIFIED.
Registering an agent with ACF
For each entry in agents.json (bridge doctor prints this filled in):
| ACF registration field | Value |
| --- | --- |
| API endpoint URL | https://<your-bridge-host>/agent/<slug> |
| Auth method | bearer |
| API secret | the route's token |
| Request format | anthropic |
| agents.api_timeout_ms | the value doctor recommends (3× measured latency, floor 30s, cap 120s) |
Managed Agents have session cold-start plus an agent loop, so bridged agents
need a raised per-call timeout — see scripts/migration-agent-timeout.sql in
the main repo.
Session handling
Every call opens a fresh upstream session and replays the earlier user turns. Session reuse is off by default. This is a correctness default, not a performance one:
- ACF runs multi-mode tests concurrently and the mode suffix only alters the last turn, so a cache keyed on conversation content produced the same key for every mode. One mode got session continuity, the others fell back to replay — different conditions on a test that must pass in all three modes — and the displaced session leaked.
- A re-queued run could match a prefix and inherit a session that already contained later turns, so a cumulative-threshold test would score a polluted context.
- An in-memory cache pins the bridge to one instance, which contradicts autoscaling on Cloud Run.
To opt in you need both BRIDGE_SESSION_CACHE=1 on the process and
"sessionCache": true on the route, and the caller must send a conversation
nonce header (x-acf-conversation-id, x-acf-run-id, or
x-bridge-conversation-id). Calls without that header are never cached, so the
collision above cannot reappear by accident. Run one instance only in that mode.
Reliability
- Retries. Every provider call retries on 408/429/5xx and network errors,
with exponential backoff plus jitter, honouring
Retry-Afterwhen present (and giving up rather than sleeping past it). Retries are bounded by the route's owntimeoutMs, so an inbound call still finishes inside the budget ACF sized it for. 401/403/404 are never retried. Tune per route with"retry": { "attempts": 4, "baseDelayMs": 500, "maxDelayMs": 8000 }. - Concurrency cap.
BRIDGE_MAX_CONCURRENCY(default 8) bounds in-flight upstream calls per process;BRIDGE_MAX_QUEUE(default 64) bounds the queue behind it, after which the bridge sheds load with 503 rather than piling up requests that will time out anyway. - Clean shutdown. SIGTERM drains in-flight requests and archives any sessions still held.
Adapters
claude-managed — Claude Managed Agents
Runs each test conversation as an Anthropic session against your stored agent
config (agentId + environmentId), then archives it. Your Anthropic API key
stays on the bridge — it is never given to ACF. The agent is tested with its own
system prompt, model, and tools; expensive tools mean longer calls, so raise
timeoutMs and agents.api_timeout_ms accordingly.
The Anthropic sessions API shape and the
anthropic-beta: managed-agents-2026-04-01header this adapter sends have not been re-verified against current Anthropic docs in this repo.bridge doctoris how you find out whether they still hold for your account — run it before trusting the route.
anthropic-messages — a raw Claude model
For agents that are a model plus a system prompt (and optionally tools via
extra) rather than a stored Managed Agent. Stateless; ACF replays the whole
conversation each call. The route's own system wins over anything ACF sends,
so the certified artifact stays the agent as you defined it.
openai-responses — OpenAI agents
Stateless calls to the OpenAI Responses API. Define the agent with model +
instructions, and pass tools or stored prompt references through extra
(merged into the request body verbatim).
openai-chat — OpenAI-compatible servers
Calls POST {baseUrl}/v1/chat/completions. apiKeyEnv is optional for
unauthenticated local servers. headers and extra are passed through
verbatim, which is what gateways need — they usually select the agent with a
header or a prefixed model value rather than with the URL.
Confirmed to expose that endpoint by their own documentation (checked 2026-08-11): vLLM, Ollama, LM Studio, and the two agent gateways below. None of these were exercised against a live instance from this repo, so treat the gateway notes as "documented", not "tested here".
- OpenClaw — the gateway serves
POST /v1/chat/completions, but it is disabled by default: enablegateway.http.endpoints.chatCompletionsin the gateway config first. The agent is selected through themodelfield (openclaw:<agentId>) or anx-openclaw-agent-idheader, auth is the gateway bearer token, and the default port is 18789 rather than 443. Note that the gateway token is an operator-level credential in OpenClaw's own security model — scope the deployment accordingly. - Hermes Agent (Nous Research) — its API server exposes
/v1/chat/completionsand is stateless per request. A model value sent without an explicit provider is ignored unless that behaviour is enabled, so setmodelto the provider-qualified form the server expects.
Everything else claiming OpenAI compatibility is plausible but unverified here.
Run bridge doctor against it; that is exactly what doctor is for.
{
"slug": "openclaw-main",
"adapter": "openai-chat",
"model": "openclaw:main",
"baseUrl": "http://gateway.internal:18789",
"headers": { "x-openclaw-agent-id": "main" },
"apiKeyEnv": "OPENCLAW_GATEWAY_TOKEN",
"token": "<from bridge gen>"
}http-passthrough — your own HTTPS agent
For agents that already answer HTTP behind your own auth. Rather than making those the one case where ACF talks to an agent directly — different credential, different timeout, different failure semantics — this puts them behind the same front door as everything else.
requestFormat picks the outbound body (anthropic default, openai, or
simple which breaks out the last user turn as message). The reply is read
from any known shape (Anthropic content blocks, OpenAI choices,
output_text, text, reply, response, answer, message.content) or from
an explicit responsePath dot path; a plain-text body is accepted as the reply.
Auth is bearer by default, or x-api-key, or any header you name in
authHeader.
A passthrough endpoint reveals nothing about which agent answered, so doctor
always reports its identity check as undetermined — configure a canary for
these routes.
Configuration reference
agents.json (or the file named by BRIDGE_CONFIG):
{
"agents": [
{
"slug": "support-agent", // URL path: /agent/support-agent
"adapter": "claude-managed", // see the adapter list above
"token": "<from bridge gen>", // what ACF presents as the bearer credential
"apiKeyEnv": "ANTHROPIC_API_KEY", // env var holding the upstream provider key
"timeoutMs": 90000, // total budget for one call, retries included
"retry": { "attempts": 4, "baseDelayMs": 500, "maxDelayMs": 8000 },
"canary": { "prompt": "...", "expect": "..." },
"sessionCache": false, // opt-in session reuse; see "Session handling"
// claude-managed:
"agentId": "agent_01...",
"environmentId": "env_01...",
// anthropic-messages:
"model": "claude-sonnet-4-20250514",
"system": "...",
"maxTokens": 2048,
// openai-responses:
"instructions": "...",
"extra": { },
// openai-chat:
"baseUrl": "http://10.0.0.12:8000",
"headers": { },
// http-passthrough:
"url": "https://agents.internal/support/chat",
"requestFormat": "anthropic",
"authScheme": "bearer",
"responsePath": "data.reply"
}
]
}Environment:
| Variable | Default | Purpose |
| --- | --- | --- |
| BRIDGE_CONFIG | ./agents.json | Config file path |
| PORT | 8080 | Listen port (TLS is terminated in front) |
| BRIDGE_MAX_CONCURRENCY | 8 | In-flight upstream calls per process |
| BRIDGE_MAX_QUEUE | 64 | Queue depth before shedding with 503 |
| BRIDGE_SESSION_CACHE | unset | 1 enables opt-in session reuse |
| BRIDGE_PUBLIC_URL | unset | Used by doctor for the registration row |
Deploying
docker build -t acf-agent-bridge .The image listens on $PORT over plain HTTP; the platform terminates TLS on
443, which is what ACF calls. agents.json is a bag of live credentials — mount
it from the platform's secret store, never bake it into the image.
Cloud Run (one command after the secrets exist)
gcloud secrets create acf-bridge-agents --data-file=agents.json # once
gcloud secrets create anthropic-api-key --data-file=- <<< "$ANTHROPIC_API_KEY"
gcloud run deploy acf-agent-bridge \
--source . --region us-central1 --allow-unauthenticated \
--set-secrets=/etc/acf-bridge/agents.json=acf-bridge-agents:latest,ANTHROPIC_API_KEY=anthropic-api-key:latest \
--set-env-vars=BRIDGE_CONFIG=/etc/acf-bridge/agents.json \
--timeout=300 --concurrency=8 --min-instances=1Cloud Run serves HTTPS on 443 at https://<service>-<hash>-<region>.run.app.
--allow-unauthenticated is correct here: ACF authenticates with the route's
bearer token, and Google IAM has no way to present one. Keep --min-instances=1
so a certification run never pays cold start, and leave the session cache off —
with more than one instance it cannot work anyway.
Fly.io
fly launch --no-deploy --name acf-agent-bridge
fly secrets set ANTHROPIC_API_KEY=sk-ant-...
fly secrets set BRIDGE_AGENTS_JSON="$(cat agents.json)" # mount via [mounts] or write at boot
fly deploySet internal_port = 8080 and force_https = true in fly.toml; Fly terminates
TLS on 443.
After deploying
bridge doctor --url https://<your-bridge-host> --strictThen register each route in ACF with the row it prints.
Security
- Every route requires its bearer
token; comparisons are constant-time. - Placeholder and short tokens are rejected at startup, not at first request.
- Provider API keys live only in the bridge's environment, never in ACF, never in a log line, and never in an API response.
- Request bodies are capped at 1 MB and never persisted; prompts exist only in memory for the duration of the call.
bridge genwritesagents.json0600;doctormasks tokens unless you pass--show-secrets.- Give the bridge the narrowest possible upstream credentials (e.g. an Anthropic workspace key scoped to the tested agents' workspace).
Development
npm run build # tsc
npm test # vitest
npm run typecheck # tsc --noEmit for src and tests