diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..48a6a28
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+.impeccable/
+*.log
diff --git a/README.md b/README.md
index 859ed9e..953eb50 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,71 @@
# AT Intents
+
+A standard, registry-free way for atproto apps to declare what they can **do** —
+so other apps (and agents) can discover those capabilities from a user's own repo
+and route to them. No central directory: an app you use leaves a small record
+behind, and that footprint *is* the discovery surface.
+
+```
+ ┌─────────────────────────── the user's repo ───────────────────────────┐
+producers │ dev.at-intent.usage ──"I use Skyreader"──► resolve app DID ─┐ │ consumers
+publish │ dev.at-intent.usage ──"I use Sill"────────────────────────┐ │ │ discover + route
+ └─────────────────────────────────────────────────────────────┼─┼────────┘
+ ▼ ▼
+ ┌─ Skyreader repo ─┐ ┌─ Sill repo ─┐
+ │ capability ×4 │ │ capability │
+ │ (verb,subject,…) │ │ (passive) │
+ └──────────────────┘ └─────────────┘
+```
+
+Every capability is a **`(verb, subject, handler)`** triple plus a **`delivery`**
+method (`passive` / `pds` / `service`) that says how the action actually reaches
+the app. The same record doubles as an **agent tool definition** — discovered
+from the user's data at runtime, scoped by their existing grants.
+
+## Start here
+
+| You are… | Read |
+|---|---|
+| understanding the system | [`docs/overview.md`](./docs/overview.md) |
+| an app that **does things** (save/subscribe/share/annotate) | [`docs/producers.md`](./docs/producers.md) |
+| an app that **discovers + routes** (menus, "open with", agents) | [`docs/consumers.md`](./docs/consumers.md) |
+| an **AI agent** asked to author records for a repo | [`docs/agent-guide.md`](./docs/agent-guide.md) |
+| writing a capability record | [`docs/capability-spec.md`](./docs/capability-spec.md) |
+
+## Try it in 30 seconds
+
+```bash
+# See the discovery loop run offline (no network, no auth):
+node resolver/cli.mjs --demo
+node resolver/cli.mjs --demo --feed https://blog.example/rss # who can subscribe?
+
+# Validate + preview a capability record without writing it (zero deps):
+cd tools
+node write-capability.mjs examples/subscribe.capability.json --dry-run --identifier myapp.com
+```
+
+## What's in this repo
+
+```
+lexicons/ dev.at-intent.capability + dev.at-intent.usage (the two records)
+tools/ write capability/usage records — from a JSON file or interactively
+resolver/ the consumer-side discovery loop (lib + CLI + offline fixtures)
+docs/ overview, producer/consumer guides, agent guide, spec reference
+```
+
+## Status
+
+Exploration / draft. The namespace `dev.at-intent.*` is a working placeholder
+(likely long-term home: `community.lexicon.*`); it's defined in one constant
+([`tools/lib/nsid.mjs`](./tools/lib/nsid.mjs)) plus the lexicon `id` fields, so
+re-homing is mechanical. No app publishes these records on the live network yet —
+the resolver fixtures stand in for that so the full loop runs today.
+
+## Prior art & context
+
+Descends from Web / Android / Apple App Intents — but replaces their handler
+*registry* with the user's repo *footprint*, which is what W3C Web Intents
+(~2012) lacked. Builds on atproto OAuth permission sets
+([proposal 0011](https://github.com/bluesky-social/proposals/blob/main/0011-auth-scopes/README.md)),
+lexicon resolution, and lexicon embeds. See [`docs/overview.md`](./docs/overview.md)
+for the full picture.
diff --git a/docs/agent-guide.md b/docs/agent-guide.md
new file mode 100644
index 0000000..c633a6d
--- /dev/null
+++ b/docs/agent-guide.md
@@ -0,0 +1,183 @@
+# Agent guide — author AT Intents capability records for a repo
+
+**Audience: an AI coding agent** (Claude Code or similar) working inside an app's
+repository. A developer has said something like:
+
+> "Let's make an AT Intents capability record — see
+> https://github.com/<...>/at-intents for context."
+
+This document is your instruction set. Your job: **scan the repo, figure out what
+the app does, write capability record file(s) as JSON, and tell the developer the exact CLI
+command to publish them.** You do not publish records yourself (that needs the
+app's credentials) and you do not need to run anything that writes.
+
+If you can fetch additional context, read [`overview.md`](./overview.md) and
+[`capability-spec.md`](./capability-spec.md) in this repo. The essentials are
+inlined below so you can work from this file alone.
+
+## The model in 60 seconds
+
+AT Intents lets an app declare what it can *do* so other apps and agents can
+discover it from a user's repo. Each capability is a **`(verb, subject, handler)`
+triple** plus a **`delivery`** method, stored as a `dev.at-intent.capability`
+record in the app's own repo.
+
+- **verb** — `share` `save` `subscribe` `annotate` `open` (open set; pick the
+ closest, or coin one and flag it to the developer).
+- **subject** — what it acts on, a *union* of shapes: `uri` (a bare web/feed
+ URL), `at-uri` (a record, optionally `of` a lexicon type), `did` (an account).
+- **delivery** — `passive` (you read a record already in the user's repo), `pds`
+ (the consumer writes a record into the user's repo that you read), or `service`
+ (the consumer must call your HTTP endpoint; a PDS write won't reach you). **This
+ is the field most likely to be wrong — get it right.**
+
+## Your procedure
+
+### Step 1 — confirm scope with the developer
+
+Briefly state what you're about to do and ask one question if anything is
+ambiguous: *"I'll scan the repo for user-facing actions and draft AT Intents
+capability specs. Should I cover all of them or a specific feature?"* Then
+proceed.
+
+### Step 2 — scan the repo for what the app does
+
+You are looking for **user-facing actions** and the **data each one touches**.
+Gather evidence; don't guess. High-signal places to look:
+
+**App identity (for `name` / `icon` / the app DID):**
+- `package.json` name/description, `README*`, site `
`/`og:` tags,
+ `public/`/`static/` for an icon, `manifest.json`, marketing copy.
+- The app's atproto identity: a `did:web:`, a configured handle, OAuth
+ `client-metadata.json`, or `.well-known/atproto-did`. The capability records
+ live in *this* app's repo, so the DID is the app's own.
+
+**Lexicons the app defines or writes (for `subject` / `produces`):**
+- `lexicons/**/*.json` (files with `"lexicon": 1`) — record types this app owns.
+- atproto write calls: grep for `com.atproto.repo.createRecord`,
+ `putRecord`, `applyWrites`, `.create(`, `agent.com.atproto.repo`, or an SDK
+ wrapper. The `collection` / `$type` argument is a `produces` NSID.
+- Record types it *reads* but doesn't own (e.g.
+ `community.lexicon.bookmarks.bookmark`, `app.bsky.feed.post`) → candidate
+ `passive` subjects.
+
+**Endpoints / backends (to decide `delivery`):**
+- XRPC routes or HTTP handlers (`/xrpc/`, an API router, server routes,
+ serverless functions) — a `service` `endpoint`.
+- A non-PDS canonical store (a database — D1, Postgres, KV; an external API) that
+ user actions write to. **If the action's source of truth is a backend, not the
+ user's PDS, it's `delivery: service`.**
+
+**The actions themselves (for `verb` / `subject`):**
+- UI: buttons/menus labeled save, subscribe/follow, share/post, highlight/annotate,
+ open/view. Share targets, intent handlers, "add to…" flows.
+- Functions/routes named `save`, `subscribe`, `share`, `post`, `bookmark`,
+ `highlight`, `follow`, `import`.
+
+### Step 3 — map each action to a capability
+
+For every distinct user-facing action, decide the triple + delivery using this
+table:
+
+| Question | Field | How to answer from evidence |
+|---|---|---|
+| What's the action? | `verb` | save / subscribe / share / annotate / open — closest match. |
+| What can you act on? | `subject[]` | A raw URL → `{kind: uri}`. A record you accept → `{kind: at-uri, of: }`. An account → `{kind: did, as: account}`. List every accepted shape. |
+| Where does the result live? | `delivery` | In the **user's PDS** as a record you write/read → `pds`. In **your backend** → `service`. You only **read** an existing repo record → `passive`. |
+| (pds) what record? | `produces` | The NSID(s) from the createRecord calls. |
+| (pds) what permission? | `scope` | `include:` — ask the developer if unknown; leave a clear `TODO`. |
+| (service) what URL? | `endpoint` | The XRPC/HTTP route that performs it. |
+| Extra inputs? | `input[]` | A note, title, target collection — name + kind. |
+
+**The `delivery` decision is the one that silently breaks if wrong.** A "save"
+whose data lands in your own database is `service`, *not* `pds` — a PDS write
+would never reach your backend, so a consumer would think it saved when it
+didn't. When the evidence is ambiguous, write your best guess and call it out in
+the summary for the developer to confirm.
+
+Write `description` for a planner *and* a human: what it does, when to use it,
+what it won't do (≤300 graphemes). This text drives both UI pickers and agent
+tool-calling, so it matters.
+
+### Step 4 — write the record file(s)
+
+Create one JSON file in the repo holding the capability record(s) — a single
+object, or `{ "capabilities": [ ... ] }` for several. It's the literal
+`dev.at-intent.capability` shape; `$type` and `createdAt` are filled in by the
+writer, so you can omit them. Recommended path: `at-intent/capabilities.json` (or
+alongside the app's lexicons). Any `_`-prefixed field (e.g. `_comment`) is
+ignored, so use it for review notes.
+
+```json
+{
+ "_comment": "Drafted by an agent from repo analysis. Review delivery/scope/endpoint, then publish with the CLI below.",
+ "capabilities": [
+ {
+ "name": "",
+ "icon": "",
+ "description": "",
+ "verb": "",
+ "subject": [
+ { "kind": "" }
+ ],
+ "delivery": "",
+
+ "_delivery_note": "Keep ONLY the fields for the delivery you chose:",
+ "_passive": "you read an existing repo record — no produces/scope/endpoint",
+ "_pds": "consumer writes a record you read — set produces + scope",
+ "produces": [""],
+ "scope": "include: TODO confirm — pds only",
+ "_service": "consumer calls your backend — set endpoint instead",
+ "endpoint": " TODO confirm — service only"
+ }
+ ]
+}
+```
+
+Fill it from your evidence and **delete the `_*` hint fields and any
+delivery-fields that don't apply**. Mark every value you guessed with a `TODO`
+note (a `_todo` field, or call it out in your summary).
+
+### Step 5 — tell the developer how to publish
+
+Do **not** write records yourself. Give the developer this, adjusting the file
+path and identifier (the tooling is zero-dependency Node 18+ — no install):
+
+```bash
+cd tools
+
+# 1) Dry-run — validates and prints the exact write, writes nothing:
+node write-capability.mjs /capabilities.json --dry-run --identifier
+
+# 2) Publish to the APP's repo (use an app password, never the main password):
+PDS_IDENTIFIER= PDS_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx \
+ node write-capability.mjs /capabilities.json
+```
+
+Also mention the interactive builder as an alternative:
+`node write-capability-interactive.mjs`.
+
+### Step 6 — summarize for review
+
+End with a short report:
+
+1. **Capabilities drafted** — one line each: `verb` + `subject` + `delivery`.
+2. **Assumptions & TODOs** — every value you guessed, especially `delivery`,
+ `scope`, and `endpoint`, with where the evidence was thin.
+3. **Usage records** — remind the developer that discovery also requires writing
+ a `dev.at-intent.usage` record into each *user's* repo when they use the app
+ (see [producers.md](./producers.md)); that's app code, not a one-time publish.
+4. **Next command** — the dry-run line from Step 5.
+
+## Guardrails
+
+- **Don't invent NSIDs, endpoints, scopes, or a DID.** If the repo doesn't show
+ it, leave a `TODO` and ask. A wrong `produces`/`endpoint` is worse than a blank
+ one.
+- **A capability may only describe its own app's records/endpoints** — never
+ another app's collections. (This is also a security rule: descriptors are
+ untrusted input to consumer agents.)
+- **Don't publish anything.** Your deliverable is JSON record files + the command. The
+ human runs the write, ideally `--dry-run` first.
+- **Prefer fewer, correct capabilities** over many speculative ones. One verb you
+ verified beats five you inferred.
diff --git a/docs/capability-spec.md b/docs/capability-spec.md
new file mode 100644
index 0000000..d530618
--- /dev/null
+++ b/docs/capability-spec.md
@@ -0,0 +1,106 @@
+# Capability record reference
+
+A capability is a `dev.at-intent.capability` record — plain JSON, the shape
+defined by the [lexicon](../lexicons/dev/at-intent/capability.json). Write the
+JSON directly. `$type` and `createdAt` are optional: the
+[`write-capability.mjs`](../tools/write-capability.mjs) tool fills them in,
+validates against the lexicon **and** the cross-field `delivery` rules, then
+publishes it. (You could also `POST` it yourself via
+`com.atproto.repo.createRecord` — the tool just adds validation, the dry-run
+preview, and app-password auth.)
+
+A file may hold:
+
+- a single capability object, **or**
+- a top-level array, **or**
+- `{ "capabilities": [ ... ] }` to publish several at once.
+
+Any field starting with `_` (e.g. `_comment`) is ignored — handy for notes, since
+JSON has no comments.
+
+## Fields
+
+| Field | Required | Type | Notes |
+|---|---|---|---|
+| `name` | ✅ | string ≤64 graphemes | Display name of the handler (e.g. `Skyreader`). |
+| `verb` | ✅ | string | Known: `share` `save` `subscribe` `annotate` `open`. Open set — unknown verbs validate with a warning; consumers that don't know a verb skip it. |
+| `subject` | ✅ | array (≥1) of [subject specs](#subject-spec) | A union: matches if **any** entry fits. |
+| `delivery` | ✅ | `passive` \| `pds` \| `service` | How the action reaches you. See [delivery rules](#delivery-rules). |
+| `description` | — | string ≤300 graphemes | Strongly recommended. Written for **both** a human picker and an agent planner: what it does, when to use it, what it won't do. |
+| `icon` | — | uri | Handler icon; http(s) URL. |
+| `input` | — | array of [input fields](#input-field) | Named typed inputs beyond the subject. |
+| `produces` | — | array of NSIDs | Record type(s) you write. Required-ish for `pds`; empty for `passive`/`service`. |
+| `endpoint` | — | uri | **Required for `delivery: service`** — the URL/XRPC that performs the action. |
+| `scope` | — | string | **For `delivery: pds`** — an `include:` permission set the consumer must request. |
+| `$type` | auto | NSID | Set to `dev.at-intent.capability` by the writer; you may include it. |
+| `createdAt` | auto | datetime | Added by the writer if omitted. |
+
+### Subject spec
+
+```json
+"subject": [
+ { "kind": "uri" },
+ { "kind": "at-uri", "of": "community.lexicon.bookmarks.bookmark" },
+ { "kind": "did", "as": "account" }
+]
+```
+
+| Field | Required | Notes |
+|---|---|---|
+| `kind` | ✅ | `uri` \| `at-uri` \| `did` \| `string` \| `blob`. `uri` is a first-class bare URL the bookmark-centric model couldn't express. |
+| `of` | — | For `kind: at-uri` only — the lexicon NSID the AT-URI must resolve to. Omit to accept any record type. |
+| `as` | — | `record` (default) \| `collection` \| `account`. Distinguishes "act on this one record" from "subscribe to this whole account/collection." |
+
+### Input field
+
+```json
+"input": [
+ { "name": "category", "kind": "string", "description": "Optional folder.", "required": false }
+]
+```
+
+| Field | Required | Notes |
+|---|---|---|
+| `name` | ✅ | The input's name. |
+| `kind` | ✅ | `string` \| `uri` \| `at-uri` \| `did` \| `integer` \| `boolean` \| `blob`. |
+| `of` | — | For `kind: at-uri` — the lexicon type the value must resolve to. |
+| `required` | — | Defaults to false. |
+| `description` | — | What the input means (also feeds agent planners). |
+
+## Delivery rules
+
+The validator enforces these cross-field rules (the JSON Schema alone can't):
+
+- **`service`** — `endpoint` is **required** (error if missing). `produces` and
+ `scope` are unexpected (warning).
+- **`pds`** — `produces` and `scope` are expected (warning if missing). `endpoint`
+ is ignored (warning).
+- **`passive`** — `produces`, `scope`, and `endpoint` are all unexpected
+ (warning); a passive capability reads what's already there and writes nothing.
+
+Errors block a write. Warnings don't — pass `--force` to write despite them, or
+`--dry-run` to inspect first.
+
+## Full example
+
+```json
+{
+ "name": "Skyreader",
+ "icon": "https://skyreader.app/icon.png",
+ "description": "Subscribe to a feed, a standard.site publication, or an account so its new items appear in your Skyreader reading list.",
+ "verb": "subscribe",
+ "subject": [
+ { "kind": "uri" },
+ { "kind": "at-uri", "of": "site.standard.publication" },
+ { "kind": "did", "as": "account" }
+ ],
+ "input": [
+ { "name": "category", "kind": "string", "description": "Optional folder to file the subscription under." }
+ ],
+ "produces": ["app.skyreader.feed.subscription"],
+ "delivery": "pds",
+ "scope": "include:app.skyreader.subscribe"
+}
+```
+
+More: one file per delivery type in [`tools/examples/`](../tools/examples/).
diff --git a/docs/consumers.md b/docs/consumers.md
new file mode 100644
index 0000000..4484f26
--- /dev/null
+++ b/docs/consumers.md
@@ -0,0 +1,128 @@
+# 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](./overview.md) 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 (pds only) Aggregate include: scopes; request their union.
+5. ACT Route by delivery: passive | pds | service.
+```
+
+Steps 1–3 are **read-only and need no auth**. The [`resolver/`](../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
+
+```bash
+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:
+
+```js
+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 |
+| `pds` | Write the capability's `produces` record(s) into the user's repo. | the capability's `scope` |
+| `service` | `POST` to the capability's `endpoint`. | the endpoint's own auth |
+
+> **Don't assume `pds` 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.
+
+## OAuth scope aggregation
+
+`passive` and the entire discover/display path need **no new scope** — ship that
+first as a complete, zero-scope feature.
+
+For `pds` capabilities:
+
+1. Collect the `include:` 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](https://github.com/bluesky-social/proposals/blob/main/0011-auth-scopes/README.md).)
+
+## 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](./overview.md#why-the-same-record-is-also-an-agent-tool)). 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
+
+- [ ] Scan usage records; dedupe by `app`; drop stale by `lastSeenAt` + TTL.
+- [ ] Resolve + cache capabilities by app DID.
+- [ ] Match on `(verb, subject)` — don't hardcode an app list.
+- [ ] Route by `delivery` (passive / pds / service) — handle all three.
+- [ ] Aggregate `include:` scopes; request the union; incremental re-auth only on
+ first encounter; never `repo:*`.
+- [ ] (Agents) untrusted-input hardening + verify authority before acting.
diff --git a/docs/overview.md b/docs/overview.md
new file mode 100644
index 0000000..cf2dfdf
--- /dev/null
+++ b/docs/overview.md
@@ -0,0 +1,140 @@
+# AT Intents — the system
+
+> A standard, registry-free way for atproto apps to declare what they can *do* —
+> so other apps (and agents) can discover those capabilities from a user's own
+> repo and route to them. No central directory: an app you use leaves a small
+> record behind, and that footprint *is* the discovery surface.
+
+This document describes the model. If you're implementing it, jump to
+[Producers](./producers.md) (you publish capabilities) or
+[Consumers](./consumers.md) (you discover and route to them). If an agent is
+authoring records for you, point it at [the agent guide](./agent-guide.md).
+
+## The problem
+
+When an app wants to be a destination for "share / save / subscribe to this," it
+gets hand-wired into every other app, one integration at a time. W3C Web Intents
+(~2012) tried to fix this with a global handler registry and died on discovery:
+nobody picks a handler from a list of strangers, and nobody registers handlers
+because nothing consumes them.
+
+AT Intents sidesteps both. Discovery is not "what could you install" — it's
+**"what's already in your repo."** You see an app as a destination *because you
+already use it* and it left a record. Discovery as a footprint, not a registry,
+is the load-bearing idea.
+
+## The triple
+
+Every capability is a `(verb, subject, handler)` triple:
+
+- **verb** — `share`, `save`, `subscribe`, `annotate`, `open`, … (an open set
+ with a governed core vocabulary). The verb is *not* always "share" — subscribe,
+ save, and annotate are first-class.
+- **subject** — what the verb acts on, as a *union* of accepted shapes. Crucially
+ includes a **bare `uri`** (an RSS feed has no atproto type) and
+ account/collection subjects, not just record AT-URIs.
+- **handler** — the app: the DID whose repo holds the capability records and
+ where the action lands.
+
+## `delivery` — the field that makes capabilities honest
+
+`delivery` is how an action actually reaches the handler. It's the difference
+between a capability that works and one that silently doesn't.
+
+| `delivery` | Meaning | `produces` | Cost to consumer |
+|---|---|---|---|
+| `passive` | Handler **reads a primitive already in the repo**; consumer writes nothing. | empty | zero |
+| `pds` | Consumer **writes the produced records** to the user's repo; handler reads them. | the record(s) | a write + scope |
+| `service` | Consumer **calls `endpoint`**; a PDS write alone won't reach the handler. | empty | an API call |
+
+Why it matters: a "Save to X" capability can *look* free and silently not work,
+because a record written to the user's PDS never reaches an app whose canonical
+store is its own backend. `delivery: service` makes that case explicit instead of
+a silent failure. `scope` (an `include:` permission set) is only needed for
+`delivery: pds`.
+
+## Two records, two homes
+
+- **`dev.at-intent.capability`** — authored once by the app, in the *app's* repo,
+ resolved from the app's DID. One record per capability. See the
+ [lexicon](../lexicons/dev/at-intent/capability.json).
+- **`dev.at-intent.usage`** — written by the app into the *user's* repo when they
+ use it. Lightweight: which app, optional per-user config, a `lastSeenAt` for
+ staleness. **This is the discovery footprint.** See the
+ [lexicon](../lexicons/dev/at-intent/usage.json).
+
+This mirrors how atproto already separates per-user identity from lexicon
+authority, and keeps the per-user record small and stable rather than duplicating
+a capability blob into every repo.
+
+## The discovery loop
+
+A consumer assembles a user's **action graph** without any hardcoded app list:
+
+```
+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 (cache by DID).
+3. MATCH Now you have a typed action set: {(verb, subject) → handler, delivery, scope}.
+ Answer "which of my apps can this ?"
+4. AUTHORIZE (pds only) Aggregate the include: scopes of chosen handlers; request their union.
+5. ACT passive → nothing; pds → write the produced record(s); service → call the endpoint.
+```
+
+Steps 1–3 are read-only and need no auth. The [resolver](../resolver/) implements
+exactly this loop; run `node resolver/cli.mjs --demo` to see it offline.
+
+## Why the same record is also an agent tool
+
+A capability descriptor maps almost field-for-field onto an LLM tool definition:
+
+| An agent tool spec needs… | A capability descriptor has… |
+|---|---|
+| a name / id | `name` + NSID + app DID |
+| a natural-language description | `description` (write it for a planner) |
+| a typed input schema | `subject` + `input` |
+| an output / effect | `produces` |
+| a permission requirement | `scope` (the app's published permission set) |
+| a way to be invoked | the `delivery` route |
+
+So the tool set is **discovered from the user's own data at runtime** and scoped
+by their existing grants — unlike static MCP servers that hardcode a fixed API.
+
+## OAuth scope: the central constraint
+
+atproto OAuth grants are requested at login and enumerate collections up front,
+so a dynamically discovered app's collections aren't in the grant. The `delivery`
+split resolves this:
+
+- `passive` and discovery/display need **no new scope** — a complete feature with
+ zero scope changes.
+- `pds` capabilities each advertise an `include:` **permission set**
+ ([atproto proposal 0011](https://github.com/bluesky-social/proposals/blob/main/0011-auth-scopes/README.md)).
+ The consumer aggregates the sets of discovered apps and triggers incremental
+ re-auth **only** on first encounter of a new app that needs scope it doesn't
+ hold. A blanket `repo:*` is rejected.
+
+## Trust & safety
+
+- A **capability record** resolves from the app's own authority — as trustworthy
+ as the app. Display fields are safe because they come from the real authority.
+- A **usage record** is self-asserted by whatever app wrote it. A malicious app
+ the user already authed could plant one and spoof the *association* ("this user
+ uses X"); the display still resolves from X's real authority, so the blast
+ radius is a misleading suggestion, not a hijacked write. Only an already-authed
+ app can plant one at all.
+- For **agent** consumers, treat `description` / `input` as untrusted planner
+ input (prompt-injection hardening), verify the handler's authority before
+ acting (not just before displaying), and enforce that a capability may only
+ name its own app's collections.
+
+## Naming & namespace
+
+The names `dev.at-intent.*` are this repo's working namespace (authority
+`at-intent.dev`). They're a placeholder so the design can iterate; the likely
+long-term home is `community.lexicon.*`. The NSIDs live in one place
+([`tools/lib/nsid.mjs`](../tools/lib/nsid.mjs) and the lexicon `id` fields) so
+re-homing is a mechanical change.
+
+"Intents" deliberately echoes the Web / Android / Apple App Intents lineage this
+descends from. (Note: Bluesky's separate "User Intents" work uses "intent" for
+data-reuse consent — a name collision to flag, not to rename around.)
diff --git a/docs/producers.md b/docs/producers.md
new file mode 100644
index 0000000..c5c18c7
--- /dev/null
+++ b/docs/producers.md
@@ -0,0 +1,155 @@
+# Producer guide — publish your app's capabilities
+
+You're a **producer** if your app *does things*: saves, subscribes, shares,
+annotates, opens. This guide takes you from "my app has features" to "other apps
+and agents can discover and route to those features from a user's repo."
+
+New to the model? Read the [overview](./overview.md) first. Want an agent to do
+the analysis for you? See the [agent guide](./agent-guide.md).
+
+There are two jobs:
+
+1. **Publish capability records** (once, in your app's repo) — describe what you
+ can do.
+2. **Write usage records** (per user, in *their* repo) — leave the footprint that
+ lets consumers discover you at all.
+
+---
+
+## 1. Publish your capabilities
+
+### Step 1 — enumerate your write/read surfaces
+
+List everything a user can *do* through your app. For each, you'll fill in the
+triple plus `delivery`. Ask:
+
+- **verb** — is it `share`, `save`, `subscribe`, `annotate`, `open`, or something
+ new? Don't force everything into "share."
+- **subject** — what does it act on? A bare URL (`uri`)? A record of a given type
+ (`at-uri` + `of`)? An account (`did`)? List *all* accepted shapes — it's a
+ union.
+- **delivery** — the key decision, below.
+
+### Step 2 — choose `delivery` for each surface
+
+This is the field that decides whether a capability actually works. Pick by
+asking *where the canonical data lives*:
+
+| If… | `delivery` | You must… |
+|---|---|---|
+| your app just **reads** a primitive the user already has in their repo | `passive` | nothing else — no `produces`, no `scope` |
+| your app's data lives **in the user's PDS** as a record you read | `pds` | list `produces` (the NSID you write) and a `scope` |
+| your app's canonical store is **your own backend** (a PDS write won't reach you) | `service` | give an `endpoint` |
+
+> The classic mistake: marking a backend-canonical save as `pds` or `passive`. A
+> record written to the user's repo never reaches your backend, so the capability
+> looks free and silently fails. Use `service`.
+
+### Step 3 — write the record as JSON
+
+A capability is just a JSON record matching the
+[lexicon](../lexicons/dev/at-intent/capability.json) — `$type` and `createdAt`
+are filled in for you. Full field reference: [capability-spec.md](./capability-spec.md).
+
+```json
+{
+ "name": "My Reader",
+ "icon": "https://myreader.app/icon.png",
+ "description": "Subscribe to a feed or account so new items appear in your reading list. Use to follow a source over time; not for saving a single article.",
+ "verb": "subscribe",
+ "subject": [
+ { "kind": "uri" },
+ { "kind": "did", "as": "account" }
+ ],
+ "produces": ["app.myreader.subscription"],
+ "delivery": "pds",
+ "scope": "include:app.myreader.subscribe"
+}
+```
+
+Write `description` as if a planner will read it: what it does, when to use it,
+what it won't do. Tool-calling quality is dominated by description quality, and
+the same text feeds a human picker.
+
+See [`tools/examples/`](../tools/examples/) for one record per delivery type and a
+multi-capability file.
+
+### Step 4 — validate and write
+
+You'll need an **app password** for your app's account (on Bluesky: Settings →
+Privacy and security → App passwords — never your main password). The tooling is
+zero-dependency Node 18+ — no install.
+
+```bash
+cd tools
+
+# Always dry-run first: validates and prints the exact write, but writes nothing.
+node write-capability.mjs examples/subscribe.capability.json --dry-run \
+ --identifier myreader.app
+
+# Write for real (capability records go in YOUR app's repo):
+PDS_IDENTIFIER=myreader.app PDS_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx \
+ node write-capability.mjs my-subscribe.capability.json
+```
+
+Prefer to be walked through it? `node write-capability-interactive.mjs` prompts
+for each field, saves a reusable JSON record, shows a dry-run, and writes on
+confirmation.
+
+Publish your whole surface at once with a `{ "capabilities": [...] }` file — see
+[`examples/all.capabilities.json`](../tools/examples/all.capabilities.json).
+
+### Step 5 — make the lexicon resolvable (optional, for live discovery)
+
+Capability *records* live in your repo and resolve today. For the *lexicon* NSID
+to resolve on the open network, the authority domain (here `at-intent.dev`) must
+publish the lexicon per atproto lexicon resolution. Until then, consumers fetch
+records directly by collection NSID from your PDS — which the resolver already
+does. Nothing blocks you from publishing records now.
+
+---
+
+## 2. Write usage records (the footprint)
+
+A capability nobody can discover is invisible. When a person uses your app, write
+a `dev.at-intent.usage` record **into their repo** so consumers find you:
+
+```json
+{
+ "$type": "dev.at-intent.usage",
+ "app": "did:web:myreader.app",
+ "config": { "defaultCategory": "tech" },
+ "lastSeenAt": "2026-06-26T12:00:00Z",
+ "createdAt": "2026-03-01T00:00:00Z"
+}
+```
+
+- You already have write access to the user's repo (they authed your app), so you
+ can create this during normal use.
+- **Dedupe by `app`** — one usage record per handler is enough.
+- **Bump `lastSeenAt`** when they use the app again, so consumers can age out
+ stale handlers (default TTL ~180 days).
+- `config` is your private per-user blob (a default collection, a category) —
+ shape is yours.
+
+For manual/testing creation (writes into the *user's* repo, so auth as the user):
+
+```bash
+node write-usage.mjs --app did:web:myreader.app \
+ --config '{"defaultCategory":"tech"}' --dry-run --identifier alice.bsky.social
+```
+
+---
+
+## Checklist
+
+- [ ] Every user-facing action mapped to a `(verb, subject)` pair.
+- [ ] `delivery` chosen by where the canonical data lives (no backend-canonical
+ surface marked `pds`/`passive`).
+- [ ] `pds` capabilities declare `produces` **and** a `scope`.
+- [ ] `service` capabilities declare an `endpoint`.
+- [ ] `description` written for a planner (what / when / won't).
+- [ ] Capability records written to your app's repo (verify with the
+ [resolver](../resolver/): `node resolver/cli.mjs `).
+- [ ] A usage record written into each user's repo on use, deduped by `app`,
+ `lastSeenAt` bumped.
diff --git a/lexicons/README.md b/lexicons/README.md
new file mode 100644
index 0000000..dbbce7b
--- /dev/null
+++ b/lexicons/README.md
@@ -0,0 +1,35 @@
+# Lexicons
+
+The protocol nucleus: two lexicons built around the `(verb, subject, handler)`
+triple, with a `delivery` field that decides whether a capability is actionable
+at all.
+
+- [`dev/at-intent/capability.json`](./dev/at-intent/capability.json) — the
+ per-app descriptor. Authored once by the app, stored in the app's own authority
+ repo, one record per capability.
+- [`dev/at-intent/usage.json`](./dev/at-intent/usage.json) — the per-user
+ discovery signal, written into the user's repo by the app.
+
+See [`../docs/overview.md`](../docs/overview.md) for the model and
+[`../docs/capability-spec.md`](../docs/capability-spec.md) for a field-by-field
+reference.
+
+## Namespace
+
+These use `dev.at-intent.*` (authority domain `at-intent.dev`) as a working
+namespace; the likely long-term home is `community.lexicon.*`. The NSIDs are
+defined in exactly two places — these files' `id` fields and
+[`../tools/lib/nsid.mjs`](../tools/lib/nsid.mjs) — so re-homing is mechanical.
+
+## `delivery` — the one design move worth knowing
+
+A "Save to X" capability can *look* like a zero-cost reader and silently not work,
+because some apps' canonical store is their own backend, not the PDS. A record
+written to the user's repo never reaches them. So `delivery` is not an
+afterthought — it decides whether a capability is actionable:
+
+| `delivery` | Meaning | `produces` | scope |
+|---|---|---|---|
+| `passive` | Handler reads a primitive already in the repo; consumer writes nothing. | empty | none |
+| `pds` | Consumer writes the produced record(s); handler reads them. | the record(s) | `include:` |
+| `service` | Consumer must call `endpoint`; a PDS write won't reach the handler. | empty | none |
diff --git a/lexicons/dev/at-intent/capability.json b/lexicons/dev/at-intent/capability.json
new file mode 100644
index 0000000..624edf8
--- /dev/null
+++ b/lexicons/dev/at-intent/capability.json
@@ -0,0 +1,114 @@
+{
+ "lexicon": 1,
+ "id": "dev.at-intent.capability",
+ "description": "A single capability an app (the handler) declares: a (verb, subject, handler) triple, plus optional typed inputs, the records it produces, and how the action is delivered. Authored once by the app developer and stored in the app's own authority repo; consumers discover an app via a dev.at-intent.usage record and resolve these from the app's DID. v1 consumers may read any subset of fields and ignore the rest.",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "One capability. An app publishes one record per capability (e.g. a reader might publish four: save, subscribe, share, annotate).",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["name", "verb", "subject", "delivery"],
+ "properties": {
+ "name": {
+ "type": "string",
+ "maxGraphemes": 64,
+ "maxLength": 640,
+ "description": "Display name of the handler for this capability (e.g. 'Skyreader', 'Skyreader Linkblog')."
+ },
+ "icon": {
+ "type": "string",
+ "format": "uri",
+ "description": "Optional handler icon URL. Consumers should constrain rendering (see docs: icon/display trust)."
+ },
+ "description": {
+ "type": "string",
+ "maxGraphemes": 300,
+ "maxLength": 3000,
+ "description": "Natural-language description of what this capability does. Written to serve BOTH a human picker and an agent planner — say what it does, when to use it, what it won't do."
+ },
+ "verb": {
+ "type": "string",
+ "knownValues": ["share", "save", "subscribe", "annotate", "open"],
+ "maxLength": 128,
+ "description": "The action this capability performs. Open set; knownValues are the vocabulary defined so far. A consumer that doesn't know a verb should ignore the capability rather than guess."
+ },
+ "subject": {
+ "type": "array",
+ "items": {"type": "ref", "ref": "#subjectSpec"},
+ "minLength": 1,
+ "description": "The accepted subject(s) — a union. The capability matches a shared item if that item satisfies ANY entry (e.g. subscribe accepts a feed URL OR a publication AT-URI OR an account DID)."
+ },
+ "input": {
+ "type": "array",
+ "items": {"type": "ref", "ref": "#inputField"},
+ "description": "Additional named, typed inputs the handler accepts beyond the subject (a note, a title, a target collection). Kept deliberately small; complex inputs (e.g. span selectors) are deferred — see docs."
+ },
+ "produces": {
+ "type": "array",
+ "items": {"type": "string", "format": "nsid"},
+ "description": "Record type(s) written when the capability runs. Empty/omitted for delivery=passive (handler reads an existing primitive) and delivery=service (handler ingests out-of-band)."
+ },
+ "delivery": {
+ "type": "string",
+ "knownValues": ["passive", "pds", "service"],
+ "description": "How the action reaches the handler. 'passive': the handler reads a primitive ALREADY in the user's repo; the consumer performs no extra write (zero-cost). 'pds': the consumer writes the 'produces' records into the user's repo and the handler reads them (enrichment). 'service': the consumer must call 'endpoint'; a PDS write alone would NOT reach the handler (e.g. an app whose canonical store is its own backend)."
+ },
+ "endpoint": {
+ "type": "string",
+ "format": "uri",
+ "description": "Required for delivery=service: the HTTP/XRPC endpoint that performs the action."
+ },
+ "scope": {
+ "type": "string",
+ "description": "For capabilities that write (delivery=pds): the OAuth permission set the handler requires, as an 'include:' scope string per atproto auth-scopes (proposal 0011). The consumer aggregates these across discovered apps and requests their union. Omit for delivery=passive."
+ },
+ "createdAt": {"type": "string", "format": "datetime"}
+ }
+ }
+ },
+ "subjectSpec": {
+ "type": "object",
+ "description": "One accepted subject shape.",
+ "required": ["kind"],
+ "properties": {
+ "kind": {
+ "type": "string",
+ "knownValues": ["uri", "at-uri", "did", "string", "blob"],
+ "description": "The value type of the subject. 'uri' is a bare web/feed URL with NO atproto type (e.g. an RSS feed) — a first-class shareable the bookmark-centric model couldn't express. 'at-uri' points at a record; 'did' is an account."
+ },
+ "of": {
+ "type": "string",
+ "format": "nsid",
+ "description": "For kind=at-uri: the lexicon type the AT-URI must resolve to (e.g. community.lexicon.bookmarks.bookmark, site.standard.publication)."
+ },
+ "as": {
+ "type": "string",
+ "knownValues": ["record", "collection", "account"],
+ "default": "record",
+ "description": "The semantic role of the subject: a single record, an entire collection, or an account. Lets 'subscribe to this account/collection' be distinct from 'act on this one record'."
+ }
+ }
+ },
+ "inputField": {
+ "type": "object",
+ "description": "A named, typed input beyond the subject.",
+ "required": ["name", "kind"],
+ "properties": {
+ "name": {"type": "string", "maxLength": 128},
+ "kind": {
+ "type": "string",
+ "knownValues": ["string", "uri", "at-uri", "did", "integer", "boolean", "blob"]
+ },
+ "of": {
+ "type": "string",
+ "format": "nsid",
+ "description": "For kind=at-uri: the lexicon type the value must resolve to."
+ },
+ "required": {"type": "boolean", "default": false},
+ "description": {"type": "string", "maxGraphemes": 300, "maxLength": 3000}
+ }
+ }
+ }
+}
diff --git a/lexicons/dev/at-intent/usage.json b/lexicons/dev/at-intent/usage.json
new file mode 100644
index 0000000..81ecfc8
--- /dev/null
+++ b/lexicons/dev/at-intent/usage.json
@@ -0,0 +1,38 @@
+{
+ "lexicon": 1,
+ "id": "dev.at-intent.usage",
+ "description": "A per-user discovery signal, written into the user's OWN repo by an app when the person uses it: 'I use this handler.' Consumers scan for these on login and resolve each app's dev.at-intent.capability records. Self-asserted; only an app the user has already granted write access can create one (see docs: trust/spoofing).",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "One usage signal per handler the user uses. Consumers should dedupe by 'app'.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["app", "createdAt"],
+ "properties": {
+ "app": {
+ "type": "string",
+ "format": "did",
+ "description": "The handler the user uses. Resolves (via atproto lexicon resolution) to that app's capability records."
+ },
+ "capability": {
+ "type": "string",
+ "format": "at-uri",
+ "description": "Optional: a specific capability record this usage refers to. Omit to mean 'all of the app's capabilities'."
+ },
+ "config": {
+ "type": "unknown",
+ "description": "Optional per-user configuration consumed by the handler (e.g. a default collection AT-URI, a default category). Shape is handler-defined."
+ },
+ "lastSeenAt": {
+ "type": "string",
+ "format": "datetime",
+ "description": "Bumped by the app each time the person uses it, so consumers can age out stale handlers (see docs: revocation/staleness). Absence means 'never refreshed since createdAt'."
+ },
+ "createdAt": {"type": "string", "format": "datetime"}
+ }
+ }
+ }
+ }
+}
diff --git a/resolver/README.md b/resolver/README.md
new file mode 100644
index 0000000..47ed234
--- /dev/null
+++ b/resolver/README.md
@@ -0,0 +1,62 @@
+# Resolver — the consumer side
+
+A read-only, zero-auth, zero-dependency resolver that discovers a user's
+**action graph** from their repo footprint, and a CLI over it. This is the
+reference implementation a [consumer](../docs/consumers.md) embeds.
+
+```
+resolver.mjs core lib (Node 18+ and browser; fetch-based)
+cli.mjs prints the action graph / answers "which app can do X?"
+fixtures/ demo records (not yet on the network) for the offline loop
+```
+
+## The loop
+
+```
+actor (handle/DID)
+ -> scan repo for dev.at-intent.usage records (discovery)
+ -> resolve each app DID -> its dev.at-intent.capability records
+ -> assemble (verb, subject, handler) action graph (the triple)
+matchActions(graph, {verb, subject}) -> "which handler can do X?"
+requiredScopes(actions) -> the include: scopes to request
+```
+
+## CLI
+
+```bash
+node cli.mjs --demo # full action graph (offline fixtures)
+node cli.mjs --demo --feed https://blog/rss # "which of my apps subscribe to a feed?"
+node cli.mjs --demo --scopes # scopes to request to enable everything
+node cli.mjs --demo --json # raw graph
+node cli.mjs alice.bsky.social # live: real handle, real repo scan
+```
+
+Live mode does real atproto plumbing — handle→DID, DID doc→PDS,
+`com.atproto.repo.listRecords` for usage/capability records. Until apps publish
+these records it finds **nothing** against live handles — which is itself proof
+the plumbing is real, not mocked. The fixtures stand in for the network so the
+full loop runs offline today.
+
+## Library
+
+```js
+import { resolveActionGraph, matchActions, requiredScopes } from "./resolver.mjs";
+
+const graph = await resolveActionGraph("alice.bsky.social", { cache: new Map() });
+const handlers = matchActions(graph, { verb: "subscribe", subject: { kind: "uri" } });
+const scopes = requiredScopes(handlers);
+```
+
+`opts`: `fixtures` (offline records), `cache` (Map, persist with a long TTL),
+`staleDays` (default 180), `includeStale`, `fetchImpl`, `now`.
+
+## Caveats
+
+- **No real records exist yet** — everything non-demo resolves to empty until an
+ app publishes `dev.at-intent.*` records.
+- **Browser live mode can hit CORS** — `listRecords` against arbitrary PDSes may
+ be blocked from a web origin; a tiny read proxy fixes it. The Node path and
+ demo mode have no such limit.
+- **Routing is read-only here** — `pds`/`service` capabilities show what they
+ *would* do; performing the write/call is the consumer's job (see
+ [consumers.md](../docs/consumers.md)).
diff --git a/resolver/cli.mjs b/resolver/cli.mjs
new file mode 100755
index 0000000..1f95730
--- /dev/null
+++ b/resolver/cli.mjs
@@ -0,0 +1,121 @@
+#!/usr/bin/env node
+// AT Intents resolver CLI — the consumer side of the loop.
+//
+// node cli.mjs # print the user's action graph
+// node cli.mjs --verb subscribe # filter to one verb
+// node cli.mjs --feed # "which of my apps can subscribe to this?"
+// node cli.mjs --demo [...] # run fully offline against fixtures
+// node cli.mjs --json # raw graph as JSON
+//
+// Flags: --verb --feed --subject-type --include-stale
+// --stale-days --scopes --json --demo
+
+import { resolveActionGraph, matchActions, requiredScopes } from "./resolver.mjs";
+import demo, { DEMO_ACTOR } from "./fixtures/demo.mjs";
+
+function parseArgs(argv) {
+ const a = { _: [] };
+ for (let i = 0; i < argv.length; i++) {
+ const t = argv[i];
+ if (t === "--demo") a.demo = true;
+ else if (t === "--json") a.json = true;
+ else if (t === "--scopes") a.scopes = true;
+ else if (t === "--include-stale") a.includeStale = true;
+ else if (t === "--verb") a.verb = argv[++i];
+ else if (t === "--feed") a.feed = argv[++i];
+ else if (t === "--subject-type") a.subjectType = argv[++i];
+ else if (t === "--stale-days") a.staleDays = Number(argv[++i]);
+ else a._.push(t);
+ }
+ return a;
+}
+
+const C = {
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
+};
+
+const deliveryNote = {
+ passive: C.green("passive") + C.dim(" — reads a primitive already in your repo (zero-cost)"),
+ pds: C.cyan("pds") + C.dim(" — would write a record to your repo (needs OAuth scope)"),
+ service: C.yellow("service") + C.dim(" — would call the app's endpoint"),
+};
+
+function subjectStr(s) {
+ if (s.kind === "at-uri") return `at-uri<${s.of || "*"}>${s.as && s.as !== "record" ? " as " + s.as : ""}`;
+ return s.as && s.as !== "record" ? `${s.kind} as ${s.as}` : s.kind;
+}
+
+function printGraph(graph) {
+ console.log(`\n${C.bold("Action graph")} for ${C.cyan(graph.actor)} ${C.dim(graph.did)}`);
+ if (!graph.handlers.length) {
+ console.log(C.dim("\n No usage records found — nothing in this repo's footprint yet.\n"));
+ return;
+ }
+ for (const h of graph.handlers) {
+ const tag = h.error ? C.yellow(" [unresolved]") : h.stale ? C.dim(" [stale]") : "";
+ console.log(`\n ${C.bold(h.app)}${tag}`);
+ if (h.error) { console.log(` ${C.yellow(h.error)}`); continue; }
+ for (const cap of h.capabilities) {
+ const subj = (cap.subject || []).map(subjectStr).join(" | ");
+ console.log(` ${C.green(cap.verb.padEnd(10))} ${subj}`);
+ console.log(` ${C.dim(cap.description || cap.name || "")}`);
+ console.log(` delivery: ${deliveryNote[cap.delivery] || cap.delivery}` +
+ (cap.produces?.length ? C.dim(` produces: ${cap.produces.join(", ")}`) : ""));
+ }
+ }
+ console.log();
+}
+
+function printMatches(graph, query) {
+ const matches = matchActions(graph, query);
+ const what = query.subject ? `${query.verb || "any verb"} on ${subjectStr(query.subject)}` : query.verb;
+ console.log(`\n${C.bold("Handlers that can")} ${C.green(what)} ${C.dim("(from your apps)")}`);
+ if (!matches.length) { console.log(C.dim("\n None of your apps can do this.\n")); return; }
+ for (const m of matches) {
+ console.log(`\n ${C.cyan(m.name || m.handler)} ${C.dim(m.handler)}`);
+ console.log(` ${m.description || ""}`);
+ console.log(` route: ${deliveryNote[m.delivery] || m.delivery}` +
+ (m.delivery === "service" && m.endpoint ? C.dim(` -> ${m.endpoint}`) : ""));
+ }
+ const scopes = requiredScopes(matches);
+ if (scopes.length) console.log(`\n ${C.dim("scopes to request:")} ${scopes.join(" ")}`);
+ console.log();
+}
+
+async function main() {
+ const a = parseArgs(process.argv.slice(2));
+ const actor = a.demo ? DEMO_ACTOR : a._[0];
+ if (!actor) {
+ console.error("usage: node cli.mjs [--verb v] [--feed url] [--demo] [--json]");
+ process.exit(1);
+ }
+
+ const opts = { staleDays: a.staleDays, includeStale: a.includeStale };
+ if (a.demo) opts.fixtures = demo;
+
+ let graph;
+ try {
+ graph = await resolveActionGraph(actor, opts);
+ } catch (err) {
+ console.error(C.yellow(`\nresolve failed: ${err?.message || err}\n`));
+ process.exit(1);
+ }
+
+ if (a.json) { console.log(JSON.stringify(graph, null, 2)); return; }
+ if (a.scopes) { console.log(requiredScopes(graph.actions).join(" ") || C.dim("(no pds-delivery scopes)")); return; }
+
+ if (a.feed || a.verb || a.subjectType) {
+ const query = { verb: a.verb, includeStale: a.includeStale };
+ if (a.feed) { query.verb = a.verb || "subscribe"; query.subject = { kind: "uri" }; }
+ else if (a.subjectType) query.subject = { kind: "at-uri", of: a.subjectType };
+ printMatches(graph, query);
+ } else {
+ printGraph(graph);
+ }
+}
+
+main();
diff --git a/resolver/fixtures/demo.mjs b/resolver/fixtures/demo.mjs
new file mode 100644
index 0000000..80e0fe6
--- /dev/null
+++ b/resolver/fixtures/demo.mjs
@@ -0,0 +1,87 @@
+// Demo fixtures — records that don't exist on the network yet. A user
+// (DEMO_ACTOR) who uses two apps, plus those apps' published capabilities.
+// Lets the resolver run the full loop offline. Mirrors the example records under
+// ../../tools/examples/.
+
+export const DEMO_ACTOR = "did:plc:demouser000000000000000";
+const READER = "did:web:skyreader.app";
+const SILL = "did:web:sill.social";
+
+const readerCaps = [
+ {
+ $type: "dev.at-intent.capability",
+ name: "Skyreader",
+ icon: "https://skyreader.app/icon.png",
+ description:
+ "Subscribe to a feed, a standard.site publication, or an account so its new items appear in your Skyreader reading list.",
+ verb: "subscribe",
+ subject: [
+ { kind: "uri" },
+ { kind: "at-uri", of: "site.standard.publication" },
+ { kind: "did", as: "account" },
+ ],
+ input: [
+ { name: "category", kind: "string", description: "Optional folder to file the subscription under." },
+ { name: "tags", kind: "string" },
+ ],
+ produces: ["app.skyreader.feed.subscription"],
+ delivery: "pds",
+ scope: "include:app.skyreader.subscribe",
+ createdAt: "2026-06-26T00:00:00Z",
+ },
+ {
+ $type: "dev.at-intent.capability",
+ name: "Skyreader Linkblog",
+ icon: "https://skyreader.app/icon.png",
+ description: "Post a link to your standard.site linkblog with an optional note.",
+ verb: "share",
+ subject: [{ kind: "uri" }],
+ input: [
+ { name: "title", kind: "string" },
+ { name: "note", kind: "string" },
+ ],
+ produces: ["site.standard.document"],
+ delivery: "pds",
+ scope: "include:app.skyreader.linkblog",
+ createdAt: "2026-06-26T00:00:00Z",
+ },
+ {
+ $type: "dev.at-intent.capability",
+ name: "Skyreader",
+ icon: "https://skyreader.app/icon.png",
+ description: "Save an article to your Skyreader reading list (read-it-later).",
+ verb: "save",
+ subject: [{ kind: "uri" }],
+ input: [{ name: "title", kind: "string" }],
+ delivery: "service",
+ endpoint: "https://skyreader.app/xrpc/app.skyreader.feed.save",
+ createdAt: "2026-06-26T00:00:00Z",
+ },
+];
+
+const sillCaps = [
+ {
+ $type: "dev.at-intent.capability",
+ name: "Sill",
+ icon: "https://sill.social/icon.png",
+ description:
+ "Your saved bookmarks, surfaced for daily review. Sill reads the community bookmark already in your repo — nothing is written.",
+ verb: "save",
+ subject: [{ kind: "at-uri", of: "community.lexicon.bookmarks.bookmark" }],
+ delivery: "passive",
+ createdAt: "2026-06-26T00:00:00Z",
+ },
+];
+
+export default {
+ usage: {
+ [DEMO_ACTOR]: [
+ { $type: "dev.at-intent.usage", app: READER, lastSeenAt: "2026-06-20T12:00:00Z", createdAt: "2026-03-01T00:00:00Z", config: { defaultCategory: "tech" } },
+ { $type: "dev.at-intent.usage", app: SILL, lastSeenAt: "2026-06-24T09:00:00Z", createdAt: "2026-02-10T00:00:00Z" },
+ ],
+ },
+ capabilities: {
+ [READER]: readerCaps,
+ [SILL]: sillCaps,
+ },
+};
diff --git a/resolver/resolver.mjs b/resolver/resolver.mjs
new file mode 100644
index 0000000..e4138d2
--- /dev/null
+++ b/resolver/resolver.mjs
@@ -0,0 +1,189 @@
+// AT Intents resolver — discover a user's "action graph" from their repo
+// footprint. Read-only, no auth. Runs in Node 18+ and the browser (uses
+// globalThis.fetch). This is the core a CONSUMER app embeds.
+//
+// The loop:
+// actor (handle/DID)
+// -> scan repo for dev.at-intent.usage records (discovery)
+// -> resolve each app DID -> its dev.at-intent.capability records
+// -> assemble (verb, subject, handler) action graph (the triple)
+// matchActions(graph, {verb, subject}) answers "which handler can do X?"
+
+export const USAGE_NSID = "dev.at-intent.usage";
+export const CAPABILITY_NSID = "dev.at-intent.capability";
+
+const PLC_DIRECTORY = "https://plc.directory";
+const HANDLE_RESOLVER = "https://public.api.bsky.app";
+const DEFAULT_STALE_DAYS = 180;
+
+const getFetch = (opts) => opts.fetchImpl || globalThis.fetch;
+
+// --- identity / repo plumbing -------------------------------------------------
+
+export async function resolveDid(actor, opts = {}) {
+ if (actor.startsWith("did:")) return actor;
+ const handle = actor.replace(/^@/, "");
+ const url = `${HANDLE_RESOLVER}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
+ const res = await getFetch(opts)(url);
+ if (!res.ok) throw new Error(`resolveHandle ${handle}: HTTP ${res.status}`);
+ const json = await res.json();
+ if (!json.did) throw new Error(`no DID for handle ${handle}`);
+ return json.did;
+}
+
+export async function resolveDidDoc(did, opts = {}) {
+ const f = getFetch(opts);
+ if (did.startsWith("did:plc:")) {
+ const res = await f(`${PLC_DIRECTORY}/${did}`);
+ if (!res.ok) throw new Error(`plc ${did}: HTTP ${res.status}`);
+ return res.json();
+ }
+ if (did.startsWith("did:web:")) {
+ const host = decodeURIComponent(did.slice("did:web:".length)).replace(/:/g, "/");
+ const res = await f(`https://${host}/.well-known/did.json`);
+ if (!res.ok) throw new Error(`did:web ${did}: HTTP ${res.status}`);
+ return res.json();
+ }
+ throw new Error(`unsupported DID method: ${did}`);
+}
+
+export function pdsFromDidDoc(doc) {
+ const svc = (doc.service || []).find(
+ (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
+ );
+ if (!svc?.serviceEndpoint) throw new Error("no #atproto_pds service in DID doc");
+ return svc.serviceEndpoint;
+}
+
+export async function resolvePds(did, opts = {}) {
+ return pdsFromDidDoc(await resolveDidDoc(did, opts));
+}
+
+export async function listRecords(pds, did, collection, opts = {}) {
+ const f = getFetch(opts);
+ const out = [];
+ let cursor;
+ do {
+ const u = new URL(`${pds}/xrpc/com.atproto.repo.listRecords`);
+ u.searchParams.set("repo", did);
+ u.searchParams.set("collection", collection);
+ u.searchParams.set("limit", "100");
+ if (cursor) u.searchParams.set("cursor", cursor);
+ const res = await f(u);
+ // A repo with no records in this collection answers 400; treat as empty.
+ if (res.status === 400) return out;
+ if (!res.ok) throw new Error(`listRecords ${collection}@${did}: HTTP ${res.status}`);
+ const json = await res.json();
+ for (const r of json.records || []) out.push(r);
+ cursor = json.cursor;
+ } while (cursor);
+ return out;
+}
+
+// --- capability resolution (cached by app DID) --------------------------------
+
+export async function resolveCapabilities(appDid, opts = {}) {
+ const { fixtures, cache } = opts;
+ if (fixtures?.capabilities && appDid in fixtures.capabilities) {
+ return fixtures.capabilities[appDid];
+ }
+ if (cache?.has(appDid)) return cache.get(appDid);
+ const pds = await resolvePds(appDid, opts);
+ const caps = (await listRecords(pds, appDid, CAPABILITY_NSID, opts)).map((r) => r.value);
+ cache?.set(appDid, caps);
+ return caps;
+}
+
+// --- staleness ----------------------------------------------------------------
+
+export function isStale(usage, opts = {}) {
+ const days = opts.staleDays ?? DEFAULT_STALE_DAYS;
+ if (days == null) return false;
+ const ts = usage.lastSeenAt || usage.createdAt;
+ if (!ts) return false;
+ const now = opts.now ? new Date(opts.now) : new Date();
+ return now - new Date(ts) > days * 86_400_000;
+}
+
+// --- the action graph ---------------------------------------------------------
+
+export async function resolveActionGraph(actor, opts = {}) {
+ const { fixtures } = opts;
+ const did = await resolveDid(actor, opts);
+
+ let usageRecords;
+ if (fixtures?.usage && did in fixtures.usage) {
+ usageRecords = fixtures.usage[did];
+ } else {
+ const pds = await resolvePds(did, opts);
+ usageRecords = (await listRecords(pds, did, USAGE_NSID, opts)).map((r) => r.value);
+ }
+
+ // dedupe by app, keeping the freshest signal
+ const byApp = new Map();
+ const freshness = (u) => u.lastSeenAt || u.createdAt || "";
+ for (const u of usageRecords) {
+ const prev = byApp.get(u.app);
+ if (!prev || freshness(u) > freshness(prev)) byApp.set(u.app, u);
+ }
+
+ // resolve each app's capabilities — per-app failure degrades gracefully
+ const handlers = [];
+ for (const [appDid, usage] of byApp) {
+ try {
+ const capabilities = await resolveCapabilities(appDid, opts);
+ handlers.push({ app: appDid, usage, capabilities, stale: isStale(usage, opts) });
+ } catch (err) {
+ handlers.push({ app: appDid, usage, capabilities: [], error: String(err?.message || err) });
+ }
+ }
+
+ // flatten to a queryable action list
+ const actions = [];
+ for (const h of handlers) {
+ for (const cap of h.capabilities) {
+ actions.push({
+ verb: cap.verb,
+ subject: cap.subject || [],
+ handler: h.app,
+ name: cap.name,
+ delivery: cap.delivery,
+ endpoint: cap.endpoint,
+ produces: cap.produces || [],
+ scope: cap.scope,
+ description: cap.description,
+ stale: h.stale,
+ });
+ }
+ }
+
+ return { actor, did, handlers, actions };
+}
+
+// --- matching: "which handler can do (verb, subject)?" ------------------------
+
+export function subjectMatches(spec, query) {
+ if (spec.kind !== query.kind) return false;
+ if (spec.kind === "at-uri" && spec.of && query.of && spec.of !== query.of) return false;
+ return (spec.as || "record") === (query.as || "record");
+}
+
+export function matchActions(graph, { verb, subject, includeStale = false } = {}) {
+ return graph.actions.filter((a) => {
+ if (a.stale && !includeStale) return false;
+ if (verb && a.verb !== verb) return false;
+ if (subject && !(a.subject || []).some((s) => subjectMatches(s, subject))) return false;
+ return true;
+ });
+}
+
+// --- scope aggregation: what a consumer must request to enable these ----------
+
+// Union of include: permission sets across pds-delivery actions.
+export function requiredScopes(actions) {
+ const set = new Set();
+ for (const a of actions) {
+ if (a.delivery === "pds" && a.scope) set.add(a.scope);
+ }
+ return [...set];
+}
diff --git a/tools/README.md b/tools/README.md
new file mode 100644
index 0000000..74fc5ee
--- /dev/null
+++ b/tools/README.md
@@ -0,0 +1,65 @@
+# Tools
+
+CLI tooling to author AT Intents records. **Node 18+, zero dependencies** — no
+install.
+
+## Write a capability record
+
+From a JSON file — the literal `dev.at-intent.capability` shape, with `$type` /
+`createdAt` optional (see [capability-spec.md](../docs/capability-spec.md)):
+
+```bash
+# Validate + preview, write nothing:
+node write-capability.mjs examples/subscribe.capability.json --dry-run --identifier myapp.com
+
+# Publish to the APP's repo (capability records live in the app's own repo):
+PDS_IDENTIFIER=myapp.com PDS_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx \
+ node write-capability.mjs examples/subscribe.capability.json
+```
+
+A file may hold one record, a top-level array, or `{ "capabilities": [...] }`.
+Interactively (prompts for each field, saves a reusable JSON record, dry-runs,
+then writes on confirmation):
+
+```bash
+node write-capability-interactive.mjs --out my.capability.json
+```
+
+Flags: `--dry-run` `--json` `--force` (write despite warnings) `--identifier`
+`--password` `--pds` `--rkey`.
+
+## Write a usage record
+
+The per-user discovery footprint — normally written by the app for the user; this
+is for manual/testing use (auth as the **user**, since it lands in their repo):
+
+```bash
+node write-usage.mjs --app did:web:myapp.com \
+ --config '{"defaultCategory":"tech"}' --dry-run --identifier alice.bsky.social
+```
+
+## Auth
+
+Writes use an **app password** via `com.atproto.server.createSession` against the
+account's own PDS (resolved from the handle/DID). Provide credentials with
+`--identifier`/`--password` or the `PDS_IDENTIFIER` / `PDS_APP_PASSWORD`
+environment variables. Get an app password from your PDS settings — **never use
+your main account password.** `--dry-run` needs no credentials.
+
+## Layout
+
+```
+lib/
+ nsid.mjs the two NSIDs + authority (single source of truth)
+ atproto.mjs resolve handle/DID/PDS; createSession; createRecord (zero-dep)
+ spec.mjs load a JSON record file; normalize to a capability list
+ capability.mjs validate + build a capability record (incl. delivery rules)
+ usage.mjs validate + build a usage record
+ cli.mjs colors, validation reporting, dry-run printing, auth-from-args
+write-capability.mjs from a JSON record file
+write-capability-interactive.mjs prompt-driven
+write-usage.mjs usage footprint
+examples/ one record per delivery type + a multi-cap file
+```
+
+The consumer-side resolver lives in [`../resolver/`](../resolver/).
diff --git a/tools/examples/all.capabilities.json b/tools/examples/all.capabilities.json
new file mode 100644
index 0000000..c30aa26
--- /dev/null
+++ b/tools/examples/all.capabilities.json
@@ -0,0 +1,42 @@
+{
+ "_comment": "A multi-capability file: publish an app's whole surface in one run with `node write-capability.mjs examples/all.capabilities.json`. Use a `capabilities` array (or a bare top-level JSON array).",
+ "capabilities": [
+ {
+ "name": "Skyreader",
+ "icon": "https://skyreader.app/icon.png",
+ "description": "Subscribe to a feed, publication, or account so new items appear in your Skyreader reading list.",
+ "verb": "subscribe",
+ "subject": [
+ { "kind": "uri" },
+ { "kind": "at-uri", "of": "site.standard.publication" },
+ { "kind": "did", "as": "account" }
+ ],
+ "produces": ["app.skyreader.feed.subscription"],
+ "delivery": "pds",
+ "scope": "include:app.skyreader.subscribe"
+ },
+ {
+ "name": "Skyreader Linkblog",
+ "icon": "https://skyreader.app/icon.png",
+ "description": "Post a link to your standard.site linkblog with an optional note.",
+ "verb": "share",
+ "subject": [ { "kind": "uri" } ],
+ "input": [
+ { "name": "title", "kind": "string" },
+ { "name": "note", "kind": "string" }
+ ],
+ "produces": ["site.standard.document"],
+ "delivery": "pds",
+ "scope": "include:app.skyreader.linkblog"
+ },
+ {
+ "name": "Skyreader",
+ "icon": "https://skyreader.app/icon.png",
+ "description": "Save an article to your Skyreader reading list (read-it-later).",
+ "verb": "save",
+ "subject": [ { "kind": "uri" } ],
+ "delivery": "service",
+ "endpoint": "https://skyreader.app/xrpc/app.skyreader.feed.save"
+ }
+ ]
+}
diff --git a/tools/examples/passive-reader.capability.json b/tools/examples/passive-reader.capability.json
new file mode 100644
index 0000000..73d3c65
--- /dev/null
+++ b/tools/examples/passive-reader.capability.json
@@ -0,0 +1,11 @@
+{
+ "_comment": "A `passive` capability — the zero-cost case. The app reads a primitive ALREADY in the user's repo (the community bookmark). No write, no scope: discovery alone surfaces it as a destination. (_comment is ignored by the writer.)",
+ "name": "Sill",
+ "icon": "https://sill.social/icon.png",
+ "description": "Your saved bookmarks, surfaced for daily review. Sill reads the community bookmark already in your repo — nothing is written.",
+ "verb": "save",
+ "subject": [
+ { "kind": "at-uri", "of": "community.lexicon.bookmarks.bookmark" }
+ ],
+ "delivery": "passive"
+}
diff --git a/tools/examples/save-service.capability.json b/tools/examples/save-service.capability.json
new file mode 100644
index 0000000..e234908
--- /dev/null
+++ b/tools/examples/save-service.capability.json
@@ -0,0 +1,16 @@
+{
+ "_comment": "A `save` capability with delivery=service. The app's canonical store is its own backend, NOT the PDS — so a record written to the user's repo would never reach it. It declares an endpoint and produces nothing on the PDS. This is the case the bookmark-centric model got silently wrong. (_comment is ignored by the writer.)",
+ "name": "Skyreader",
+ "icon": "https://skyreader.app/icon.png",
+ "description": "Save an article to your Skyreader reading list (read-it-later). Delivered to Skyreader's backend; nothing is written to your repo.",
+ "verb": "save",
+ "subject": [
+ { "kind": "uri" }
+ ],
+ "input": [
+ { "name": "title", "kind": "string" },
+ { "name": "author", "kind": "string" }
+ ],
+ "delivery": "service",
+ "endpoint": "https://skyreader.app/xrpc/app.skyreader.feed.save"
+}
diff --git a/tools/examples/subscribe.capability.json b/tools/examples/subscribe.capability.json
new file mode 100644
index 0000000..fb74e63
--- /dev/null
+++ b/tools/examples/subscribe.capability.json
@@ -0,0 +1,19 @@
+{
+ "_comment": "A `subscribe` capability with delivery=pds. The consumer writes a `produces` record into the user's repo; the app reads it. Exercises a union subject: a bare feed URL (no atproto type), a publication AT-URI, and an account DID — matches if ANY entry fits. The _comment field is ignored by the writer.",
+ "name": "Skyreader",
+ "icon": "https://skyreader.app/icon.png",
+ "description": "Subscribe to a feed, a standard.site publication, or an account so its new items appear in your Skyreader reading list. Use for following a source over time; not for saving a single article (that's `save`).",
+ "verb": "subscribe",
+ "subject": [
+ { "kind": "uri" },
+ { "kind": "at-uri", "of": "site.standard.publication" },
+ { "kind": "did", "as": "account" }
+ ],
+ "input": [
+ { "name": "category", "kind": "string", "description": "Optional folder to file the subscription under." },
+ { "name": "tags", "kind": "string" }
+ ],
+ "produces": ["app.skyreader.feed.subscription"],
+ "delivery": "pds",
+ "scope": "include:app.skyreader.subscribe"
+}
diff --git a/tools/lib/atproto.mjs b/tools/lib/atproto.mjs
new file mode 100644
index 0000000..6187e49
--- /dev/null
+++ b/tools/lib/atproto.mjs
@@ -0,0 +1,94 @@
+// Minimal, zero-dependency atproto plumbing for the write CLIs.
+//
+// Reads use public endpoints (handle->DID, DID doc->PDS). Writes use an app
+// password: createSession against the user's own PDS, then createRecord with
+// the returned access token. No SDK — these are plain XRPC calls over fetch.
+//
+// Get an app password at: /settings/app-passwords (on Bluesky:
+// Settings -> Privacy and security -> App passwords). NEVER use your main
+// account password here.
+
+const PLC_DIRECTORY = "https://plc.directory";
+const HANDLE_RESOLVER = "https://public.api.bsky.app";
+
+// --- identity / PDS resolution ------------------------------------------------
+
+export async function resolveDid(actor) {
+ if (actor.startsWith("did:")) return actor;
+ const handle = actor.replace(/^@/, "");
+ const url = `${HANDLE_RESOLVER}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`;
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`resolveHandle ${handle}: HTTP ${res.status}`);
+ const json = await res.json();
+ if (!json.did) throw new Error(`no DID for handle ${handle}`);
+ return json.did;
+}
+
+export async function resolveDidDoc(did) {
+ if (did.startsWith("did:plc:")) {
+ const res = await fetch(`${PLC_DIRECTORY}/${did}`);
+ if (!res.ok) throw new Error(`plc ${did}: HTTP ${res.status}`);
+ return res.json();
+ }
+ if (did.startsWith("did:web:")) {
+ const host = decodeURIComponent(did.slice("did:web:".length)).replace(/:/g, "/");
+ const res = await fetch(`https://${host}/.well-known/did.json`);
+ if (!res.ok) throw new Error(`did:web ${did}: HTTP ${res.status}`);
+ return res.json();
+ }
+ throw new Error(`unsupported DID method: ${did}`);
+}
+
+export function pdsFromDidDoc(doc) {
+ const svc = (doc.service || []).find(
+ (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
+ );
+ if (!svc?.serviceEndpoint) throw new Error("no #atproto_pds service in DID doc");
+ return svc.serviceEndpoint;
+}
+
+export async function resolvePds(didOrHandle) {
+ const did = await resolveDid(didOrHandle);
+ return pdsFromDidDoc(await resolveDidDoc(did));
+}
+
+// --- authenticated session (app password) -------------------------------------
+
+// Returns { pds, did, accessJwt, handle }. If `pds` is given we skip resolution
+// (useful when a handle's DID doc is unreachable but you know the host).
+export async function createSession({ identifier, password, pds }) {
+ if (!identifier) throw new Error("missing identifier (handle or DID)");
+ if (!password) throw new Error("missing app password");
+ const host = pds || (await resolvePds(identifier));
+ const res = await fetch(`${host}/xrpc/com.atproto.server.createSession`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ identifier, password }),
+ });
+ if (!res.ok) {
+ const body = await res.text().catch(() => "");
+ throw new Error(`createSession failed: HTTP ${res.status} ${body}`);
+ }
+ const json = await res.json();
+ return { pds: host, did: json.did, accessJwt: json.accessJwt, handle: json.handle };
+}
+
+// Create a record in the session's repo. `rkey` optional (server assigns a TID
+// when omitted). Returns { uri, cid }.
+export async function createRecord(session, { collection, record, rkey }) {
+ const body = { repo: session.did, collection, record };
+ if (rkey) body.rkey = rkey;
+ const res = await fetch(`${session.pds}/xrpc/com.atproto.repo.createRecord`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${session.accessJwt}`,
+ },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw new Error(`createRecord ${collection} failed: HTTP ${res.status} ${text}`);
+ }
+ return res.json();
+}
diff --git a/tools/lib/capability.mjs b/tools/lib/capability.mjs
new file mode 100644
index 0000000..62933f5
--- /dev/null
+++ b/tools/lib/capability.mjs
@@ -0,0 +1,100 @@
+// Validate + build a dev.at-intent.capability record from a parsed JSON object.
+//
+// Validation mirrors the lexicon (../../lexicons/dev/at-intent/capability.json)
+// plus the cross-field `delivery` rules that the JSON Schema can't express.
+// Errors block a write; warnings are advisory (descriptor quality, open-set
+// verbs, scope hygiene). Pass --force to write despite warnings.
+
+import { CAPABILITY_NSID } from "./nsid.mjs";
+
+const VERBS = ["share", "save", "subscribe", "annotate", "open"];
+const SUBJECT_KINDS = ["uri", "at-uri", "did", "string", "blob"];
+const SUBJECT_AS = ["record", "collection", "account"];
+const INPUT_KINDS = ["string", "uri", "at-uri", "did", "integer", "boolean", "blob"];
+const DELIVERY = ["passive", "pds", "service"];
+
+const RECORD_FIELDS = [
+ "name", "icon", "description", "verb", "subject",
+ "input", "produces", "delivery", "endpoint", "scope", "createdAt",
+];
+
+// graphemes ~ code points; good enough for the maxGraphemes checks here.
+const len = (s) => [...String(s)].length;
+
+export function validateCapability(cap) {
+ const errors = [];
+ const warnings = [];
+
+ if (!cap || typeof cap !== "object") {
+ return { errors: ["capability is not an object"], warnings };
+ }
+
+ // --- name ---
+ if (!cap.name || typeof cap.name !== "string") errors.push("name: required string");
+ else if (len(cap.name) > 64) errors.push("name: max 64 graphemes");
+
+ // --- verb ---
+ if (!cap.verb || typeof cap.verb !== "string") errors.push("verb: required string");
+ else if (!VERBS.includes(cap.verb))
+ warnings.push(`verb: "${cap.verb}" is outside the known set (${VERBS.join(", ")}); consumers that don't know it will skip this capability`);
+
+ // --- subject ---
+ if (!Array.isArray(cap.subject) || cap.subject.length === 0) {
+ errors.push("subject: required non-empty array");
+ } else {
+ cap.subject.forEach((s, i) => {
+ if (!s || typeof s !== "object") { errors.push(`subject[${i}]: not an object`); return; }
+ if (!SUBJECT_KINDS.includes(s.kind)) errors.push(`subject[${i}].kind: must be one of ${SUBJECT_KINDS.join(", ")}`);
+ if (s.as && !SUBJECT_AS.includes(s.as)) errors.push(`subject[${i}].as: must be one of ${SUBJECT_AS.join(", ")}`);
+ if (s.kind === "at-uri" && !s.of) warnings.push(`subject[${i}]: kind=at-uri without "of" matches any record type — set "of" to a lexicon NSID to be specific`);
+ if (s.of && s.kind !== "at-uri") warnings.push(`subject[${i}]: "of" is only meaningful for kind=at-uri`);
+ });
+ }
+
+ // --- input ---
+ if (cap.input != null) {
+ if (!Array.isArray(cap.input)) errors.push("input: must be an array");
+ else cap.input.forEach((f, i) => {
+ if (!f || typeof f !== "object") { errors.push(`input[${i}]: not an object`); return; }
+ if (!f.name) errors.push(`input[${i}].name: required`);
+ if (!INPUT_KINDS.includes(f.kind)) errors.push(`input[${i}].kind: must be one of ${INPUT_KINDS.join(", ")}`);
+ });
+ }
+
+ // --- produces ---
+ if (cap.produces != null && !Array.isArray(cap.produces)) errors.push("produces: must be an array of NSIDs");
+
+ // --- delivery (cross-field rules) ---
+ if (!DELIVERY.includes(cap.delivery)) {
+ errors.push(`delivery: required, must be one of ${DELIVERY.join(", ")}`);
+ } else if (cap.delivery === "service") {
+ if (!cap.endpoint) errors.push("delivery=service requires an endpoint (the URL that performs the action)");
+ if (cap.produces?.length) warnings.push("delivery=service usually produces nothing on the PDS; the handler ingests out-of-band");
+ if (cap.scope) warnings.push("delivery=service does not need a PDS scope");
+ } else if (cap.delivery === "pds") {
+ if (!cap.produces?.length) warnings.push("delivery=pds normally lists the record type(s) it writes in `produces`");
+ if (!cap.scope) warnings.push("delivery=pds normally declares a `scope` (include: permission set) so consumers can request it");
+ if (cap.endpoint) warnings.push("delivery=pds ignores `endpoint`");
+ } else if (cap.delivery === "passive") {
+ if (cap.produces?.length) warnings.push("delivery=passive writes nothing; drop `produces`");
+ if (cap.scope) warnings.push("delivery=passive needs no scope; drop it (zero-cost discovery)");
+ if (cap.endpoint) warnings.push("delivery=passive ignores `endpoint`");
+ }
+
+ // --- description quality (matters for both human picker and agent planner) ---
+ if (!cap.description) warnings.push("description: missing — agents and humans both rely on it; say what it does, when to use it, what it won't do");
+ else if (len(cap.description) > 300) errors.push("description: max 300 graphemes");
+
+ if (cap.icon && !/^https?:\/\//.test(cap.icon)) warnings.push("icon: should be an http(s) URL");
+
+ return { errors, warnings };
+}
+
+export function buildCapabilityRecord(cap, now) {
+ const record = { $type: CAPABILITY_NSID };
+ for (const f of RECORD_FIELDS) {
+ if (cap[f] !== undefined) record[f] = cap[f];
+ }
+ if (!record.createdAt) record.createdAt = now || new Date().toISOString();
+ return record;
+}
diff --git a/tools/lib/cli.mjs b/tools/lib/cli.mjs
new file mode 100644
index 0000000..1cb9cba
--- /dev/null
+++ b/tools/lib/cli.mjs
@@ -0,0 +1,42 @@
+// Shared CLI helpers: colors, validation reporting, dry-run printing, and
+// pulling auth out of flags/env.
+
+export const C = {
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
+};
+
+// Print errors/warnings for one record. Returns true if it has blocking errors.
+export function report(label, { errors, warnings }) {
+ if (errors.length) {
+ console.error(`\n${C.red("✗")} ${C.bold(label)} — ${errors.length} error(s):`);
+ for (const e of errors) console.error(` ${C.red("•")} ${e}`);
+ }
+ if (warnings.length) {
+ console.error(`\n${C.yellow("!")} ${C.bold(label)} — ${warnings.length} warning(s):`);
+ for (const w of warnings) console.error(` ${C.yellow("•")} ${w}`);
+ }
+ if (!errors.length && !warnings.length) console.error(`${C.green("✓")} ${label} — looks good`);
+ return errors.length > 0;
+}
+
+// Show exactly what a write would do, without doing it.
+export function printDryRun({ pds, did, collection, record }) {
+ console.log(`\n${C.dim("# dry-run — no record written")}`);
+ console.log(`${C.bold("POST")} ${pds || ""}/xrpc/com.atproto.repo.createRecord`);
+ console.log(C.dim(`# as repo: ${did || ""} collection: ${collection}`));
+ console.log(JSON.stringify({ repo: did || "", collection, record }, null, 2));
+}
+
+// identifier/password from flags first, then env. (PDS_IDENTIFIER / PDS_APP_PASSWORD)
+export function authFromArgs(a) {
+ return {
+ identifier: a.identifier || process.env.PDS_IDENTIFIER || process.env.PDS_HANDLE,
+ password: a.password || process.env.PDS_APP_PASSWORD,
+ pds: a.pds || process.env.PDS_URL,
+ };
+}
diff --git a/tools/lib/nsid.mjs b/tools/lib/nsid.mjs
new file mode 100644
index 0000000..4964b8a
--- /dev/null
+++ b/tools/lib/nsid.mjs
@@ -0,0 +1,12 @@
+// The canonical NSIDs for the AT Intents records. Change these two constants
+// (and the lexicon `id` fields under ../../lexicons/) to re-home the namespace.
+//
+// Authority domain: at-intent.dev. atproto resolves a record's lexicon by
+// reversing the NSID authority into a domain, resolving that to a DID, and
+// reading the lexicon from there — so these names only resolve on the live
+// network once at-intent.dev publishes the lexicons. The *records themselves*
+// live in each app's repo under these collection NSIDs.
+
+export const AUTHORITY = "at-intent.dev";
+export const CAPABILITY_NSID = "dev.at-intent.capability";
+export const USAGE_NSID = "dev.at-intent.usage";
diff --git a/tools/lib/spec.mjs b/tools/lib/spec.mjs
new file mode 100644
index 0000000..1eb7c64
--- /dev/null
+++ b/tools/lib/spec.mjs
@@ -0,0 +1,19 @@
+// Load capability record(s) from a JSON file.
+//
+// The file is just the capability record as JSON — the same shape as the lexicon
+// (../../lexicons/dev/at-intent/capability.json). `$type` and `createdAt` are
+// optional; the writer fills them in. A file may hold a single record object, a
+// top-level array, or `{ "capabilities": [ ... ] }` to publish several at once.
+
+import { readFile } from "node:fs/promises";
+
+export async function loadRecordFile(path) {
+ return JSON.parse(await readFile(path, "utf8"));
+}
+
+export function capabilitiesFrom(parsed) {
+ if (Array.isArray(parsed)) return parsed;
+ if (parsed && Array.isArray(parsed.capabilities)) return parsed.capabilities;
+ if (parsed && typeof parsed === "object") return [parsed];
+ throw new Error("file is not a capability object, an array, or { capabilities: [...] }");
+}
diff --git a/tools/lib/usage.mjs b/tools/lib/usage.mjs
new file mode 100644
index 0000000..d4b33b8
--- /dev/null
+++ b/tools/lib/usage.mjs
@@ -0,0 +1,29 @@
+// Validate + build a dev.at-intent.usage record. Written into a USER's repo by
+// an app when the person uses it — the discovery footprint a consumer scans.
+
+import { USAGE_NSID } from "./nsid.mjs";
+
+const RECORD_FIELDS = ["app", "capability", "config", "lastSeenAt", "createdAt"];
+
+export function validateUsage(u) {
+ const errors = [];
+ const warnings = [];
+ if (!u || typeof u !== "object") return { errors: ["usage is not an object"], warnings };
+
+ if (!u.app || typeof u.app !== "string") errors.push("app: required DID of the handler");
+ else if (!u.app.startsWith("did:")) errors.push("app: must be a DID (did:plc:... or did:web:...)");
+
+ if (u.capability && !u.capability.startsWith("at://")) errors.push("capability: must be an AT-URI");
+ if (u.lastSeenAt && Number.isNaN(Date.parse(u.lastSeenAt))) errors.push("lastSeenAt: not a valid datetime");
+
+ return { errors, warnings };
+}
+
+export function buildUsageRecord(u, now) {
+ const record = { $type: USAGE_NSID };
+ for (const f of RECORD_FIELDS) {
+ if (u[f] !== undefined) record[f] = u[f];
+ }
+ if (!record.createdAt) record.createdAt = now || new Date().toISOString();
+ return record;
+}
diff --git a/tools/package.json b/tools/package.json
new file mode 100644
index 0000000..9f6c0bc
--- /dev/null
+++ b/tools/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "at-intent-tools",
+ "version": "0.1.0",
+ "type": "module",
+ "private": true,
+ "description": "CLI tooling for the AT Intents proposal: write capability/usage records, resolve a repo's action graph. Zero dependencies.",
+ "bin": {
+ "at-intent-capability": "./write-capability.mjs",
+ "at-intent-capability-interactive": "./write-capability-interactive.mjs",
+ "at-intent-usage": "./write-usage.mjs",
+ "at-intent-resolve": "../resolver/cli.mjs"
+ },
+ "scripts": {
+ "capability": "node write-capability.mjs",
+ "interactive": "node write-capability-interactive.mjs",
+ "usage": "node write-usage.mjs",
+ "resolve": "node ../resolver/cli.mjs"
+ }
+}
diff --git a/tools/write-capability-interactive.mjs b/tools/write-capability-interactive.mjs
new file mode 100755
index 0000000..7401896
--- /dev/null
+++ b/tools/write-capability-interactive.mjs
@@ -0,0 +1,160 @@
+#!/usr/bin/env node
+// Build a dev.at-intent.capability record by answering prompts. Writes a JSON
+// record file you can keep, then validates, prints a dry-run, and (with
+// confirmation) writes the record.
+//
+// node write-capability-interactive.mjs [--out record.json]
+//
+// Auth (only needed if you choose to write): --identifier / --password, or
+// PDS_IDENTIFIER / PDS_APP_PASSWORD env vars.
+
+import { writeFile } from "node:fs/promises";
+import { createInterface } from "node:readline/promises";
+import { stdin, stdout } from "node:process";
+import { validateCapability, buildCapabilityRecord } from "./lib/capability.mjs";
+import { CAPABILITY_NSID } from "./lib/nsid.mjs";
+import { createSession, createRecord } from "./lib/atproto.mjs";
+import { C, report, printDryRun, authFromArgs } from "./lib/cli.mjs";
+
+const rl = createInterface({ input: stdin, output: stdout });
+const ask = async (q, dflt) => {
+ const ans = (await rl.question(dflt ? `${q} ${C.dim(`[${dflt}]`)} ` : `${q} `)).trim();
+ return ans || dflt || "";
+};
+const askYN = async (q, dflt = false) => {
+ const ans = (await ask(`${q} ${C.dim(dflt ? "(Y/n)" : "(y/N)")}`)).toLowerCase();
+ if (!ans) return dflt;
+ return ans.startsWith("y");
+};
+
+function parseArgs(argv) {
+ const a = { _: [] };
+ for (let i = 0; i < argv.length; i++) {
+ const t = argv[i];
+ if (t === "--out") a.out = argv[++i];
+ else if (t === "--identifier") a.identifier = argv[++i];
+ else if (t === "--password") a.password = argv[++i];
+ else if (t === "--pds") a.pds = argv[++i];
+ else a._.push(t);
+ }
+ return a;
+}
+
+async function collectSubjects() {
+ console.log(C.dim("\nSubjects — what this capability accepts (a union; matches if ANY entry fits)."));
+ console.log(C.dim("kinds: uri (a bare web/feed URL) | at-uri (a record) | did (an account) | string | blob"));
+ const subjects = [];
+ do {
+ const kind = await ask(` subject kind ${C.dim("(uri/at-uri/did/string/blob)")}:`, "uri");
+ const s = { kind };
+ if (kind === "at-uri") {
+ const of = await ask(" of (lexicon NSID, e.g. site.standard.publication):");
+ if (of) s.of = of;
+ }
+ if (kind === "did" || kind === "at-uri") {
+ const as = await ask(` as ${C.dim("(record/collection/account)")}:`, "record");
+ if (as && as !== "record") s.as = as;
+ }
+ subjects.push(s);
+ } while (await askYN(" add another subject?"));
+ return subjects;
+}
+
+async function collectInputs() {
+ if (!(await askYN("\nAdd named inputs beyond the subject (a note, title, target collection)?"))) return [];
+ console.log(C.dim("kinds: string | uri | at-uri | did | integer | boolean | blob"));
+ const inputs = [];
+ do {
+ const name = await ask(" input name:");
+ if (!name) break;
+ const kind = await ask(" kind:", "string");
+ const field = { name, kind };
+ const desc = await ask(" description (optional):");
+ if (desc) field.description = desc;
+ if (await askYN(" required?")) field.required = true;
+ inputs.push(field);
+ } while (await askYN(" add another input?"));
+ return inputs;
+}
+
+async function main() {
+ const a = parseArgs(process.argv.slice(2));
+ console.log(C.bold("\nAT Intents — interactive capability builder\n"));
+ console.log(C.dim("Answer the prompts to describe one capability your app provides.\n"));
+
+ const cap = {};
+ cap.name = await ask("Display name (e.g. 'Skyreader'):");
+ const desc = await ask("Description (what it does, when to use it, what it won't do):");
+ if (desc) cap.description = desc;
+ const icon = await ask("Icon URL (optional):");
+ if (icon) cap.icon = icon;
+
+ cap.verb = await ask(`Verb ${C.dim("(share/save/subscribe/annotate/open, or your own)")}:`, "share");
+ cap.subject = await collectSubjects();
+
+ console.log(C.dim("\nDelivery — how the action actually reaches your app:"));
+ console.log(C.dim(" passive your app READS a record already in the user's repo (zero-cost)"));
+ console.log(C.dim(" pds the consumer WRITES record(s) to the user's repo; your app reads them"));
+ console.log(C.dim(" service the consumer must CALL your endpoint; a PDS write won't reach you"));
+ cap.delivery = await ask("delivery (passive/pds/service):", "pds");
+
+ if (cap.delivery === "service") {
+ cap.endpoint = await ask(" endpoint URL (the API/XRPC that performs the action):");
+ }
+ if (cap.delivery === "pds") {
+ const produces = await ask(" produces — record NSID(s) you write, comma-separated:");
+ if (produces) cap.produces = produces.split(",").map((s) => s.trim()).filter(Boolean);
+ cap.scope = await ask(" scope (include: permission set):");
+ }
+
+ const inputs = await collectInputs();
+ if (inputs.length) cap.input = inputs;
+
+ // validate
+ const v = validateCapability(cap);
+ report(`${cap.name} [${cap.verb}]`, v);
+
+ // build the record (adds $type + createdAt)
+ const record = buildCapabilityRecord(cap);
+
+ // offer to save the record JSON — directly re-runnable with write-capability.mjs
+ const out = a.out || (await ask("\nSave this record to a JSON file? (path, or blank to skip):"));
+ if (out) {
+ await writeFile(out, JSON.stringify(record, null, 2) + "\n");
+ console.log(`${C.green("✓ saved")} ${out}`);
+ }
+
+ // dry-run preview
+ const { identifier, password, pds } = authFromArgs(a);
+ printDryRun({ pds, did: identifier, collection: CAPABILITY_NSID, record });
+
+ if (v.errors.length) {
+ console.log(C.red("\nErrors above must be fixed before this can be written. Edit the JSON file and use write-capability.mjs.\n"));
+ rl.close();
+ return;
+ }
+
+ if (!(await askYN("\nWrite this record to the PDS now?"))) {
+ console.log(C.dim("\nNothing written. Run `node write-capability.mjs ` later to write it.\n"));
+ rl.close();
+ return;
+ }
+
+ rl.close();
+ let session;
+ try {
+ session = await createSession({ identifier, password, pds });
+ } catch (err) {
+ console.error(C.red(`\nauth failed: ${err.message}`));
+ console.error(C.dim("Set --identifier/--password or PDS_IDENTIFIER/PDS_APP_PASSWORD and retry with write-capability.mjs.\n"));
+ return;
+ }
+ try {
+ const res = await createRecord(session, { collection: CAPABILITY_NSID, record });
+ console.log(`${C.green("\n✓ wrote")} ${C.cyan(res.uri)}\n`);
+ } catch (err) {
+ console.error(C.red(`\nwrite failed: ${err.message}\n`));
+ }
+}
+
+main();
diff --git a/tools/write-capability.mjs b/tools/write-capability.mjs
new file mode 100755
index 0000000..ea7d030
--- /dev/null
+++ b/tools/write-capability.mjs
@@ -0,0 +1,124 @@
+#!/usr/bin/env node
+// Write dev.at-intent.capability record(s) from a JSON file.
+//
+// node write-capability.mjs [options]
+//
+// The file is a capability record (the dev.at-intent.capability shape); `$type`
+// and `createdAt` are optional and filled in. It may be one record, an array, or
+// { "capabilities": [ ... ] }.
+//
+// Options:
+// --dry-run validate and print the createRecord call; write nothing
+// --json print the built record(s) as JSON and exit
+// --identifier handle or DID to auth as (env: PDS_IDENTIFIER)
+// --password app password (env: PDS_APP_PASSWORD)
+// --pds PDS endpoint override (env: PDS_URL)
+// --rkey record key for a single capability (default: server TID)
+// --force write even if there are warnings (errors always block)
+//
+// Capability records are authored by the APP, in the APP's own repo — so
+// --identifier is the app's account. Example:
+// PDS_IDENTIFIER=myapp.com PDS_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx \
+// node write-capability.mjs examples/subscribe.capability.json
+
+import { loadRecordFile, capabilitiesFrom } from "./lib/spec.mjs";
+import { validateCapability, buildCapabilityRecord } from "./lib/capability.mjs";
+import { CAPABILITY_NSID } from "./lib/nsid.mjs";
+import { createSession, createRecord } from "./lib/atproto.mjs";
+import { C, report, printDryRun, authFromArgs } from "./lib/cli.mjs";
+
+function parseArgs(argv) {
+ const a = { _: [] };
+ for (let i = 0; i < argv.length; i++) {
+ const t = argv[i];
+ if (t === "--dry-run") a.dryRun = true;
+ else if (t === "--json") a.json = true;
+ else if (t === "--force") a.force = true;
+ else if (t === "--identifier") a.identifier = argv[++i];
+ else if (t === "--password") a.password = argv[++i];
+ else if (t === "--pds") a.pds = argv[++i];
+ else if (t === "--rkey") a.rkey = argv[++i];
+ else a._.push(t);
+ }
+ return a;
+}
+
+async function main() {
+ const a = parseArgs(process.argv.slice(2));
+ const file = a._[0];
+ if (!file) {
+ console.error("usage: node write-capability.mjs [--dry-run] [--identifier h] [--password pw]");
+ process.exit(1);
+ }
+
+ let caps;
+ try {
+ caps = capabilitiesFrom(await loadRecordFile(file));
+ } catch (err) {
+ console.error(C.red(`\ncould not load ${file}: ${err.message}\n`));
+ process.exit(1);
+ }
+ if (a.rkey && caps.length > 1) {
+ console.error(C.red("--rkey only works with a single-capability file"));
+ process.exit(1);
+ }
+
+ // validate all, build records
+ const now = new Date().toISOString();
+ let blocked = false;
+ let warned = false;
+ const records = [];
+ caps.forEach((cap, i) => {
+ const label = `${cap.name || "(unnamed)"} [${cap.verb || "?"}]`;
+ const v = validateCapability(cap);
+ if (report(label, v)) blocked = true;
+ if (v.warnings.length) warned = true;
+ records.push(buildCapabilityRecord(cap, now));
+ });
+
+ if (blocked) {
+ console.error(C.red("\nFix the errors above before writing.\n"));
+ process.exit(1);
+ }
+
+ if (a.json) {
+ console.log(JSON.stringify(records.length === 1 ? records[0] : records, null, 2));
+ return;
+ }
+
+ if (a.dryRun) {
+ const { identifier, pds } = authFromArgs(a);
+ for (const record of records) {
+ printDryRun({ pds, did: identifier, collection: CAPABILITY_NSID, record });
+ }
+ console.log(C.dim(`\n${records.length} record(s) validated. Re-run without --dry-run to write.\n`));
+ return;
+ }
+
+ if (warned && !a.force) {
+ console.error(C.yellow("\nThere are warnings. Re-run with --force to write anyway, or --dry-run to inspect.\n"));
+ process.exit(1);
+ }
+
+ const { identifier, password, pds } = authFromArgs(a);
+ let session;
+ try {
+ session = await createSession({ identifier, password, pds });
+ } catch (err) {
+ console.error(C.red(`\nauth failed: ${err.message}\n`));
+ process.exit(1);
+ }
+ console.error(C.dim(`\nauthed as ${session.handle || session.did} @ ${session.pds}`));
+
+ for (const record of records) {
+ try {
+ const res = await createRecord(session, { collection: CAPABILITY_NSID, record, rkey: a.rkey });
+ console.log(`${C.green("✓ wrote")} ${record.name} ${C.dim(record.verb)} ${C.cyan(res.uri)}`);
+ } catch (err) {
+ console.error(C.red(`✗ ${record.name}: ${err.message}`));
+ }
+ }
+ console.log();
+}
+
+main();
diff --git a/tools/write-usage.mjs b/tools/write-usage.mjs
new file mode 100755
index 0000000..59aab26
--- /dev/null
+++ b/tools/write-usage.mjs
@@ -0,0 +1,102 @@
+#!/usr/bin/env node
+// Write a dev.at-intent.usage record into a USER's repo — the discovery
+// footprint a consumer scans. In production an app writes this for the user
+// when they start using it; this script is for manual/testing use.
+//
+// node write-usage.mjs --app [options]
+//
+// Options:
+// --app the handler the user uses (required)
+// --capability a specific capability record (optional)
+// --config per-user config blob, e.g. '{"defaultCategory":"tech"}'
+// --last-seen lastSeenAt datetime (default: now)
+// --dry-run validate and print; write nothing
+// --json print the built record and exit
+// --identifier USER handle/DID to auth as (env: PDS_IDENTIFIER)
+// --password app password (env: PDS_APP_PASSWORD)
+// --pds PDS override (env: PDS_URL)
+// --force write even with warnings
+//
+// Note: the auth identity is the USER (the record lands in their repo), not the
+// app — unlike write-capability.mjs.
+
+import { validateUsage, buildUsageRecord } from "./lib/usage.mjs";
+import { USAGE_NSID } from "./lib/nsid.mjs";
+import { createSession, createRecord } from "./lib/atproto.mjs";
+import { C, report, printDryRun, authFromArgs } from "./lib/cli.mjs";
+
+function parseArgs(argv) {
+ const a = { _: [] };
+ for (let i = 0; i < argv.length; i++) {
+ const t = argv[i];
+ if (t === "--app") a.app = argv[++i];
+ else if (t === "--capability") a.capability = argv[++i];
+ else if (t === "--config") a.config = argv[++i];
+ else if (t === "--last-seen") a.lastSeen = argv[++i];
+ else if (t === "--dry-run") a.dryRun = true;
+ else if (t === "--json") a.json = true;
+ else if (t === "--force") a.force = true;
+ else if (t === "--identifier") a.identifier = argv[++i];
+ else if (t === "--password") a.password = argv[++i];
+ else if (t === "--pds") a.pds = argv[++i];
+ else a._.push(t);
+ }
+ return a;
+}
+
+async function main() {
+ const a = parseArgs(process.argv.slice(2));
+ if (!a.app) {
+ console.error("usage: node write-usage.mjs --app [--config '{...}'] [--dry-run]");
+ process.exit(1);
+ }
+
+ const u = { app: a.app };
+ if (a.capability) u.capability = a.capability;
+ if (a.lastSeen) u.lastSeenAt = a.lastSeen;
+ else u.lastSeenAt = new Date().toISOString();
+ if (a.config) {
+ try { u.config = JSON.parse(a.config); }
+ catch { console.error(C.red("--config must be valid JSON")); process.exit(1); }
+ }
+
+ const v = validateUsage(u);
+ if (report(`usage -> ${u.app}`, v)) {
+ console.error(C.red("\nFix the errors above.\n"));
+ process.exit(1);
+ }
+
+ const record = buildUsageRecord(u);
+
+ if (a.json) { console.log(JSON.stringify(record, null, 2)); return; }
+
+ if (a.dryRun) {
+ const { identifier, pds } = authFromArgs(a);
+ printDryRun({ pds, did: identifier, collection: USAGE_NSID, record });
+ console.log(C.dim("\nRe-run without --dry-run to write.\n"));
+ return;
+ }
+
+ if (v.warnings.length && !a.force) {
+ console.error(C.yellow("\nWarnings present. Re-run with --force or --dry-run.\n"));
+ process.exit(1);
+ }
+
+ const { identifier, password, pds } = authFromArgs(a);
+ let session;
+ try {
+ session = await createSession({ identifier, password, pds });
+ } catch (err) {
+ console.error(C.red(`\nauth failed: ${err.message}\n`));
+ process.exit(1);
+ }
+ try {
+ const res = await createRecord(session, { collection: USAGE_NSID, record });
+ console.log(`${C.green("\n✓ wrote usage")} ${C.cyan(res.uri)}\n`);
+ } catch (err) {
+ console.error(C.red(`\nwrite failed: ${err.message}\n`));
+ process.exit(1);
+ }
+}
+
+main();