at-intents docs consumers.md
8.5 kB · 165 lines
Markdown
at main

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:

  • kind must be equal (uri ≠ at-uri).
  • For at-uri, if both sides name an of type, they must match. A capability with at-uri and no of accepts any record type.
  • as (record / collection / account) must match (default record).

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 repo for everything. A service capability will not work if you only write to the PDS; you must call its endpoint. A passive capability needs no write at all. Honoring delivery is 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 subject and each input under its name. Scalar kinds (string uri at-uri did integer boolean) go in the query string (booleans as bare true/false, arrays as repeated keys); a blob goes in the request body (so a GET/open capability can't take one).
  • Preserve the endpoint's own query string. If endpoint already 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 an atproto-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 separate getServiceAuth step. Auth to your own PDS is the OAuth session you already hold; in a browser that's a DPoP-bound token (Authorization: DPoP … plus a DPoP proof header), not Bearer. (See the atproto XRPC spec.)
    • The serviceId is 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 missing serviceId is 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.

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:

  1. Collect the include:<nsid> scope of each handler the user might invoke (requiredScopes(actions)).
  2. 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.
  3. 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 cache Map in opts.)
  • 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 listRecords from 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 description and input as 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.

Checklist #