Consumer guide — discover and route to capabilities #
You're a consumer if you want to offer a user actions powered by the other apps they use: a "send this out" menu, an "open with" picker, an agent that can act across the atmosphere. You build none of those integrations by hand — you read them out of the user's repo.
New to the model? Read the overview first.
The loop you implement #
1. RESOLVE Scan the user's repo for dev.at-intent.usage records → app DIDs.
2. DISCOVER Resolve each app DID → its dev.at-intent.capability records.
3. MATCH Filter to (verb, subject) you care about → candidate handlers.
4. AUTHORIZE (repo only) Aggregate include:<nsid> scopes; request their union.
5. ACT Route by delivery: passive | repo | service.
Steps 1–3 are read-only and need no auth. The resolver/ in
this repo implements all of them; you can embed resolver.mjs (Node + browser,
zero deps) directly or treat it as a reference.
Quick start with the reference resolver #
node resolver/cli.mjs --demo # full action graph, offline
node resolver/cli.mjs --demo --feed https://blog/rss # who can subscribe to this feed?
node resolver/cli.mjs --demo --scopes # scopes you'd need to enable everything
node resolver/cli.mjs alice.bsky.social # live: real repo scan
In code:
import { resolveActionGraph, matchActions, requiredScopes } from "./resolver/resolver.mjs";
const graph = await resolveActionGraph("alice.bsky.social");
// "Which of Alice's apps can subscribe to this feed URL?"
const handlers = matchActions(graph, { verb: "subscribe", subject: { kind: "uri" } });
// What OAuth scopes must I request to enable them?
const scopes = requiredScopes(handlers); // ["include:app.myreader.subscribe", ...]
Matching: the rules #
Matching is on (verb, subject). A query subject matches a capability if any
of the capability's subject entries fits:
kindmust be equal (uri≠at-uri).- For
at-uri, if both sides name anoftype, they must match. A capability withat-uriand noofaccepts any record type. as(record / collection / account) must match (defaultrecord).
So a bare feed uri matches a reader's subscribe but not a bookmark app
whose subject is an at-uri — the right handlers surface and the wrong ones
don't, with zero per-app code. That codeless second-handler property is the whole
point: a new app lights up in your UI just by publishing records.
Routing by delivery #
Once the user picks a handler, route by its delivery:
delivery |
What you do | Auth needed |
|---|---|---|
passive |
Nothing — the handler already reads the primitive in the repo. Just show it as available. | none |
repo |
Write the capability's produces record(s) into the user's repo. |
the capability's scope |
service |
Call the endpoint as an XRPC method — the verb picks GET/POST, subject+input become params. See below. |
the endpoint's own auth (OAuth), or service proxying via your PDS |
Don't assume
repofor everything. Aservicecapability will not work if you only write to the PDS; you must call its endpoint. Apassivecapability needs no write at all. Honoringdeliveryis what keeps "looks like it worked" and "actually worked" the same thing.
Calling a service endpoint #
endpoint is an XRPC method URL (https://host/xrpc/<nsid>). You don't need a
method lexicon — the capability record is the param schema. To make the call:
- Method from
verb.open→ GET (XRPC query). Every other verb (and any unknown verb) → POST (XRPC procedure). - Encode subject + input as params. Put the matched subject under the reserved
name
subjectand each input under itsname. Scalar kinds (stringuriat-urididintegerboolean) go in the query string (booleans as baretrue/false, arrays as repeated keys); ablobgoes in the request body (so a GET/opencapability can't take one). - Preserve the endpoint's own query string. If
endpointalready has params (?source=…), keep them and merge yours in — never clobber. - Auth is the endpoint's own OAuth (the path you already walk for
repo), not a PDS write scope. Errors come back as XRPC{ error, message }JSON on a non-2xx. - Prefer service proxying when the handler publishes a service entry. Instead of
calling the handler directly, call your own PDS at the same
/xrpc/<nsid>path with anatproto-proxy: <handler-did>#<serviceId>header; the PDS resolves the DID, mints the inter-service JWT, and forwards. You only ever fetch your own PDS — so this is the browser-friendly path that sidesteps the third-party CORS noted under performance — and you skip the separategetServiceAuthstep. Auth to your own PDS is the OAuth session you already hold; in a browser that's a DPoP-bound token (Authorization: DPoP …plus aDPoPproof header), notBearer. (See the atproto XRPC spec.)- The
serviceIdis a convenience: the capability record may carry it, but you already resolved the handler's DID during discovery, so you can fall back to reading the service entry from its DID document — a missingserviceIdis not "can't proxy." - Unverified — confirm against a live PDS: that a PDS will proxy to an arbitrary third-party handler DID (proxying was built for canonical services like AppViews/labelers), and that the OAuth scope a browser session holds permits it. Falling back to a direct call (CORS permitting) or a server-side fetch is the escape hatch if a given PDS refuses.
- The
OAuth scope aggregation #
passive and the entire discover/display path need no new scope — ship that
first as a complete, zero-scope feature.
For repo capabilities:
- Collect the
include:<nsid>scope of each handler the user might invoke (requiredScopes(actions)). - Request their union at OAuth time — but only trigger incremental re-auth on first encounter of an app whose scope you don't already hold. A fresh grant on a newly discovered collection is normal OAuth, not a failure.
- Never widen to
repo:*for convenience. Scope is the real containment boundary — keep it least-privilege and per-discovered-app. (See atproto proposal 0011.)
Staleness, caching, performance #
- Staleness. Usage records are write-once; nothing removes one when a person
stops using an app. Age handlers out by
lastSeenAt+ a TTL (resolver default 180 days;--stale-days,--include-stale). - Caching. Capability records change rarely — cache them by app DID with a
long TTL. Only the usage scan must be fresh. (The resolver accepts a
cacheMap inopts.) - Latency. A repo scan plus N capability resolutions across N PDSes per login has a budget. Cache aggressively; parallelize the per-app resolution.
- Browser CORS. Live
listRecordsfrom a web origin can hit CORS on some PDSes; a tiny read proxy fixes it. The Node path has no such limit.
Agent consumers #
A capability descriptor is a tool definition (see the overview). An agent consumer runs the same loop, then exposes matched capabilities as tools. Extra duties unique to agents:
- Treat
descriptionandinputas untrusted input to your planner (prompt-injection hardening). - Verify the handler's authority before acting, not just before displaying — a spoofed usage record could otherwise redirect work to an attacker's handler.
- Gate any write the user didn't directly request behind a dry-run/confirm step, and do per-step scope checks rather than one broad up-front grant.