This repository has no description
3

Configure Feed

Select the types of activity you want to include in your feed.

README.md

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/*.mjsdist/share.js) then the zero-dep static server. While iterating, npm run watch rebuilds on save.

Then:

  1. Open http://127.0.0.1:8787/demo/ and drag Share with… to your bookmarks bar (or hit Try the share sheet).
  2. In the sheet, enter your handle and Discover apps — read-only, no login.
  3. Review the grouped verbs and the exact scopes, then Connect to sign in at your PDS and grant them.
  4. 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 special http://localhost?redirect_uri=…&scope=… client — no hosted metadata needed. The granted scope is dynamic, so it's stashed and the same client_id is rebuilt when the redirect returns.
  • hosted: a real client-metadata.json served next to the page; client_id is that file's URL. To deploy, replace YOUR_HOST, host the file at its own client_id URL, and keep its scope a superset of what any sign-in requests (it ships with repo:* plus the demo handler's rpc: 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's app.foo.* method isn't bundled, so an unregistered NSID throws Lexicon not found with zero network traffic (no request shows up in devtools).
  • Undeclared query params are rejected. call() only appends params that are declared in the method's parameters block, and throws Invalid query parameter on any that aren't. Passing { subject } as the params arg 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.mjsensureProcedure):

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.mjs makes 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 repo writes. We don't have the handler's lexicon, so we synthesize a minimal record and write with validate: 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.