Skip to content

Type to search pages.

View .md

HTTP endpoints

Two hosts serve HTTP, and only one of them is an API worth building against. This page says which, because the difference is not guessable from the outside and the marketing domain is the one people try first.

The marketing site and this documentation. It is a static export, so almost everything on it is a file. The one exception is /api, which is a Static Web Apps managed function that accepts nothing.

Method Path What it does
GET /api Returns this list as JSON.
GET /openapi.json The control plane’s OpenAPI 3.1 document, published at the apex address.
GET /errors.v1.json The versioned error catalog: code, message, recovery, whether retrying is safe, documentation and exit status.
GET /lint-findings.v1.json The versioned migration lint catalogue: the identifier of each finding, which does not change between releases, and the rule name and title, which do.

GET /api publishes an empty endpoints array, which is the honest shape rather than a missing field: the question somebody typing that address is asking is what this host offers a machine, and the answer is nothing, plus where the product’s API lives.

There was a POST /api/waitlist here. It stored one address per person in a table with no read path, and mailed nobody, on a domain that publishes no mail exchanger and an SPF policy authorizing no outbound sender. Signing up is a GitHub exchange against the control plane now, and asking to buy is POST /v1/leads on the control plane, both listed below.

Any other path under /api answers 404 with a body saying so, carrying a stable code, a human message and a resolution. That is the whole surface. The source is api/ in the repository.

The control plane, and the API the product actually has. It is a separate deployment with a separate hostname, described in Control plane configuration. Self-hosted installations serve it wherever they put it.

Every row says what authenticates it. There is deliberately no count in that sentence: the last version of this page said “the four unauthenticated routes at the top” and there were five paths in four rows, with two webhook routes below that take no session either.

Path Authentication What it is
GET /health, GET /readyz none Liveness and readiness. See Control plane configuration.
GET /openapi.json none The OpenAPI 3.1 document this deployment serves.
GET /metrics none Prometheus text format.
/trpc/* session cookie The console’s own API. Every procedure states the permission it needs.
/v1/* session cookie Sign-in state and provider keys, for a browser. Answers 401 without one.
POST /v1/events engine token Where an engine sends what it did.
POST /v1/workloads/claim engine token Takes the workload run waiting for an environment.
POST /v1/workloads/runs/{id}/heartbeat engine token Says a claimed run is still going.
POST /v1/commands/claim engine token Takes the cancel requests waiting for this organization.
POST /v1/commands/{id}/ack engine token Says what happened to one of them.
POST /v1/auth/github-oidc a GitHub Actions workflow identity token, in the body Exchanges a job’s own identity for a short lived engine token, so nothing has to be pasted into a repository secret. The identity says which repository the job runs in and never whose, so the organization comes from a claim on that repository. See GitHub.
POST /v1/pr/callback-token a GitHub Actions workflow identity token Exchanges a job’s own identity for a credential scoped to one commit.
POST /v1/pr/report that credential What a job says about the commit it checked.
POST /webhooks/github, POST /webhooks/stripe an HMAC over the raw body Deliveries. Verified before the body is parsed, and each one handled once.
/auth/* varies GitHub sign in for a browser, the device flow af login uses, and the browser consent an MCP client is sent through.
POST /mcp an MCP access token issued by that consent The hosted Model Context Protocol endpoint. Stateless JSON only, so GET /mcp and DELETE /mcp answer 405 with an Allow: POST header rather than opening an event stream this endpoint would have no session for. See MCP.
GET /.well-known/oauth-protected-resource none Which resource /mcp is and which authorization server issues tokens for it, read by an MCP client before it authorizes. The resource is the configured public origin rather than the request’s Host, so a token cannot be minted for an audience somebody else named.
GET /.well-known/oauth-authorization-server none The authorization, token and registration endpoints, authorization_code as the one grant, and S256 as the one challenge method.
GET /exports/deletion the token in the link, and nothing else Downloads the export of an organization that has been deleted.
POST /v1/leads none The enterprise contact form on the marketing site. One of the routes here that answers a cross-origin browser, allowed for the exact origins named in AF_SITE_ORIGIN and carrying no credentials. Writes a row the serving role can insert into and cannot read back; an operator reads the queue with af-control-plane-backup leads.
POST /webhooks/github HMAC signature Deliveries from the GitHub App. No session and no token: the body’s signature is the credential, and an unsigned delivery is refused.
POST /webhooks/stripe HMAC signature Billing deliveries, verified the same way.
POST /byok/anthropic/v1/messages engine or CLI token, in that provider’s own header The budgeted model proxy. See Model keys.
POST /byok/openai/v1/chat/completions engine or CLI token, in that provider’s own header The same, for OpenAI-shaped requests.
GET /console/api/providers session cookie and CSRF header Which provider keys and budgets an organization holds. Never the keys.
PUT /console/api/providers/{provider} session cookie and CSRF header Seals and stores one provider key.
DELETE /console/api/providers/{provider} session cookie and CSRF header Revokes one.
PUT /console/api/providers/{provider}/budget session cookie and CSRF header Sets the spend cap that the proxy above enforces.

The two /byok routes are the mechanism Model keys and Provider keys describe, and this page omitted both until now, so it described everything except the thing those guides are about. Either token kind is accepted on them, because an engine on a build machine has no person attached and a terminal has a personal token, and both are asking the same organization to spend its own money. The token goes in whichever header that provider’s own client already sends, x-api-key for Anthropic and an Authorization bearer for OpenAI, so pointing an existing SDK at this host is a base URL change rather than an edit to the caller.

GET /exports/deletion is the one row here whose credential is the URL. An organization that has been deleted has no members left to authenticate, so a session cannot be the thing that opens its export; the link mailed at closure is. It is rate limited like a sign in rather than like an API read for that reason, because it is the one address on this list somebody could usefully guess at. ?describe=1 returns the export’s size and expiry without the body, so the page that opens the link can say whether the export is still there before it offers a download rather than after. A link naming nothing answers 404, and one naming an export that is not built yet answers 409, which is a real link and worth trying again.

The /console/api/* routes need the CSRF token as well as the cookie, and saying “session cookie” alone would send somebody to a 403 they could not explain. They exist separately from /v1/providers, which authenticates a bearer token for af provider, because teaching one endpoint both schemes is how it ends up accepting the weaker one.

The two /v1/pr routes are how a pull request check reports its result, and they exist so that there is no repository secret to paste. A job asks GitHub Actions for an identity token with the audience antifailure-control-plane, posts it with the commit it is checking, and gets back a bearer credential good for that one commit and that one run, expiring within the hour. It reports once with it.

Nothing about that is optional for a fork and nothing has to remember to check: GitHub does not mint a workflow identity token for a pull request job running on a fork at all, so the exchange simply fails there, and the control plane separately refuses a credential for a fork’s commit until a maintainer has approved that exact commit. See GitHub.

/openapi.json does not describe all of that, and it is worth knowing which part it does. It is generated by walking the tRPC router, so it carries every /trpc procedure a customer can call, plus the paths written by hand: /health, /readyz, /v1/events, /v1/auth/github-oidc, the four Studio endpoints above and the four /v1/oidc/bindings routes. Each

Everything else on this page is real and answers and is not in the document: the /auth routes, the rest of /v1, /metrics, the webhooks, the model proxy, the console’s own endpoints, and the export link.

That used to be a fact you had to take on trust, and worse, an absence you could not read. A route missing from the document meant either that no reader of the document could call it or that somebody forgot, and there was no way to tell which from the outside or from the inside. web/apps/api/src/boundary.ts now classifies every route the router serves as one or the other, with the reason, and the build fails on a route that is neither, on a published route the document does not carry, and on an excluded route it does. So the shape of this page is checked rather than maintained.

The operator routes under /trpc/admin. are not in it either, and that is a deliberate exclusion rather than an oversight. They are reachable only with an operator session, which no customer credential can produce, so documenting them would describe routes every reader of this document is unable to call. The stronger reason is that the generator reads each procedure’s permission from the tenant catalogue, and an operator route declares its permission in a separate one, so the generator has nothing to read and would publish every operator route as needing no permission and no session. That would be a false statement about the control surface, so the document says nothing instead of saying something untrue.

https://app.antifailure.dev/openapi.json is generated at request time by the deployment answering it, so it always describes exactly what that host serves.

https://antifailure.dev/openapi.json is a file, generated from the router at build time, validated before it is published, and pinned to the site revision that produced it. It is the address to guess at and the one llms.txt advertises, and it cannot fail because the control plane is unreachable.

They can differ. The site deploys on every push to main and the hosted control plane moves on a release promotion, so the apex copy can describe an operation the hosted deployment does not serve yet. That is additive: calling one returns 404 rather than something surprising. The deploy compares the API version in both and fails if those disagree, because a caller reading one version of the contract and calling another is the failure worth stopping. When the two answers matter to you, read the control plane’s own.

A browser gets a session by signing in with GitHub. A machine gets a token through the device flow, which is what Signing in walks through. The generated description of both is web/apps/api/src/openapi.ts.

Why an engine pulls its work rather than being told

Section titled “Why an engine pulls its work rather than being told”

The console does not run anything. environments.create, agents.run, load.run and workloads.start ask GitHub to run the workflow in your own repository, because the engine works against a masked branch of your production database, your secrets and your third-party credentials, and none of those may cross into a hosted service.

A workflow_dispatch carries only the inputs the workflow declares, and the identifier of a recorded workload run is not one of them: the engine’s command line has no flag for it, and sending an input nothing can act on is a socket that goes nowhere. So the dispatch says what to run and POST /v1/workloads/claim says which recorded request it belongs to. The engine asks what is waiting for the environment it is working on and takes it, with a lease.

That also survives the dispatch failing. A run whose dispatch was refused, for a missing App installation or a workflow file that has not been updated, is still recorded and still claimable. A run nobody ever claims ends as abandoned when its deadline passes, which is the control plane saying it never heard rather than a claim about whether the work happened.

The lease is what stops two engines measuring the same run. A heartbeat extends it; enough missed heartbeats and it expires, and another engine polling the same environment may take the run and carry on with the work. Two rules follow, and both exist because getting them wrong loses measurements rather than merely confusing a display:

An engine answered 409 by the heartbeat has lost the run and stops. It does not send a final event, because the engine that took the run may be running it right now, and ending the run from here would refuse that engine’s report when it arrives. The result document is still written and still uploaded by the job, so nothing is lost locally.

The control plane accepts a final event only from the engine holding the run, or from any engine while nothing holds it, which is the ordinary case for a run started by hand with --run-id and for a spooled event that overtook its own claim. An event from an engine that has lost the run is stored whole and answered with a sentence saying so, and it changes nothing about the run.

An abandoned run says which kind of silence it was, because they call for different things. Nobody ever claimed it, so look at the dispatch. One engine took it and went quiet, so look at that runner. It changed hands and then went quiet, so look at the runner that took it. Or it changed hands and the first engine was still alive enough to try to end it, in which case the mechanism worked and the engine holding the run is the one that said nothing.

Teardown works the same way from the other end. environments.teardown writes a durable command and dispatches af down; whichever route reaches your runtime, the engine’s own env.destroyed event is the acknowledgement, and a teardown nothing confirmed says so rather than sitting silent.

There is no public REST API for building your own integration, and no client library. GET /openapi.json describes an API whose primary callers are this product’s own console and its own engine, and the permission model behind it assumes both. If you need something the engine cannot already do, the contributing guide is the shorter path than an integration would be.