Remote services
A resolver does not have to be one you run. The same control plane that lets you register a project with your own node lets you register it with a managed provider — a hosted service you hold an API key for. Self-hosting and a provider are one configuration change apart: same interface, same commands, and leaving is never blocked on the provider's cooperation. The interface is pinned by the remote-services spec.
Named remotes
A remote is a {url, token} pair resolved per invocation — the CLI stays stateless. Put a provider
in .env:
ABX_REMOTE_MYPROVIDER_URL=https://meta.provider.xyz
ABX_REMOTE_MYPROVIDER_TOKEN=<your API key>Then every remote command takes the name: abx add <addr> --remote myprovider, and likewise
index, forget, render, verify, abx migrate --from/--to <name>. Names normalize the same
way as ABX_RPC_URLS_<CHAIN>: uppercase, runs of non-alphanumerics collapse to _.
--remote <url>targets an ad-hoc URL; the token comes from--remote-tokenorABX_REMOTE_SELF_TOKEN.- Bare
--remotestays the self-host default:ABX_PUBLIC_BASE_URLplusABX_REMOTE_SELF_TOKEN— nothing changes about which node you're talking to if you deployed one, only the env var it reads client-side (renamed fromABX_RESOLVER_ADMIN_TOKEN;abx doctorflags the old name if it's still set). - A named remote never falls back to
ABX_REMOTE_SELF_TOKEN, so your own node's credential cannot silently go to a third-party provider.
Inspect a remote before trusting it with a registration: abx remote <name|url> prints its
descriptor and, when a token resolves, the projects visible to that token — the one-command "is my
key valid?" check.
The control plane
Every conforming service exposes the same versioned, role-neutral HTTP surface, authenticated with
Authorization: Bearer <token>. The token authorizes index and metadata control only — never
on-chain signing.
| Method and path | Purpose |
|---|---|
POST /v1/projects | Register a project (idempotent upsert; chainId required in the body). |
GET /v1/projects | List the projects visible to this token, each with its indexing status. |
DELETE /v1/projects/{chainId}/{address} | Deregister. |
POST /v1/projects/{chainId}/{address}/reindex | Nudge a re-index. |
GET /v1/projects/{chainId}/{address}/status | Indexing lifecycle + freshness. |
POST /v1/effect-artifacts, POST /v1/effect-status | The artifact registry: a producer registers each render output and reports run state. |
Errors carry a machine code alongside the human message: unauthorized (401, fix the token),
forbidden (403, the token is valid but not authorized for that resource — a multi-tenant
provider's scoping), unsupported_chain, not_registered, disabled (the node has no
control-plane credential configured), invalid_request, and internal_error (500). A 500 never
carries internal detail: upstream errors embed the node's own endpoint URLs, and a keyed URL is a
credential, so the cause goes to the operator's logs rather than to the caller.
Three ways a read can miss
The public read routes carry the same machine code when a lookup misses, because "no metadata came
back" has three very different causes and a bare 404 for all of them is a trap — a client that built the URL itself reads
its own mistake as a provider outage:
| Answer | Meaning | What to do |
|---|---|---|
400 invalid_request | a real route, wrong shape. The body names the correct template, and may include didYouMean | fix the path — or better, stop building paths |
404 unknown_route | this node serves nothing at that path. The body lists the templates it does serve | check the node's interfaces version |
404 not_registered | the path and chain were right; this node doesn't index that contract | register it: abx add <address> --remote <name> |
400 unsupported_chain | this node serves a different chain. The body carries the chains it does | point at a node for your chain |
The classic miss is dropping the {tokenId} off /t/{chainId}/{address}/{tokenId} expecting
collection metadata — that's /c/{chainId}/{address}, and the 400 will say so.
Treat these as diagnostics, not discovery. The route grammar is fixed by the abx-token-api/v1
interface, and per contract the authoritative URL is the one committed on-chain — read it with
abx tokenuri / abx contracturi rather than assembling one.
Registering is asynchronous underneath
Catching a contract up to chain head takes milliseconds for a fresh deploy and can take minutes for a
cold replay of an older contract on a rate-limited RPC. So POST /v1/projects has two conformant
answers, and the HTTP status code tells them apart:
200— catch-up finished inside the request. The body carries the real counts.202— the registration is durable and catch-up is still running. No counts yet; poll.../statusfor progress.
Two guarantees make that safe. A service must persist the registration before it starts replaying — so a flaky RPC produces a slower backfill, never a lost registration — and a re-POST while catch-up is running never starts a second replay.
You mostly don't have to think about it, because abx add <addr> --remote <name> handles both: it
prints registered — backfilling…, polls until the project is live, and then prints the same summary
it would have printed synchronously. --no-wait returns immediately instead.
Indexing status
status is a closed set of five words, and they mean the same thing on a managed provider as on a node
you run yourself:
| Status | Meaning |
|---|---|
queued | Registered and accepted; catch-up hasn't started. |
backfilling | Catching up from the deploy block to head. May already serve partial state. |
live | Caught up, tracking head. |
stale | Was live, now lagging — still serving its last-known state. |
failed | Catch-up errored. Carries a cause; the service retries with backoff. |
Check it with abx status <address> --remote <name> (add --watch to follow along), or without
--remote for a project on your own node — same command, same five words. abx remote <name> shows
the roll-up for everything a key can see: "3 live, 1 backfilling, 1 failed (rpc_rate_limited)".
A failed status names a machine-readable cause from a closed set — rpc_unavailable,
rpc_rate_limited, not_abx_contract, internal — and the accompanying hint is guaranteed
credential-free, because "the provider's RPC is throttled, it will retry" and "the provider is broken"
call for very different reactions and neither should require a support ticket to distinguish.
not_abx_contract is the one that points back at you: the service replayed the chain at that address
and found no ABX events — almost always a wrong address or the wrong chain. It's decided by replay
rather than at registration time, because the only trustworthy evidence that a contract is ABX is that
it actually emitted the event spine; anything a service could check instantly is just the contract
saying so about itself. (A service may separately choose to serve only contracts deployed from a
canonical factory. That's a business policy, not the protocol's line, and such a service refuses those
registrations up front rather than accepting them.)
Indexing status is service observability, never protocol truth. Nothing about resolving a token
consults it, it never appears in served metadata, and a project reconstructs from chain no matter
what any provider says about it. GET .../status also reports fromBlock / toBlock / headBlock,
which is what a progress bar is made of.
The service descriptor
GET /.well-known/abx-service is public and says what a service supports before you send it
anything:
{
"service": {"name": "Example Provider", "version": "0.1.0"},
"interfaces": ["abx-token-api/v1", "abx-control-plane/v1"],
"chains": [84532],
"auth": {"scheme": "bearer", "signupUrl": "https://…", "docsUrl": "https://…"},
"render": {"attached": true, "effects": [{"key": "render", "outputs": [{"key": "image", "mimeType": "image/png"}]}]},
"baseUrl": "https://meta.provider.xyz"
}interfacesdeclares which surfaces exist.abx-control-plane/v1appears only when the control plane is enabled, which is how a client tells "disabled" from "wrong URL". There are exactly two ids and they are all-or-nothing: declaring one promises every route it names, so partial support is expressed by not declaring the interface (or per-caller with a403), never by declaring it and serving a subset. The artifact-registry routes rideabx-control-plane/v1rather than an id of their own — see who holds the bytes for why there is nothing to negotiate.chainsare the EIP-155 ids served; registering a project on any other chain is refused withunsupported_chain.auth.signupUrlis for humans: where a customer gets an API key.render.attachedmeans rendering runs behind the service.
Managed rendering
The resolver already fronts the effects runner — clients never talk to a renderer directly. So a
provider that runs its own renderer advertises render: {attached: true} and a generative project
gets thumbnails and traits with nothing extra to stand up: no abx deploy-effects, no Playwright,
no runner. Without it, renders remain yours to produce (abx render <addr> --remote <name> or your
own runner publishing through /v1/effect-artifacts).
Who holds the bytes
A resolver is not an object store, and the interface makes that structural rather than a matter of manners. Every effect output is one of two classes, decided by how it binds into the metadata JSON:
- Referenced — the still, a video, a model: the projection carries its URL, or it only appears
in the
artifactsmanifest. The producer keeps the bytes and registers a locator; the resolver records the pointer and302-redirects to it. It never fetches the locator when you register it, and never proxies the bytes. Sending bytes for a referenced output is a400— the resolver would gain nothing (it redirects either way) and would take on storage, retention and egress. - Bound — script-reported
traits: its content is stitched intoattributes, so a locator could never work (it would put a third-party fetch ontokenURI, the hottest read there is). These are published as inline bytes, capped at 64 KB, and the resolver keeps them only at the token's currentinputsHash— so what it holds is bounded by supply, not by how often you change a param. Sending a locator for a bound output is also a400: it would be recorded and then never stitch, which is worse than a refusal.
The practical consequence for rendering: a runner that doesn't share the resolver's disk needs a
storage backend that can name a reachable URL — cloud (S3/R2 with a public base), ipfs, or
arweave. These are peers, not a ranking; pick on cost and operations. Derived output is
re-creatable (a lost render is a re-render), so the protocol expresses no preference among schemes,
and an https:// gateway URL you chose is exactly as legitimate as ipfs://. The only real
requirement is that someone other than you can fetch it: a loopback host or an expiring presigned URL
is refused. The default fs backend can't name a URL at all, so abx render --remote and
abx deploy-effects refuse it up front rather than after the render — or render co-located with
the resolver, where there is nothing to publish and any backend works.
Leaving a provider
Registration is never load-bearing for resolution: the on-chain base URL and chain replay stay the
source of truth, and the public read plane is guaranteed to be sufficient to migrate with zero
provider cooperation. abx migrate <addr> --from <provider> --to <anywhere> reads only public
endpoints, then the cutover is one DNS change or one on-chain re-point. See
self-hosting → migrating.
No default provider ships in the CLI, the SDK, or the skill — any provider implements the same
public interface, and the reference resolver is itself a conforming implementation. The provider
market is nascent; self-hosting remains the fully-supported path while it forms. A provider can
self-verify with the conformance fixture: pnpm conformance -- <base-url> --token <key>.