Share sheet — a real browser consumer#
A working consumer of AT Intents: a browser app that shares the current web page through whatever atproto apps are already in your repo. No app list is hardcoded — the handlers are discovered from your repo footprint (the same loop the resolver runs on the CLI), and the actions run for real against a live PDS over OAuth.
your handle
│
├─ 1 DISCOVER scan repo for dev.at-intent.usage → resolve each app's (read-only,
│ dev.at-intent.capability records → one action graph no auth)
│
├─ 1b MATCH keep actions whose subject accepts this page (a bare `uri`); (resolver.mjs
│ the rest are shown but greyed, with the subject they'd need subjectMatches)
│
├─ 2 CONSENT compute the minimal granular OAuth scopes the APPLICABLE (scopes.mjs)
│ handlers declared, and request exactly them — nothing more
│
└─ 3 ACT run any action against the page, by `delivery`: (execute.mjs)
repo → com.atproto.repo.createRecord into your repo
service → XRPC proxied through your PDS (atproto-proxy)
open → navigate; the handler's own session authenticates
The order matters: because we request granular scopes (repo:… / rpc:…,
not blanket write access), we must know which collections and methods to ask
for — so discovery runs first, read-only, then consent requests precisely that
set. That's AT Intents' whole pitch made concrete: ask for what the repo says is
needed, and nothing else.
Subject constraints are part of that: a capability declares which subjects it
accepts (a uri, a did account, an at-uri of some collection…). The share
target is a bare web URL, so an action like "follow an account or publication"
can't apply — subjectMatches filters it out of the runnable set, drops its scope
from the request, and the UI lists it under "Doesn't apply to this page" with the
subject it would need. Same matcher the resolver and CLI use.
Run it#
cd demo
npm install
npm run dev # builds the bundle, then serves → http://127.0.0.1:8787/demo/
Use 127.0.0.1, not localhost — atproto's loopback OAuth client requires a
127.0.0.1 redirect URI. npm run dev runs esbuild (src/*.mjs → dist/share.js)
then the zero-dep static server. While iterating, npm run watch rebuilds on save.
Then:
- Open
http://127.0.0.1:8787/demo/and drag Share with… to your bookmarks bar (or hit Try the share sheet). - In the sheet, enter your handle and Discover apps — read-only, no login.
- Review the grouped verbs and the exact scopes, then Connect to sign in at your PDS and grant them.
- Back in the sheet, run any action — it executes live against the target page.
Auth: loopback dev vs hosted#
src/auth.mjs picks a client from the hostname:
- loopback (
127.0.0.1): the specialhttp://localhost?redirect_uri=…&scope=…client — no hosted metadata needed. The granted scope is dynamic, so it's stashed and the sameclient_idis rebuilt when the redirect returns. - hosted: a real
client-metadata.jsonserved next to the page;client_idis that file's URL. To deploy, replaceYOUR_HOST, host the file at its ownclient_idURL, and keep itsscopea superset of what any sign-in requests (it ships withrepo:*plus the demo handler'srpc:auds).
PKCE, DPoP, token refresh, and session storage are handled by
@atproto/oauth-client-browser:
on the next visit initAuth() silently restores a live session and goes straight
to the connected view. We additionally remember the user's {did, handle} so a
returning visitor whose session has lapsed is greeted by handle, pre-filled, and
one click from reconnecting (cleared on explicit sign-out).
How it maps to the code#
| File | Role |
|---|---|
index.html |
builds + installs the bookmarklet (against the serving origin) |
share.html |
the sheet shell + styles; loads the built dist/share.js |
src/share.mjs |
the flow: discover → consent → execute, grouped by verb |
src/auth.mjs |
atproto OAuth (loopback + hosted), session → @atproto/api Agent |
src/scopes.mjs |
action graph → minimal granular repo: / rpc: scopes |
src/execute.mjs |
the real act: createRecord, PDS-proxied XRPC, navigation |
client-metadata.json |
hosted OAuth client template |
serve.mjs |
zero-dep static server (binds 127.0.0.1) |
Discovery is upstream code: resolveActionGraph from
../resolver/resolver.mjs, unchanged except that it
now carries serviceId through to each action (needed for the proxy aud).
Implementing service calls against @atproto/api#
The service wire contract
is unambiguous: subject + scalar inputs go in the query string, only a blob
goes in the body. But @atproto/api's generic agent.call(nsid, params, data, opts)
adds a library-specific wrinkle that the spec (rightly) doesn't mention — and that
is easy to get wrong:
call()needs the method in its local lexicon registry first. It looks the NSID up before making any request — to choose GET vs POST and to know which query params are valid. A handler'sapp.foo.*method isn't bundled, so an unregistered NSID throwsLexicon not foundwith zero network traffic (no request shows up in devtools).- Undeclared query params are rejected.
call()only appends params that are declared in the method'sparametersblock, and throwsInvalid query parameteron any that aren't. Passing{ subject }as theparamsarg is not enough.
So before calling, register a minimal stub lexicon declaring the contract's params
(subject + each scalar input), then pass them as params and leave data/body
empty (execute.mjs → ensureProcedure):
agent.lex.add({
lexicon: 1,
id: lxm,
defs: { main: { type: "procedure", parameters: { type: "params",
properties: { subject: { type: "string" }, /* + each scalar input */ } } } },
});
await agent.call(lxm, { subject: page.url, ...scalarInputs }, undefined, {
headers: { "atproto-proxy": `${handlerDid}#${serviceId}` },
});
Output has no schema, so any response the handler returns is accepted. Putting
subject in data instead of params is what produces a handler-side
Missing required param: subject.
Scope mapping#
scopesForGraph unions one rule per action (atproto is always included):
| delivery | scope requested |
|---|---|
repo |
the capability's own repo:<collection> (or repo:<produces[0]>?action=create) |
service (non-open) |
rpc:<lxm>?aud=<handlerDID>#<serviceId> |
open / passive |
none (navigation / already-readable record) |
For the demo footprint (photos.disnetdev.com → Skyreader) that's:
atproto
repo:site.standard.graph.subscription
rpc:app.skyreader.linkblog.share?aud=did:plc:ra4jsemddo2ii4pn5jaf6x4v#skyreader_api
rpc:app.skyreader.feed.subscribe?aud=did:plc:ra4jsemddo2ii4pn5jaf6x4v#skyreader_api
rpc:app.skyreader.feed.save?aud=did:plc:ra4jsemddo2ii4pn5jaf6x4v#skyreader_api
Verified vs. load-bearing assumptions#
Verified against the live bsky.social PDS: read-only discovery in the browser
(CORS to plc.directory + the PDS is fine), scope computation, and the OAuth
redirect — the authorization server accepts the loopback client and all granular
scopes (the pushed-authorization request succeeds and the consent screen renders).
Still needs a real end-to-end run (they depend on a completed login, which can't be automated):
- Will a production PDS proxy to an arbitrary third-party handler? Service
proxying was designed around canonical services (AppView, labeler, feed-gen).
The reference PDS forwards on any DID-doc service entry with no allowlist, but
whether Bluesky's PDS will mint a user-signed JWT toward an arbitrary app DID is
the load-bearing assumption —
execute.mjsmakes the call; a live test confirms it. - Does the granted
rpc:scope actually permit the proxied call? We request it; the PDS enforces it. - Record shape for
repowrites. We don't have the handler's lexicon, so we synthesize a minimal record and write withvalidate: false. A first-party consumer would build the exact shape. - Trust: proxying makes your PDS sign a JWT under your identity toward a DID that surfaced from a self-asserted usage record — verify the handler's authority before acting.
See docs/consumers.md and docs/capability-spec.md.