Most app-store adapters are bring-your-own-key (BYO): each user supplies
their own API key at install (${TOKEN} headers, $APP/secrets.json). That's
perfect when the user has their own account with the partner.
But some partners give Pilot one big key and expect Pilot to share it across all users — a data or enrichment partner is the typical case. We can't ship that key inside the adapter (every install would leak it). The managed-key broker solves this: Pilot holds the one master key centrally, and every call is metered to the user who made it.
Set backend.auth: managed in pilot.app.yaml. Everything else about
publishing stays identical.
Imagine a candy shop (the partner API) that gave Pilot one golden ticket (the master key) that works forever. We want every kid (user) to get candy, but:
- We can't photocopy the golden ticket and hand one to each kid — they'd all run off with it.
- We need to know which kid took how much candy, so the bill is fair.
So Pilot builds a counter (the broker). The golden ticket stays behind the counter. Each kid wears a wristband only they can sign (their ed25519 identity). To get candy, a kid signs a slip ("it's me, I want candy"), hands it across the counter, and the counter:
- checks the wristband signature — is this really that kid? (no faking)
- checks the menu — is this candy on the allowed list? (no sneaking into the back room)
- checks their tab — are they under their limit? (no one empties the shop)
- uses the golden ticket itself to get the candy, and
- writes the cost on that kid's tab.
The kid never sees the golden ticket. The counter never lets an unsigned slip through. That's the whole system.
┌─────────────┐ signed request ┌──────────────────────────┐ master key ┌────────────┐
│ adapter │ ──────────────────► │ broker │ ─────────────► │ partner │
│ (on a user │ X-Pilot-Caller │ verify → allow → quota │ x-api-key │ API │
│ host, │ X-Pilot-Timestamp │ → inject key → meter │ ◄───────────── │ (partner) │
│ keyless) │ ◄────────────────── │ │ response └────────────┘
└─────────────┘ JSON response └──────────────────────────┘
▲ signs with the per-app │ holds PARTNER_MASTER_KEY,
│ ed25519 identity the daemon │ one durable usage row per (app, caller)
│ provisions (--identity) ▼
/gw/usage → per-(app,caller) calls + cents
This is the Pilot service-agent pattern (a host fronts a capability and the identity of each caller is authenticated) realized over signed HTTPS instead of the overlay, so the broker is a plain, deployable web service that holds the key.
The prototype trusted an X-Pilot-Caller header. Anyone could set it and bill
someone else. The production broker verifies a signature instead.
Each request carries three headers:
| Header | Meaning |
|---|---|
X-Pilot-Caller |
the caller's ed25519 public key (base64) — their identity |
X-Pilot-Timestamp |
unix seconds — bounds replay |
X-Pilot-Signature |
ed25519 signature over the canonical request |
The signed bytes (identical in the adapter and the broker) are:
METHOD \n PATH \n TIMESTAMP \n base64(sha256(BODY))
Binding the method, path, timestamp, and a hash of the body means a captured
signature can't be replayed against a different call, a different app, or a
tampered body. The broker (internal/broker/identity.go) re-derives those bytes
and checks the signature against the claimed public key; the adapter
(internal/scaffold/templates/signer.go.tmpl) produces them with the per-app
ed25519 key the daemon hands it via --identity. A golden test
(canonical_golden_test.go) and a template string-match assertion lock the two
copies together so they can't drift.
// broker side — verify (simplified)
caller, err := verify.Verify(r.Header.Get, r.Method, r.URL.Path, body)
if err != nil { http 401 } // missing / stale / tampered / forged
// adapter side — sign (simplified, generated)
ts := strconv.FormatInt(time.Now().Unix(), 10)
sig := ed25519.Sign(priv, canonical(method, path, ts, body))
req.Header.Set("X-Pilot-Caller", base64(pub))
req.Header.Set("X-Pilot-Timestamp", ts)
req.Header.Set("X-Pilot-Signature", base64(sig))internal/broker/broker.go runs the same pipeline for every managed app:
- Identity — verify the signature →
401if missing/stale/forged. - App — look up the app id in the registry →
404if unknown. - Allow-list — the method path must be declared →
403otherwise. No open proxy onto the master key. - Breaker + quota — if the partner is flapping, fail fast (
503); else the per-caller quota is checked-and-counted atomically →429if over. - Forward + meter — build a fresh request (caller headers are never carried over), inject the master key, forward, then add the partner-reported cost to that caller's tab.
- Author sets
auth: managedinpilot.app.yaml.pilot-app initgenerates a keyless adapter: it points athttps://broker.pilotprotocol.network/<app-id>, carries no secret, is grantedkey.sign, and signs every request. - Publish as usual (one repo, same flow). The submission carries
backend.auth: managed. - On approval, the publish-server derives a broker registry entry from the
submission (
internal/publish/broker_register.go) and writes the broker'sapps.json(BROKER_REGISTRY). It logs the env var name for the master key (e.g.PARTNER_MASTER_KEY). - Ops sets that env var on the broker and reloads it (
kill -HUP). The app is now live and metered. - A user installs the app like any other. They bring nothing. Every call is verified as them and metered to them.
One entry per managed app — adding an app is config, not code:
[{
"id": "io.pilot.partner",
"upstream": "https://api.example.com",
"key_env": "PARTNER_MASTER_KEY",
"auth_header": "x-api-key",
"allow": ["/enrich", "/find-email"],
"quota": 0,
"cost_field": "cost_cents",
"timeout_ms": 60000,
"breaker_threshold": 5,
"breaker_cooldown_ms": 30000
}]The master key is never in this file — only the name of the env var that holds it.
Partners authenticate differently; the broker injects the master key per the
entry's auth_style (internal/broker/inject.go):
header(default) —auth_header+ optionalauth_scheme(x-api-key: <key>orAuthorization: Bearer <key>)query—auth_param(?apikey=<key>)basic—auth_user(HTTP Basic; key-as-username by default)
quota caps the number of calls per user (→ 429). For a paid partner you
often want a dollar budget instead: give each user a fixed amount of credit
and, once spent, return 402 Payment Required. Add a credit block:
[{
"id": "io.pilot.partner",
"upstream": "https://api.example.com",
"key_env": "PARTNER_MASTER_KEY",
"auth_header": "Authorization", "auth_scheme": "Bearer",
"allow": ["/v1/messages", "/v1/calls", "/v1/calls/{id}", "/v1/numbers"],
"credit": {
"seed_credits": 5000000,
"default_cost": 0,
"cost_credits": {
"POST /v1/numbers": 3000000,
"POST /v1/calls": 50000,
"POST /v1/messages": 10000
}
}
}]- Unit is micro-dollars (1 = $0.000001), so
5000000= $5. Match thecost_creditsto the partner's real prices (here: $3 to buy a number, $0.05 a call, $0.01 a text); any call not matched costsdefault_cost. default_cost: 0makes reads free — only the priced calls debit, so polling for status/replies never burns budget.- Cost keys can be method-specific (
"POST /v1/numbers") or any-method ("/v1/usage"), and the path may be templated ("/v1/calls/{id}"). This matters when one path is both a free read and a paid write —GET /v1/numbers(list, free) vsPOST /v1/numbers(buy, $3). A method-specific key wins over any-method. - How it works: on a caller's first call the broker seeds
seed_credits, then debits the call's cost before touching the master key. A call that would overdraw is refused with402(the master key is never used). - Only successful (2xx) calls burn credit — a failed/
4xx/5xxcall is refunded, so users pay for value, not errors. - Every metered response carries
X-Pilot-Credits-Remaining(micro-dollars), so an agent always knows its balance; the402body includescredits_remaining+credits_required. credit(plain HTTP budget) andprovision(the cloud/machine credit ledger with per-user key minting) are mutually exclusive. Both need a durable store in prod (BROKER_DB) so balances survive a restart.
The remaining budget rides on the X-Pilot-Credits-Remaining header of every
metered response — but a keyless adapter only surfaces the response body, so
that header is invisible to the agent. So every managed app also gets a
dedicated, free balance method, wired automatically — no submission field needed:
-
The scaffolder injects
<ns>.balanceinto any app withauth: managed(seeConfig.Resolve). It is aGETto the broker's canonical/_pilot/balanceroute (scaffold.BalanceMetaPath==broker.pilotBalancePath), and it shows up in the manifestexposeslist and in<ns>.helplike any other method. -
The broker answers
/_pilot/balancefor any credit-metered app before the allow-list, seeds a first-seen caller so they see their full budget, and returns the ledger read without forwarding upstream, touching the master key, or debiting — a pure read that can never402, scoped to THIS caller (the shared account's pooled balance is never disclosed):{ "balance": "$1.80", "credits_remaining": 1800000, "credits_seed": 5000000, "unit": "micro_usd", "scope": "per-pilot-user" } -
provisionapps keep their own/_balanceroute instead. A managed app may additionally setcredit.balance_pathto shadow a partner's own account-balance endpoint (so calling it returns the per-user budget instead of leaking the pooled account) — that is answered by the same handler, alongside the canonical/_pilot/balance.
Playbook — any broker app that meters a budget: rely on this rather than
hand-rolling a balance endpoint. Set the credit block, and the adapter exposes
<ns>.balance for free; document in the app's app_description that agents should
call <ns>.balance (or read X-Pilot-Credits-Remaining) to check funds before a
spend op, and that spend ops return 402 with credits_remaining /
credits_required when the budget is exhausted.
# durable usage store + one master key per app:
BROKER_DB=/data/usage.db PARTNER_MASTER_KEY=sk-... \
broker -registry /registry/apps.json -addr :8099
curl localhost:8099/gw/health # liveness
curl localhost:8099/gw/usage # per-(app,caller) calls + cents
kill -HUP <pid> # reload the registry with no downtimeSee deploy/docker for the prod-like local stack and
scripts/e2e-broker.sh for a real-process,
multi-user end-to-end test.
See TODO.md for the follow-up list — durable store scaling,
rate-limiting vs. quota, the daemon identity-file contract, and per-method (vs.
per-app) timeouts.