Andrew Fribush

← All posts

Designing APIs for AI Agents as First-Class Users

· November 9, 2026 · 14 min read

The operator API of the agent platform I built had 56 verbs, and the caller I designed it for was an AI agent. I wrote the design rules for that caller and called them AIX, for agent experience. The agent arrives with zero context every session, and its priors about the API come from training data that may describe an older version. Everything it needs has to be readable from the surface at runtime, through calls that change nothing. The agent could fetch a catalog of all 56 verbs. Each error code said whether a bare retry could ever succeed. A conformance suite in CI called all 56 verbs as a caller with no standing. On its first run, when the catalog was smaller, a read-only token could write and delete tenant secrets.

Developer experience assumes a human who reads the docs once and remembers them for everything they build after. An agent reads the surface inside a context window, where every response token competes with its actual task, and the next session starts empty. My design rule was that each response include what a developer would have learned from the docs once. A refused call names the grant it was missing. An agent reads the worker ceiling before it hires, and a later session can list the workers an earlier one created even though it doesn't remember creating them.

This essay goes through the ten principles in the design document's own wording, each with the rule that enforced it and what the platform did in production. Codex reviewed the document adversarially on 2026-07-15, and the principles carry that review's corrections. The platform ran on my account alone and is now archived.

1. Introspection First: Discovery Must Not Require Failed Writes

Three reads answer any live token, and none of them needs a grant. An agent holding only a token and the base URL can make all three before it attempts a write, and they tell it what exists and what it may do. me returns the token's identity, scope, lineage and tenant. audit returns what the token's own authority has done. capabilities returns every verb's method, path, scope and arguments, with the limits and the error taxonomy, as a static document built without a database call.

The other 53 verbs each name a grant action, and the suite checks that gate on every one. It seeds a write-scoped token whose grant list is empty and calls the whole catalog with it. The sweep, as it ran (a reduction: the test wrapper, the tenant setup and two comments are cut, and the long lines are wrapped):

ts
const denyAll = await seedToken(tenantId, {  scope: "write",  grants: { actions: [] },});for (const cmd of COMMANDS) {  const res = await call(cmd, denyAll);  if (!cmd.action) {    assert.equal(res.status, 200, `${cmd.name}: ` +      `ungated orientation read stays open to any live token`);    continue;  }  assert.equal(res.status, 403, `${cmd.name}: ` +    `ungranted → 403 (known caller, insufficient authority)`);  const body = await envelopeOf(res);  assert.equal(body.code, "action_not_granted",    `${cmd.name}: stable code`);  assert.equal(body.details?.missing, cmd.action, `${cmd.name}: ` +    `the denial names the exact contract action to request`);}

The suite loops over COMMANDS, the exported catalog. A new verb fails it until the verb gets a harness entry. It runs four more sweeps: no token or a garbage one, an expired token, a read-only token on the 31 write verbs, and malformed or oversized bodies on every POST verb.

fig. 2.13.1 · the 56 operator verbs and what each returns to callers with no standing, and the write verbs to a read-only token.
UNGRANTED: 53 NAME THE GAP, 3 ANSWERONE CELL IS ONE OF THE 56 CATALOG VERBS, GROUPED BY THE GRANT IT NEEDS; ACCENTREFUSES WITH A STABLE CODE, INK ANSWERS.NO TOKENEXPIRED TOKENUNGRANTED TOKENREAD-ONLY TOKENORIENTATIONWORKERSACCOUNTBILLINGLIMITSTASKSSCHEDULESLOCKSSECRETSTOKENSTRIGGERSCHANNELSESTATESSERVINGMODELSCAPABILITIESMECAPABILITIESAUDITPSESTIMATELOGSCOORDINATIONUPONBOARD TOKENDEPLOYDEPLOY STATUSHIREPAUSERESUMELABELRMMODEL ROUTEWORKER SNAPSHOTSWORKER RESTOREACCOUNTUPGRADEBILLINGLIMITSLIMITS SETTASKTASK STATUSSCHEDULESCHEDULE LSSCHEDULE RMLOCKLOCK RENEWLOCK RELEASESECRET LSSECRET SETSECRET RMTOKEN LSTOKEN CREATETOKEN REVOKETRIGGER CREATETRIGGER LISTTRIGGER REVOKECHANNEL CREATECHANNEL LISTCHANNEL REVOKEESTATE LISTESTATE PURGESERVESERVE DOWNSERVINGMODEL TUNEMODELSMODEL STATUSCAPABILITIES PROBECAPABILITY REQUESTCAPABILITY GRANTSCAPABILITY REVOKEFIRST GATED CELL, PS: 403 { CODE: ACTION_NOT_GRANTED, RETRYABLE: FALSE,DETAILS: { MISSING: WORKERS:READ } }.

source: creelshed b2835ab · apps/web/server/operator-contract.ts lines 203-815 (COMMANDS), 818-821 (error codes) · apps/web/server/cold-agent.test.ts lines 209-260 (the four sweeps) · catalogSweep.data.ts

The suite also caught deploy parsing its deploy_key and spec before it checked the workers:create grant. An agent without the grant would have fixed one body-shape 400 after another and then been refused at the end. I moved the grant check ahead of argument validation in the same commit that added the suite. The web tests ran 315 of 315 on that commit.

2. Failures Must Be Corrective; Successful Reads Must Be Orienting

Every non-ok response carries the same envelope: error, code, retryable and an optional details. An agent branches on code, a stable slug, without matching the error text. retryable is true only when a bare retry could ever succeed. details holds what would make the call right (the missing grant, the valid range, the cap that was hit). A 403 means the platform knows the token and refused the action, and code says what to do about it: action_not_granted names the grant to request. A 401 means the token itself is missing, bad or expired, and no grant will fix it.

The contract lists 42 error codes, and 8 of them are retryable.

fig. 2.13.2 · the 42 error codes of the operator contract by HTTP status, the 8 a bare retry can clear lit.
42 CODES, EACH WITH A RETRY VERDICT: 8 YES, 34 NOONE CELL IS ONE ERROR CODE, GROUPED BY HTTP STATUS; ACCENT IS RETRYABLE: TRUE,OUTLINE IS RETRYABLE: FALSE.401 · 2403 · 8400 · 25402 · 2413 · 1429 · 2500 · 1503 · 1UNAUTHORIZEDTOKEN_EXPIREDREAD_ONLY_TOKENACTION_NOT_GRANTEDWORKER_NOT_GRANTEDRUNTIME_NOT_GRANTEDNOT_IN_SUBTREETOKEN_REVOKEDADMIN_REQUIREDCEILING_EXCEEDEDSECRET_IN_USESECRET_NOT_FOUNDSECRET_EXPIREDDEPLOY_KEY_CONFLICTIDEMPOTENCY_KEY_CONFLICTWORKER_CEILING_REACHEDDELEGATION_DEPTH_EXCEEDEDTRIGGER_NOT_FOUNDTRIGGER_CEILING_REACHEDESTATE_NOT_FOUNDSOURCE_RETIREDSOURCE_NOT_READYSOURCE_ISOLATEDESTATE_NOT_RESTORABLEESTATE_FINALIZINGESTATE_EXPIREDRESTORE_NO_SNAPSHOTHIRE_IN_FLIGHTCONFIRMATION_REQUIREDSERVING_SLUG_TAKENDATASET_INVALIDBASE_MODEL_UNSUPPORTEDMODEL_NAME_TAKENSTALE_FENCEINVALID_REQUESTPAYMENT_REQUIREDPAYMENT_AUTHORIZATION_FAILEDPAYLOAD_TOO_LARGEFLY_UNAVAILABLERATE_LIMITEDINTERNALBROKER_UNAVAILABLEESTATE_FINALIZING, 400, RETRYABLE TRUE: “the estate’s final snapshot is stillforming, poll `estates` for restorable=true (about a minute), then restore”

source: creelshed b2835ab · apps/web/server/operator-contract.ts lines 817-860 (ERROR_CODES) · apps/web/server/results.ts lines 7-11 (the envelope) · errorTaxonomy.data.ts

One of the eight came out of a production smoke run. Restoring a retired worker's estate returned estate_not_restorable: still finalizing, marked not retryable. The final snapshot was still forming, and it becomes restorable within about a minute. I split the case out as estate_finalizing, which is retryable. Its text tells the agent to poll estates for restorable=true. estate_not_restorable kept the case where the snapshot actually failed.

The document asks that no failure be silent. The schedule endpoint once accepted a standing order for a runtime with no on-box cron. The order never ran, and nothing reported it. The endpoint now refuses that runtime at creation and names the runtimes that are supported. On the success side, limits returns the tenant's worker ceiling with the current headroom beside it.

3. Normal Agent Behavior Is What Humans Call Abuse

Agents retry, replay, crash mid-operation and resume, and race concurrent copies of themselves. The binding rules require every mutation to be idempotent or guarded by a compare-and-set or a lock, and the pull request to say which. up, which hires a worker, takes an idempotency_key and returns the same hire for the same key. A resubmitted deploy finds its worker by deploy_key instead of making a second one. The adversarial sweep on 2026-07-15 went after double provisioning, a mint racing a revoke, and the outbound-cap reservation.

The mint-and-revoke race produced a code the taxonomy didn't list. A token could authenticate and then lose its whole lineage to a concurrent revoke before it acted. The platform returned token_revoked on that path. The Codex review found it undocumented, and it was added to the taxonomy. Retries came from my own code too. A per-minute retire sweep kept retrying the close of Telegram topics that were already closed. That used up the group's budget of about 20 calls a minute, and every real topic message got a 429. I made the close idempotent.

4. Headroom Is Queryable, Never Discovered by Collision

An agent can read each cap before it takes the action that would hit it, and when a call is refused the response says which number was exceeded. capabilities advertises max_token_depth, which is 8, and a token reads its own depth on me. A mint past the cap returns delegation_depth_exceeded with the cap and the parent's depth in details. worker_ceiling_reached returns max_workers and current_workers in the same way. Once per-token spend budgets shipped, me also returned the token's weekly budget and what it had spent this week.

Before the proxy forwards a model call, it checks the estimate against the account ceiling, the worker's budget and every budgeted ancestor of the token that created the worker. A breach of a token's budget is refused with a 402 that names what was spent and what the budget is. I ran that metered pre-authorization in production on real model streams.

5. The World Is Reconstructible From Zero

An agent may resume with no memory of what it did. Everything it creates is listed by a call its token can make, with its status, its lineage and the reason for its last transition. ps returns each worker's desired state against its actual state. up returns a hire_request_id, and hire reports its phase, whether it's terminal, and the remediation. A worker took about 7 minutes to become ready in production. An agent that crashed inside those minutes finds its worker again through the ID.

Retiring a worker took a final snapshot of its volume. The provisioner refused to destroy the volume until that snapshot was restorable, and the snapshot-to-restore path was proven in production. Idle workers slept. The machine stopped, and it woke in about 8 seconds on a task, a message or a due schedule. In production, schedules fired on time through an image roll while workers slept and woke. The design's test was that an agent never has to know sleep exists. The contract added a machine_lifecycle field to ps, with no wake verb and no new error code.

6. Observable to Self

The audit verb, ungated like me, returns what the calling token's authority has done: the mints and revokes across its lineage, the workers and deploys created under its subtree, and the spend attributable to it this week, committed and in flight. Its lists come back newest first with *_total counts, which tell an agent when a list was cut short.

The platform had the same problem with its own unattended work. A worker's daily standing order once stopped firing and stayed silent for 10 days behind a panel that read ok. Every standing order was marked kept, watched or broken each day in a promise ledger. The ledger worked in production from its first day.

7. Delegation Is a First-Class Verb: Bounded, Not Frictionless

Agents spawn sub-agents, scope them down and clean up after them. Any token holding the tokens:mint grant can mint a child with no human involved. The child's scope, grants and expiry can't exceed its parent's. Ask for an expiry later than the parent's and it's clamped to the parent's. Revoking a token revokes its subtree.

Spend is bounded along the same lineage. A grant can carry a weekly spend budget, and the proxy enforces it at the 402 pre-authorization against all the model spend attributable to that token's subtree. A child's budget can't be larger than its parent's, which falls out of the same ceiling rule the mint already applied. I wrote up that kernel in Spending Limits for Autonomous Agents. The commit that shipped the budgets ran 692 of 692 web tests.

8. Not All Operations Deserve Equal Friction

The document sorts operations into reads, idempotent reconciles, creates, risk-expanding operations (raising a budget, minting a broad token) and destructive ones, each with more friction than the last. Reads are bare calls, up carries an idempotency_key, and estimate returns the machine cost as a dry run before up hires a worker. The irreversible delete, purging a retired worker's estate, needs confirm_name to match the worker's name. Without it the call returns confirmation_required.

An agent can't tell a pending approval from an outage when the call just hangs. Where a human does have to approve, the document requires a stable code such as approval_required and a state the agent can poll.

9. All Agent-Visible Text Is Untrusted Data

Any text in a response is input to an agent's next step. Every string a tenant or another worker controlled counted as prompt surface: worker names, labels, task payloads, messages. Worker and token names were capped at 120 characters and returned in typed fields. The binding rule bans building remediation text by putting a user-controlled string into an imperative sentence. The taxonomy's 42 meanings are fixed strings. The eval below plants prompt-injection strings in resource names to test that.

10. Stale Priors Are the Real Versioning Problem

Agents don't read changelogs, and they arrive believing whatever their training data said. The contract has a CONTRACT_VERSION, bumped on any change an agent could branch on. The capabilities catalog is the current shape of the API, and an agent that reads it first replaces a stale belief before it acts. The version was 1.20.0 when the platform was archived. That last bump brought schedule, schedule ls and schedule rm to the token API. Before it, an agent could hire and task a worker headlessly but could not make it recur.

The adversarial review extended the contract past the API. A resident agent also depends on the base image, the filesystem layout, env-var conventions, token semantics and the scheduler's guarantees. Changing any of them breaks it with no 400 to say so. Image tags were immutable, and on-box identifiers that live state depended on were frozen for good (the AGENTS.md markers, the cron prefix, the session key).

The Cold-Agent Eval Counts Discovery in Tokens as Well as Calls

The suite checks verbs one at a time, and the document also specifies an eval of the surface as a whole. A fresh agent gets a token and the base URL, with no docs, and a task: deploy a worker, set a budget, delegate a scoped token, retire it. It's scored on calls to first success, the failed-call ratio, tokens burned on discovery, elapsed time, and the recovery rate after a mid-task kill. The document says "three fat well-structured calls can beat nine thin ones," so discovery is counted in response bytes and model tokens as well as calls.

To resist gaming it hides task variants, randomizes resource names and varies token scopes. It also injects failures such as an expired lease, a revoked child token, a malformed spec or prompt-injection strings in resource names. The score covers the correctness and safety of what the agent did as well as its speed, and an agent that "succeeds" by leaving orphaned machines fails. The per-verb suite ran in CI. The document lists this eval as the standard still to build toward.

AIX Is Not a Natural-Language API or an MCP Adapter

The document excludes natural-language APIs and asks for a strict, boring schema. An MCP server is a protocol adapter, and AIX is how the surface behind it behaves once an agent arrives. Least privilege, bounded delegation and confirmation on destructive operations are inside the definition. Forty optional parameters is bad AIX, and so is a ten-call saga for one logical task, which is why a deploy takes the spec, secrets, machine and first task in one call, converged on deploy_key.

The strongest objection is that this is ordinary API design with a new name, and much of it is older than agents: 401 against 403, idempotency keys, Retry-After. A developer who hit the deploy bug from principle 1 would send a valid body once and remember it. An agent hits it every session and spends tokens on body-shape 400s before the refusal arrives. The suite asserts a 403 from deploy before any argument is parsed, and a regression fails the build.

Limits

I wrote the document for one platform, the one I was building, and Codex reviewed it adversarially once. The platform ran on one account. The task-driven eval is specified and has no recorded run, and replay fixtures for each mutation were still on the list to build. The levels in Appendix A rest on the suite and on production for that account.

Copy the Conformance Suite First

If an agent calls your API, the suite is the cheapest part to copy. Call every verb with no token, an expired token, a token with no grants and a read-only token. Assert the status, a stable code, and a refusal that names the missing grant before any argument is parsed. On my platform the first run caught setSecret and deleteSecret accepting a read-only token.

Appendix A: The Maturity Model

Each level is a property of the whole surface, and claiming one means passing its checks across every endpoint class.

L0, operable: works if you already know it (most REST APIs).

L1, discoverable: a cold agent finds what exists and what it may do from the surface alone, without a failed write.

L2, corrective and idempotent: failures teach, and retries, replays and concurrent self-races are safe everywhere.

L3, reconcilable and observable: an amnesiac agent rebuilds resources, desired against actual, and its own history, and converges state.

L4, delegation-native: agents mint, scope, budget and revoke other agents' access without human ceremony, safely, with auditable lineage.

The platform held L1 to L3 across every endpoint class, with the core L4 primitives live: lineage tokens with cascade revocation, and per-token metered spend budgets that bind concurrent holds.

fig. 2.13.3 · the AIX maturity model as a ladder, with the contract verbs and production proofs that carried each level.
L1 TO L3 HELD, L4’S PRIMITIVES LIVEONE STEP IS ONE LEVEL OF THE MATURITY MODEL; ACCENT STEPS WERE HELD ACROSS THEWHOLE SURFACE; BESIDE EACH, ONE RECEIPT.L4 DELEGATION-NATIVE · PRIMITIVES LIVETOKEN CREATE: A CHILD NEVER EXCEEDS ITS PARENT IN SCOPE, GRANTS OR EXPIRYL3 RECONCILABLE AND OBSERVABLEESTATE SNAPSHOT TO RESTORE, PROVEN IN PRODUCTIONL2 CORRECTIVE AND IDEMPOTENT42 ERROR CODES, EACH WITH RETRYABLEL1 DISCOVERABLE56 VERBS CATALOGUED WITH SCOPE, ACTION AND ARGSL0 OPERABLETHE FLOOR: MOST REST APIS STOP HERE

L4 Delegation-native, agents mint, scope, budget and revoke other agents’ access without human ceremony, safely, with auditable lineage. token create: child never exceeds parent in scope, grants or expiry; token revoke cascades; lineage depth capped at 8; per-token spend budget at the proxy’s 402 pre-auth, proven on real streams.L3 Reconcilable and observable, an amnesiac agent rebuilds full situational awareness and converges state. ps: desired against actual per worker; audit: a token reads its own lineage and spend; estate snapshot to restore, proven in production.L2 Corrective and idempotent, failures teach; retries, replays and concurrent self-races are safe everywhere. 42 error codes, each with retryable; up idempotent on idempotency_key; deploy converges on deploy_key.L1 Discoverable, a cold agent finds what exists and what it may do from the surface alone, without a failed write. me and capabilities, never gated; 56 verbs catalogued with scope, action and args; contract version 1.20.0.L0 Operable, works if you already know it. most REST APIs.

source: creelshed b2835ab · AIX.md lines 182-206 (maturity model) · operator-contract.ts lines 124, 203-815, 817-860, 322, 352, 563, 577 · operator-grants.ts line 85 · SALVAGE.md lines 104-107 (live-proven) · maturityLadder.data.ts

Appendix B: The Review Checklist

Paste this into any pull request that touches the agent surface (verbatim, with the rule ranges written out and two lines wrapped for width).

text
[ ] Non-ok paths return the envelope with a stable code (rule 1 to 3)[ ] Mutation is idempotent or CAS/locked; replay test exists    (rule 4 to 5)[ ] Intent recorded before external side effects (rule 6)[ ] operator-contract.ts updated; CONTRACT_VERSION bumped if branchable    (rule 7)[ ] New caps: readable headroom + cap value in rejection details (rule 8)[ ] Responses bounded; lists paginated/ordered (rule 9)[ ] Creations enumerable; no write-only state (rule 10)[ ] Server-truth timestamps; pollable ID for long ops (rule 11 to 12)[ ] User strings bounded, typed, never in imperative prose (rule 13)[ ] Destructive/risk-expanding ops have friction (rule 14)[ ] Delegation bounds hold (rule 15)[ ] Cold-agent tests added (rule 16)

Andrew Fribush, software and computer engineer in Austin. I build consequential systems where software meets institutions: finance, healthcare, commerce, government data, and the mechanisms that govern AI agents. Taking new engagements: hello@andrewfribush.com.

I write when a system teaches me something. Get new posts by email: no tracking, one-click unsubscribe.