Skip to content

Add ChatGPT app submission metadata - #9

Closed
alcor wants to merge 153 commits into
inanimate-tech:mainfrom
arfct:codex/chatgpt-app-submission
Closed

alcor wants to merge 153 commits into
inanimate-tech:mainfrom
arfct:codex/chatgpt-app-submission

Conversation

@alcor

@alcor alcor commented Sep 13, 2026

Copy link
Copy Markdown

Summary

  • add chatgpt-app-submission.json covering all 19 MCP tools
  • include suggested app metadata plus 5 positive and 3 negative review test cases
  • mark list_documents as non-read-only because it may prune stale Registry enrollments

Validation

  • submission JSON structural checks passed
  • git diff --check passed
  • npm run typecheck could not start because node_modules/.bin/wrangler is not installed in this checkout

alcor and others added 30 commits August 30, 2026 08:37
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address review: y-markdown's delimiter map now reuses the shared
DELIMITERS export from critic-constants.ts instead of duplicating the
strings, and blockText nests all active mark types on a run (highlight
outermost) instead of rendering only the first one, so overlapping
criticHighlight + criticAddition/Deletion/Comment marks no longer lose
a delimiter pair silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds mint/verify/revoke/list for per-document agent tokens: app/lib/agent-tokens.ts
generates and hashes tokens (SHA-256), and DocumentAgent gains an agent_tokens
SQLite table plus mintAgentToken/getAgentRoster/revokeAgentToken/verifyAgentToken
RPC methods. Extends the integration test's sql mock with a generic in-memory
table store for tables beyond doc_state, reusable by later agent tasks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alarm() only deleted doc_state, so tokens minted before a document
expired stayed valid against whatever content later landed at the
same doc id. Clear agent_tokens alongside doc_state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
resolveAnchor's nearest-index heuristic can resolve `to` below `from`
after duplicate-hash blocks or concurrent edits, which made deleteBlocks
pass a negative length that Yjs silently ignores while the insert still
ran -- degrading agentReplace into a pure insert that returned { ok: true }
despite the document diverging from the request. Reject inverted ranges
with a stale_anchor error before mutating.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DocumentAgent's mutation RPCs (agentInsert/agentReplace/agentSuggest) now
share a private applyMutation() for direct Yjs application. A pace of
natural/fast with at least one connected human enqueues the mutation into
a `performances` table instead of applying it instantly; a setTimeout-chain
runner types insert/suggest text out via app/lib/performance-chunks.ts
chunks so connected clients see it appear incrementally, while replace
still applies atomically once dequeued. Anchors are re-resolved at dequeue
time (not enqueue time) so a mutation gone stale while queued is simply
dropped. Leftover queue rows are applied instantly on the next
ensureInitialised() (eviction recovery), and the alarm handler now also
purges the performances table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rmance engine

Code review found two Important issues in the Task 6 performance queue:

1. performTypedInsert/performTypedSuggest captured a block index (or text
   position) once and kept reusing it across every `await sleep(...)` tick,
   so a concurrent pace:"instant" mutation (which bypasses the queue) could
   shift the document under them, landing later writes at a stale spot.
   Fixed by claiming the target slot synchronously (zero yield before the
   first write), then tracking the write position from there on via a
   Y.RelativePosition, which stays correct across concurrent structural
   edits and aborts cleanly if it stops resolving.

2. Eviction recovery re-applied a queued mutation's full original text even
   if some ticks had already landed, duplicating what was typed. Fixed by
   deleting a performance's `performances` row at the moment its first
   write lands (the same synchronous-claim moment from fix 1) instead of
   at completion — from that instant the typed content is already part of
   the Yjs doc and persisted normally, so an eviction mid-typing now loses
   only the untyped tail instead of duplicating anything.

Added a covering test that reproduces fix 1 (an anchored typed insert vs. a
concurrent instant insert on the same anchor) — verified it fails against
the pre-fix code and passes against the fix. Redesigned the eviction
recovery test to use two queued mutations so it genuinely exercises the
pre-first-write case under the new claim-then-delete semantics. Tightened
the natural-pace typing test's partial-content assertion to a bounded
timer advance instead of an assertion that also passes for a fully atomic
implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Gives agents a synthetic Yjs awareness client so humans see them in the
presence stack and caret while they work. app/lib/agent-awareness.ts
hand-encodes MSG_AWARENESS frames for a stable per-name synthetic
clientId; DocumentAgent tracks join/leave/idle state in a new
agentPresence map, broadcasting on every change and replaying it to
late joiners in onConnect. onPerformanceCursor now sources the caret
position from the performance engine's live Y.RelativePosition tracking
(the text node + current offset) instead of the frozen block index it
was called with before, so it stays correct under concurrent edits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds an `events` table (seq/type/payload/created_at) plus
`agentAwaitEvents` long-polling for it. Human-origin Yjs transactions are
scanned via frag.observeDeep for @mentions of roster agents (with a
30s-capped doc_changed digest), and a threads-map observer notifies an
agent when a human replies to its thread. Agent-originated mutations are
tagged with a Yjs "agent" transaction origin so they're excluded from
both detectors — this also covers onRequest's initial content/threads
import, which isn't a live human edit.

agentComment/agentReply were missing from the RPC surface (deferred from
Task 5) and are needed to produce thread_reply events, so they're added
here too: agentComment creates a ThreadData entry (requires `comment`),
agentReply appends to one (doc_not_found for an unknown thread id).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rload

- thread_reply now fires only when a human-origin threads-map edit
  actually grows replies.length (via event.changes.keys' oldValue), not
  on any edit to an agent-authored thread (resolve toggles, re-saves).
- agentComment/agentReply now call checkRateLimit like the other
  mutation RPCs, instead of bypassing the agent's rate-limit budget.
- Added "thread_not_found" to the closed AgentErrorCode union and use it
  from agentReply instead of overloading "doc_not_found".

Also replaces the fake-timer tests' single-flush workaround
(flushMicrotasks) with waitForTimerRegistered, which polls
vi.getTimerCount() instead of guessing a fixed number of setImmediate
ticks — the single flush was an intermittent hang under full-suite load
(verifyAgentToken's real crypto.subtle.digest call didn't always resolve
in one tick before vi.advanceTimersByTimeAsync raced ahead of the
long-poll's setTimeout ever being registered).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a VaporMcp Durable Object that exposes the agent tool surface over
streamable HTTP at /mcp. The tool table lives in agents/mcp-tools.ts,
which imports nothing from the `agents` package so it stays testable in
plain Vitest; agents/mcp.ts wires it to DocumentAgent stubs and adds
create_document (no token, needs env).

The bearer token from the Authorization header rides to the DO as
ctx.props; DocumentAgent remains the only thing that validates it, and
every tool — errors included — returns its result as JSON text content.

Drops baseUrl from tsconfig.cloudflare.json: it resolved "agents/mcp" to
this repo's own agents/ directory instead of the npm package. A paths
entry keeps the package's internal types nameable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds GET /:id.md for a document's raw markdown (public, no token, backed
by a new DocumentAgent.exportMarkdown RPC) and a GET /mcp help page shown
to browsers (Accept: text/html) instead of a protocol error, with
connection snippets for Claude Code, claude.ai, and generic MCP clients.
The two handlers live in workers/routes.ts as pure functions that avoid
importing the `agents` package, so they unit-test in plain vitest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restricts the origin interpolated into the /mcp help page to a strict
http(s) allowlist, falling back to the default origin otherwise, since it
derives from the client-controlled Host header and was being spliced
unescaped into raw HTML/JSON. Also adds X-Content-Type-Options: nosniff
to the new GET /:id.md response, since it serves raw user content at a
public URL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the /:id/agents resource route (GET roster, POST mint/revoke)
backed by DocumentAgent's mintAgentToken/getAgentRoster/revokeAgentToken
RPCs, with server-side capability validation since the route is the
untyped boundary. Wires an InviteAgentDialog into the doc header: a
form with a pre-filled unused name suggestion, capability switches
(suggest+comment on, write off by default), a one-time token screen
with copy-ready Claude Code/claude.ai/mcpServers snippets, and a live
roster list with revoke.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds role="dialog"/aria-modal/aria-labelledby, Escape-to-close, a
backdrop-click-only close (ignoring bubbled clicks from the panel),
and minimal focus management (focus the name input on open, return
focus to the invoking button on close). No dialog primitive exists
in this codebase to build on, so this is the manual minimal version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add redirectHost() to workers/routes.ts that 301-redirects vpr.fyi, www.vpr.fyi, vaporware.fyi, www.vaporware.fyi, www.vapor.fyi to https://vapor.fyi while preserving path and query
- Call redirectHost() FIRST in workers/app.ts fetch handler before MCP help
- Add vpr.fyi and vaporware.fyi custom domain routes to wrangler.jsonc
- Add comprehensive tests for all redirect scenarios and edge cases

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Documents moved from /docs/:id to /:id, but vapor.fyi is live and
documents last 99 hours, so links shared before the move are still
being opened — and 404ing.

Add redirectLegacyDocPath, a pure handler alongside redirectHost: it
301s GET /docs/:id to /:id and GET /docs/:id.md to /:id.md, preserving
the query string and using a path-relative Location so the redirect
stays on whichever host served it. Wired into workers/app.ts ahead of
routeAgentRequest and React Router.

Also note that redirectHost's www.* entries only fire if DNS is later
pointed at this worker — they aren't registered routes today.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RESERVED_SLUGS was declared but never read, and two spec requirements
around it were unimplemented. Add the missing ".well-known" entry and
an isReservedSlug helper, then use it in both places the spec calls
for: generateDocumentId re-rolls a candidate that collides with a
reserved slug, and the /:id loader 404s reserved names explicitly
before the id-shape check, without resolving a Durable Object stub.

Also add MAX_AGENTS_PER_DOC alongside the list, for the roster cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
parseCriticMarkupToContent throws on CriticMarkup substitution, and
four agent paths called it unguarded on arbitrary MCP input. Worst
was replace: deleteBlocks and the insert shared one Yjs transaction,
and Yjs has no rollback, so a parse throw after the delete committed
the delete and lost the replaced blocks. The throw also escaped the
RPC as an exception instead of a typed error, wedged the performance
queue with isPerforming stuck true, and could abort ensureInitialised
mid-flight — leaving the Yjs observers unregistered (no mentions or
events for the life of the instance) and a leftover performances row
alive to collide with an id counter that restarts at 1.

- critic-parser: add tryParseCriticMarkup, returning a result value.
  parseCriticMarkupToContent stays as a throwing wrapper for the
  document-import path, which catches it into a 400.
- y-markdown: replace insertMarkdownBlocks with buildMarkdownBlocks
  (parse to detached nodes, no document touched) plus insertBlockNodes,
  so callers can validate before opening a transaction.
- document: add the unsupported_markup error code and return it from
  dispatchMutation, before the instant/queued fork, so an agent gets
  the same typed error at any pace and nothing unparseable is ever
  persisted. applyMutation and performTypedInsert re-check as a
  backstop for rows written by older builds.
- runPerformances clears isPerforming in a finally and drops a failing
  mutation (row included) so the queue drains past it; eviction
  recovery applies each leftover row in its own try/catch.
- Guard the unguarded JSON.parse sites: agentRead skips an unparseable
  thread, agentReply returns thread_not_found for one, and a corrupt
  rate-limit log is treated as empty (it is rewritten on that same
  check).

The integration test's SQL fake now enforces the PRIMARY KEY on
performances.id and UNIQUE on agent_tokens.name, so the id-collision
class of bug fails a test instead of passing silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three problems in the document event log, all in the same observer or
its reader.

Mentions only ever fired on paste. The observer matched findMentions
against each individual delta op, and a human typing arrives one
character per op, so "@scribe" never matched — the existing test
inserted the whole string at once and masked it. Scan the containing
block's full text instead (findBlockTextForXmlText already resolves
it), de-duplicated per (block text node, agent name) so later
keystrokes in the same block don't re-fire. A name is forgotten as
soon as it leaves the block's text, so deleting and retyping a mention
notifies again. The map is a WeakMap keyed by the live text node, so
it needs no explicit clearing.

doc_changed digests were recorded above the roster check, so every
document with no agents on it accrued events rows nobody could read.
Record the digest only once at least one agent is on the roster.

agentAwaitEvents filtered on seq alone, so every agent received every
other agent's mentions and thread replies. Filter both types to the
calling agent; doc_changed stays broadcast. The returned cursor now
advances to the highest row scanned rather than the last row returned,
so an agent never re-scans notifications addressed to someone else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
create_document reaches the same document store as POST /new but
applied none of its input guards, so an MCP client could create a
document from arbitrary-size or binary content. Add the same 1MB
ceiling and NUL-byte check, as validateNewDocumentMarkdown in
mcp-tools.ts — agents/mcp.ts can't be imported in plain Vitest, so the
guard lives where it can be tested directly and is called from the
tool.

mintAgentToken had no roster ceiling either. A document is a public,
unauthenticated URL, so cap it at MAX_AGENTS_PER_DOC (16) and return a
rate_limited error naming the cap. Revoking an agent frees a slot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alcor and others added 27 commits September 6, 2026 09:09
One document per draft: re-read, then replace/insert only the blocks
that changed so URLs and comment anchors survive; a whole-document range
replace is a last resort. The create_document tool description carries
the same nudge. Context: #59.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…dio client, demo cast (#63)

Follow-ups to #60.

- **Face pile draws agents as hexagons.** The pile checked the agent
flag on the wrong object, so every agent drew as a circle, and the
homepage's "also online" cast reset the flag to false. The paper backing
behind each face follows the shape now.
- **Anonymous agents keep their animal**, in white inside the hexagon.
Owned agents show their client's mark.
- **LM Studio** is a recognised client (`lmstudio-mcp-server-session` →
"LM Studio"), with a placeholder mark and no invite tab yet. Known
clients take their table label everywhere, so "Rob's Lmstudio Mcp Server
Session" becomes "Rob's LM Studio".
- **Demo doc:** Alice's Claude makes the cursor comment in Alice's
purple; the Badger reply stays.

Verified on the local homepage: fox and Alice as circles, Alice's Claude
as a purple hexagon with the Claude mark, the badger inside a blue
hexagon. 747 tests, typecheck, and lint pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Closes #62.

## What

Sign-in was Google-only. Apple joins it as a sibling: the same ID-token
verification, the same session cookie, and one more button wherever
sign-in is offered.

- **Verifier.** `verifyIdToken(provider, …)` in `app/lib/auth.server.ts`
with a small `PROVIDERS` table (JWKS URL, accepted issuers);
`verifyGoogleIdToken` and the new `verifyAppleIdToken` wrap it. Apple
sends `email_verified` as the string `"true"` in some tokens, which is
accepted. Principals are `apple:<sub>` via `principalFor(provider,
sub)`; `principalFromSub` stays for Google.
- **Routes.** `/auth/config` now returns `{ googleClientId,
appleClientId }`; the client renders one button per non-empty id. New
`POST /auth/apple` verifies the popup flow's `id_token`, takes the name
from the first authorization's `user` field, keeps the stored profile
name on later sign-ins (Apple never sends it again), and falls back to
the address only for a brand-new account. Both providers finish through
one `completeSignIn`.
- **Browser.** `app/lib/apple-signin.ts` loads Apple's JS toolkit on
click, runs the popup flow, and posts to `/auth/apple`. The OAuth
consent page (`app/lib/oauth-pages.ts`) inlines the same flow next to
Google's, so the MCP grant works for Apple users, and says so when an
instance has no provider at all.
- **Sign-in is a dialog now.** `SignInDialog` is a Dialog like New
document and Invite an agent, with one button per configured provider,
the webview fallback note, and a note when the instance has no provider;
it closes itself once a session exists. The header menu shows a **Sign
in** row while signed out that opens it. `vapor://signin` opens it from
document text (the tour's "Names are optional" paragraph now links
there), and so does the sign-in event a dropped file fires while signed
out. `HeaderMenu` loses all the GSI/Apple code.
- **Config and docs.** `APPLE_CLIENT_ID` (the Services ID) alongside
`GOOGLE_CLIENT_ID`; either, both, or neither. `docs/self-hosting.md`
step 7 becomes "Sign-in providers" with the Apple walkthrough (App ID →
Services ID → domain and `https://<origin>/auth/apple` return URL; Apple
refuses `http`, so localhost can't be listed). Privacy page covers both
providers and Hide My Email. README, CLAUDE.md, `.dev.vars.example`,
wrangler comments updated.

## Design notes

- **Separate identities per provider.** Principals are keyed on the
provider's `sub`, so someone using Google one day and Apple the next
gets two profiles, two counterpart agents, two sets of grants. Linking
them by verified email is possible later but is not in this PR; the
Registry's `e:<email>` index simply points at whichever signed in last.
(This supersedes the "same email, same profile" line in the original
issue, which predated the move to `sub`-keyed principals.)
- **No avatar from Apple.** The profile's `avatar` stays null and
`Avatar.tsx` already falls back to the letter mark.
- **Relay emails.** A Hide My Email user is stored under the relay
address, which is also what others would need to mention them by. Called
out on the privacy page.

## Verified

Typecheck and lint clean. 742 tests pass; the 16 failures are the
pre-existing Node 26 localStorage ones (`safe-storage`, `anon-identity`,
`use-theme`), green on Node 22 in CI. Not exercised against a live Apple
Services ID: that needs a paid developer account and an `https` return
URL, so the first real run will be on a deployed preview once vapor.fyi
has an `APPLE_CLIENT_ID`. Things to check then, from the issue: the
nested popup inside the MCP clients' consent popup, and the first-login
name capture.

## Rollout for vapor.fyi

Nothing changes until `APPLE_CLIENT_ID` is added to
`deploy/vapor.fyi.jsonc` (after registering `vapor.fyi` and
`https://vapor.fyi/auth/apple` on a Services ID). Without it the header
and consent page look exactly as they do today.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sets APPLE_CLIENT_ID to the Services ID `fyi.vapor`, registered for
domain vapor.fyi with return URL https://vapor.fyi/auth/apple. Adds the
Apple button to the sign-in dialog and the MCP consent page (#64).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
The toolkit URL in #64 (`appleauth/static/jstoolkit/v1/…`) returns 404,
so the Apple button did nothing. Apple serves it at
`appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js` (verified 200).
Same fix in the sign-in dialog and the OAuth consent page.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…own (#67)

Closes #40.

## What

Every agent mutation ran in a Yjs transaction tagged with the bare
string `"agent"`, and the document observers skipped that origin. That
kept an agent from hearing its own typing, but it also meant no agent's
edit ever produced an event: one agent `@mentioning` another never woke
it, and an agent's reply never reached the agent that opened the thread.

- **Origin carries the actor.** Agent writes now run with `{ kind:
"agent", actor: <roster name> }` (`agentOrigin(name)` in
`agents/document.ts`). `applyMutation` takes the acting agent; the
typed-performance paths, `agentComment`, and `agentReply` tag theirs;
recovered performance rows use their stored `agent_name`. The bare
`"agent"` origin is reserved for system writes (import, restore), which
still fire nothing.
- **Observers run for agent edits.** Mention detection, thread replies,
and the doc_changed digest treat an agent edit like a human one and put
`actor` on the event payload. The digest window is per actor, so an
agent's typing burst is one event to the others and never consumes the
window meant for human edits. An agent writing its own name is not a
self-mention.
- **Each agent filters its own.** `agentAwaitEvents`, `eventsPoll`, and
the webhook dispatcher drop events whose `actor` is the requesting
agent, server-side, so no client changes. The cursor still advances past
filtered rows.
- **Catalog.** Event descriptions and the payload schema name the
`actor` field.

## Not changed

A mention inside a brand-new comment's text (as opposed to a reply) was
never detected, for people or agents; the thread observer only looks at
replies. Left as is; worth its own issue if wanted.

## Verified

Typecheck and lint clean. 750 tests pass; the 16 failures are the
pre-existing Node 26 localStorage ones. New integration tests cover: an
agent's mention reaching the other agent with `actor`, the actor hearing
nothing and its cursor advancing; self-name not a mention; an agent's
reply firing `thread_reply` for the author only; `events_poll` applying
the same filter and carrying `actor` on the wire.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…to the alarm (#68)

Closes #58. Stacked on #67 (same file); GitHub retargets this to `main`
when that merges.

## Finding

A Durable Object bills for every moment it is awake, and any pending
timer keeps it awake. Auditing `DocumentAgent` for standing timers
turned up one the sleeping-tabs plan missed, plus the two it listed:

1. **y-protocols' `Awareness` starts a 3-second `setInterval` in its
constructor**, and `ensureInitialised()` constructs one for every
document. Nothing cleared it (only `doc.destroy()` on expiry would). So
every document that had ever been opened held a standing interval until
eviction, which is exactly the "≈29 hours awake per day on ≤13k
requests" shape in the August 31 numbers.
2. The 5-minute per-agent idle `setTimeout`, re-armed on every join and
cursor tick.
3. The 60-second idle-snapshot `setTimeout`, re-armed on every edit.

## Fix

- The awareness interval is cleared right after construction. Its job,
forgetting peers whose heartbeat lapsed for 30s, now runs on incoming
awareness traffic (`pruneOutdatedAwareness`); `onClose` already removes
a departing client's state.
- Idle presence and the idle snapshot become rows in a `schedule` table
served by the DO's single alarm. `armAlarm()` sets the alarm to the
earliest deadline or the document's expiry; the alarm handler runs what
is due, then re-arms, and expires the document only once its 99 hours
are actually up. Expiry is computed from the stored `createdAt` (as
`docExpiresAt()` already did) rather than read back from the alarm, so
`remainingLifetimeMs` is unchanged in meaning.
- Creation and system writes (import, restore) book no snapshot; a
deadline that moves by under 5 seconds is not rewritten, so a typing
burst doesn't write storage per keystroke.
- The 1-second persistence debounce is the only timer left. The
15-second long-poll cap stands.

## Verified

Typecheck and lint clean; 752 tests pass (the 16 failures are the
pre-existing Node 26 localStorage ones). New integration tests: presence
clears at its deadline through the alarm while the document survives,
and the alarm is re-armed for the expiry; the deadline moves with
performances; `vi.getTimerCount()` is 0 after initialisation plus an
agent join and an edit; stale awareness is pruned on the next awareness
message; the expiry tests now move the clock past the TTL instead of
assuming any alarm firing is the expiry.

**Not verified against the dashboard.** I have no analytics token on
this machine. After deploy, `node tools/do-usage.mjs --days 3` (needs
`CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_ANALYTICS_TOKEN`) should show
`DocumentAgent` duration collapse to a small multiple of
request-handling time; the per-namespace graph at Workers → Durable
Objects → DocumentAgent shows the same.

## Behaviour notes

- Agent presence now clears at the deadline even if the DO was evicted
in between (the alarm survives hibernation), where a lost `setTimeout`
used to leave a ghost cursor until the next wake.
- The alarm may fire a little early for a task; the handler tolerates
one second of slack and otherwise just re-arms.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ument (#69)

## URLs

A document's address is its title as a slug plus the id:
`/agent-identity-plan-26g5wsew`. The id resolves; the slug is for people
reading the link and is regenerated from the live title, so `/26g5wsew`,
a stale slug, and `/<slug>-<id>.md` all still open the document. `/new`
and the MCP `create_document` tool return the slugged URL. In the
editor, the visible URL and the tab title follow the first heading
through `history.replaceState`, without involving the router; query
string and hash survive.

`app/shared/doc-url.ts` holds the segment parser, the slug rules (ASCII
words, 60-char cap at a word boundary), and the title/description
extraction (CriticMarkup resolved as accepted, comments dropped, mention
ids hidden).

## Link previews

The loader reads the title and first paragraph from the Durable Object
and renders them server-side: `<title>` as "Title · vapor", a
description tag, Open Graph type, site name, title, description,
canonical URL, image with width/height/alt, and Twitter card tags.

Per arfct/link-previews (docs/imessage.md): iMessage shows a description
only for pages that look like a social post, so document pages serve
`og:type=article` plus an ActivityPub alternate link pointing back at
the page. Other platforms ignore the link. Left for later: a 1200×630
per-document image (the square logo is the summary card today) and
absolute icon hrefs.

## Verified

- Dev server: creating a titled doc returns the slugged URL; the server
HTML fetched with the four-way iMessage user agent carries every tag; a
wrong slug loads the document and the URL is corrected client-side;
typing into the title rewrites the address within a second.
- 774 unit tests plus integration tests pass; typecheck and lint clean.
New tests: `doc-url`, `doc-meta`, slugged cases in the loader,
raw-route, and `/new` tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
#74)

Closes #70. Closes #71.

## #70 — comments were never anchored

The browser places a comment thread by its inline marks: a
`criticHighlight` over the quoted words and the comment text right after
it as a hidden `criticComment` run, matched to the thread-map entry by
comment text (`scanDocumentComments` / `matchThreadsToComments`). The
MCP `comment` tool wrote the thread-map entry only, so every agent
comment was an orphan and rendered floating.

`agentComment` now lays down the same marks the UI's CommentInput does,
in the same transaction as the map write:

- with `quote` (an exact substring of the block): highlight over the
span, hidden run right after it;
- without: a bare marker at the end of the block's text, like a
standalone `{>>comment<<}`;
- a quote that isn't in the block returns `find_not_matched` with the
block's text as the snippet, where it used to silently create a floating
thread.

The thread id is `threadIdForComment` (moved from `useThreads` to
`app/shared/thread-id.ts`), the deterministic id a browser would mint
for the same mark, so a client that scans the mark before the map entry
arrives converges on one key. Exports now read `{==Hello==}{>>needs
work<<}` exactly like a person's comment.

### Threads that still have no marks

Existing agent comments (the ones on
`agent-setup-report-clarence-on-vapor-fslav1fw`, for instance) and
imported comments whose passage has since changed have no marks to
anchor to. They used to stack after the last anchored card and, when
selected, get pinned to anchor 0 — the animate-to-the-top effect. Two
fallbacks in the rail:

- The thread's `highlightText` is searched for in the document
(`findTextPosition`: first occurrence inside a single text block,
offsets mapped through inline children so a mention chip doesn't skew
it) and the card anchors level with that passage.
- A card that still has no anchor is pinned, when active, where the
stack already placed it rather than at the top (`layoutComments`).

### Collapsed cards clip at 480px

A long comment or a deep reply chain no longer stands at full height in
the rail while unselected: the collapsed card clips its body at 480px
with a bottom fade (only when it is actually cut off), and the selected
card shows everything.

## #71 — resolve, edit, delete

Three tools, all requiring the `comment` capability, mirroring
`useThreads`:

- `resolve_thread` (`thread_id`, `resolved` default true): lifts the
highlight and marker and keeps the words, as the UI's Resolve does;
reopening leaves the text alone. Anyone may resolve, as in the UI.
- `edit_comment` (`thread_id`, optional `reply_id`, `text`): the agent's
own comment or reply. Editing the opening comment rewrites its hidden
run too, since the text is the match key.
- `delete_comment` (`thread_id`, optional `reply_id`): the agent's own
reply, or its whole thread with the marks.

Ownership: agent authors on comments and replies now carry `id:
"agent:<roster name>"` (the roster name is public already as the mention
token; the principal never leaves the server). Someone else's returns
`not_author`; a missing reply `reply_not_found`. Legacy agent authors
without an id are recognised by name plus client.

## Verified

Typecheck and lint clean; 778 tests pass (the usual 16 Node 26
localStorage failures aside). New integration tests cover the marks laid
down with and without a quote via the exported markdown, the
`find_not_matched` path, a duplicate-text comment getting its own id,
resolve and reopen, edit of comments and replies with `not_author` and
`reply_not_found`, delete of replies and threads, a person's thread
being resolvable but not editable by an agent, and capability checks.
Unit tests cover the tool-argument mapping, `findTextPosition` (inside a
block, across a mark boundary, first block wins, absent and empty
passages), and the layout's anchorless active card staying put.

Not exercised in a browser: the rail picking up an agent's anchored
comment live. The marks are byte-for-byte what CommentInput writes, and
the export proves the placement, but a look at doc `fslav1fw`'s
successor after deploy would close the loop.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Closes #73.

## Cause

`CommentEditor` is a TipTap editor, so its root element carries TipTap's
`tiptap` class alongside our `comment-editor`. The body editor's rules
in `app.css` (`.tiptap { padding: 100px 1.5rem 1.5rem; font-size:
1.15rem; … }` and `.tiptap p { max-width: 65ch }`) therefore applied to
the comment box as well. `.comment-editor` only reset outline,
min-height, and paragraph margin, at equal specificity, so the padding
and type won. That is the ~300px box in the screenshot: 100px of top
padding above the paragraph, the placeholder centred in the box, the
caret on the paragraph line below it.

## Fix

The `.comment-editor` block now resets padding (0), font size (inherit),
line height (1.5), and paragraph width (none), with a comment explaining
that it has to stay below the `.tiptap` rules in the file to win the
cascade. The box is one line tall with the placeholder on that line, and
grows as the comment wraps. Replies use the same editor and get the same
fix.

## Verified

CSS only; typecheck, lint, and the suite are unaffected. Not checked in
a browser here, so worth opening a comment box on the deployed site once
merged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Closes #72.

## What

The header's face pile showed everyone on the document except the
viewer. Their own face now sits at the far right, a small step apart
from the other faces, so they can see how they appear to everyone else
(animal or signed-in photo, and colour) and confirm they are signed in.

- The pile renders even when nobody else is on the document; the
button's label reads "Only you here", otherwise "N people, M here now
(including you)".
- The list that opens gains a final "You · here now" row with the same
face.
- Ordering for everyone else is unchanged: oldest left, newest right,
"+N" for the overflow.
- Circle avatars now carry the person's name as a tooltip, as hexagons
already did (also what the test reads).

## Verified

Typecheck and lint clean; 785 tests pass (the usual 16 Node 26
localStorage failures aside). New tests render the pile with a live
Y.Doc and awareness: alone it shows one face, the viewer's, marked as
the self face; with another person online the viewer comes last with the
gap, and the list shows the You row.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Follow-up to #76. The faces sat above the pill's centre: each per-face
wrapper was an inline span, so the inline-flex face aligned to the
span's text baseline with descender space under it. The wrappers are
flex boxes now, so the faces centre in the 48px pill. Test asserts the
wrappers are flex.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ts, useful errors (#91)

Closes #78. Closes #79. Closes #80.

## #78 — a failed exchange burned the code

`handleToken` took the code out of the Registry first and validated
after, so a wrong `client_id`, `redirect_uri`, or verifier spent the
code and the person had to authorize again. Now it peeks, validates
every field against the stored code, and only then takes it. A
concurrent exchange that loses the race gets the winner's response if it
has landed.

A successful exchange's response is remembered for **60 seconds** keyed
by the spent code (`Registry.putReplay` / `getReplay`). A retry
presenting the same code and the same verifier from the same client gets
the identical tokens, so a response lost to a dropped connection no
longer costs a browser round-trip. A different verifier or client
presenting the spent code is refused as before.

## #79 — loopback redirect URIs on any port

`redirectUriMatches` treats `http://localhost`, `http://127.0.0.1`, and
`http://[::1]` URIs as matching when scheme, host, path, and query
agree, ignoring the port, per RFC 8252 §7.3. A registered URI with no
port (Claude Code's CIMD document declares `http://localhost/callback`)
covers every port. Applied at authorize and at the token exchange.
Non-loopback URIs still match exactly. `[::1]` is accepted as a
registrable loopback host.

## #80 — the mismatch error says something

An unknown client or an unregistered redirect URI must not redirect, so
the answer stays on our side: a JSON `invalid_request` with
`error_description` naming the received URI and the registered list for
clients, the consent page carrying the same text for browsers
(negotiated on `Accept`). The text is escaped in the page.

## Verified

Typecheck and lint clean; the OAuth suite grew from 9 to 14 tests: wrong
verifier / client / redirect leave the code usable and the corrected
retry succeeds; a retry of a spent code replays the same tokens while
other callers are refused; the loopback matcher's cases; an
ephemeral-port client authorizing and exchanging on a new port; the
negotiated error carrying both URIs. Full suite green apart from the
known Node 26 localStorage failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…son, create_document says what it allows (#92)

Closes #86. Closes #88. Closes #90.

## #90 — quotes copied from read_document didn't match

Corrected diagnosis: it was not a mark boundary. A block's text with
marks is a single `Y.XmlText`, so a search across bold or code already
worked. What failed was the quote itself: `read_document` hands each
block over as **markdown**, so an agent copying from it sends `` a
`list_documents` for `` with backticks the page's text doesn't have.
`findInBlock` now tries the text as given, then with inline syntax
stripped (`stripInlineMarkdown` in `app/shared/quote-text.ts`:
backticks, `**`/`__`, `*`/`_` emphasis without touching snake_case,
`~~`, link and image brackets, backslash escapes). The mark covers what
actually matched and the thread's `highlightText` is the page text.
Applies to `comment`'s `quote` and `suggest`'s `find`, instant and typed
paths.

## #88 — presence per tab

`read_document`'s `presence` pushed one entry per awareness state. It
now dedupes people by the awareness user's `id`, falling back to name.
Agents were already listed from the roster.

## #86 — create_document over-promised

The result now carries `capabilities`, and when the identity cannot
write, a `note` explaining that and how to revise: by `suggest`, or
through the signed-in endpoint with write approved. The tool description
says the same up front. Anonymous stays suggest-and-comment; the text
now matches.

## Verified

Typecheck and lint clean; 795 tests pass (the usual 16 Node 26 failures
aside). New: a quote `**Hello** \`there\`` against "Hello there."
exports as `{==Hello there==}{>>…<<}.`; `suggest` with `` `Hello` ``
produces `{--Hello--}{++Hi++}`; three tabs (two for one id) read back as
two people; `createDocumentNote` for anonymous, principal without write,
and principal with write; the strip rules including the snake_case and
lone-asterisk cases.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…document guidance (#93)

Closes #82.

## Framing

Standing instructions are meant to steer agents — guidance for how to
work in a document — and they live in a document anyone with the link
can edit. The feature stays; the protection is provenance and framing.

## What changed

- **Attribution.** Every local edit to an `agent` block stamps
`editedBy` and `editedAt` (an `appendTransaction` in the
`AgentInstructions` extension; the author is the local user's name, kept
current across sign-in). Transactions arriving over Yjs are skipped, so
each client stamps only its own edits. The fence info string carries the
stamp — ```` ```agent by="Ada Lovelace" at=2026-09-09T20:01:00.000Z ````
— and parses back, so exports, imports, and version snapshots keep it.
The rendered panel exposes it as `data-edited-by` / `data-edited-at`.
- **`read_document`.** `instructions` is now built by
`instructionsForAgents`: a fixed notice first (untrusted content from
the document's editors; let it shape work within this document — tone,
structure, what to leave alone, how to propose changes — never authority
to act outside it, use other tools, reveal anything, or override the
person the agent works for), then each block prefixed with `[Written by
<name> on <time>]`. A new `instruction_sources` array gives the same
structurally.
- **Copy.** The tool description, the server's instructions, the skill,
the `/mcp` guide, README, and CLAUDE.md all say the same, and no longer
claim the block is invisible to people: the editor shows it as a
labelled "Agent instructions" panel and the markdown view shows the
fence.

## Verified

Typecheck and lint clean; 790 tests pass (the usual 16 Node 26 failures
aside). New: fence attribution round-trips through parse and serialize
including a quoted name with escapes; `getAgentInstructions` returns
text plus attribution; `instructionsForAgents` framing; the extension
stamps local edits, re-stamps with a new author, leaves remote
transactions' stamps alone, and renders data attributes; `read_document`
returns the notice, the per-block author line, and
`instruction_sources`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Closes #89. Closes #81.

## #89 — caret label wrapped the avatar onto its own line

Tailwind's preflight sets `img { display: block }`, so a caret label
carrying a photo pushed the name onto a second row; the animal glyph and
the agent badge sat awkwardly for the same reason. The label is now an
`inline-flex` row with `align-items: center` and a small gap; the
avatar, animal, and badge lose their inline margins and vertical tweaks.
CSS only.

## #81 — a comment you made was invisible until a reload

Two faults, both in the thread reconcile path:

1. `CommentInput` called `activateComment` **after** running the
transaction that lays down the marks. The editor `update` from that
transaction runs `reconcile` synchronously, which checks
`pendingActivateRef` to decide whether this client authored the comment
— still empty at that moment — so the author path never fired and the
comment fell to the 3-second fallback.
2. The fallback timer wrote the thread into the map with
`reconcilingRef` set, so the map observer skipped it, and nothing
re-read afterwards. The thread existed in the shared document (hence a
reload fixed it) but the local rail never learned of it.

Now the comment is announced before the marks land, so the author path
creates and selects the thread immediately, and the fallback calls
`reconcile` after writing, so a comment from any path (including one
made on another client, or imported) appears within three seconds.

## Verified

Typecheck and lint clean; 786 tests pass (the usual 16 Node 26 failures
aside). New: a hook test renders `useThreads` against a real TipTap
editor and Y.Doc, inserts a comment mark without announcing it, confirms
nothing shows, advances three seconds, and confirms the thread appears
with a position and the local user as author — the exact sequence that
used to leave the rail empty.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…ents (#95)

Closes #83. Closes #84.

## #83 — expiry is visible and announced

- `read_document` returns `created_at` and `expires_at` (ISO 8601);
`create_document` returns them too. A new `documentSummary()` RPC
(exists, title, lifetime) serves listings without a full read.
- A fourth event, **`document.expiring`**, fires once six hours before
deletion with `expires_at` in its payload, delivered by poll and webhook
like the others. It runs from the alarm-scheduled `expiring` task (the
scheduler from #58), booked at creation — and, for documents created
before this change, on an agent's next enrollment, so existing documents
get the warning too. The single alarm now serves the idle deadlines, the
expiring warning, and the expiry, earliest first.

## #84 — list_documents

- When a signed-in identity's roster row is created, the document agent
records the enrollment in the Registry (`docs:<principal>`, bounded to
200). Expiry removes the document from every enrolled principal's list.
- `list_documents` (signed-in endpoint; `capability_denied` on
anonymous) reads the list, fetches each document's summary, drops and
forgets any that no longer exist, and returns `{ documents: [{ id, url,
title, created_at, expires_at, enrolled_at }] }`, most recently enrolled
first.

## Docs

The `/mcp` guide (HTML and markdown), README, the skill's archive step,
and CLAUDE.md describe the fields, the event, and the tool.

## Verified

Typecheck and lint clean; 804 tests pass (the usual 16 Node 26 failures
aside). New: lifetime fields on read and summary; the expiring task
booked at creation six hours before expiry, and, when the alarm fires at
that deadline, the event recorded and pollable as `document.expiring`
while the document lives on; Registry enrollments add, refresh without
duplicating, list most recent first, and remove; the catalog lists four
events. Existing alarm tests updated for the extra deadline.

Not exercised end to end: `list_documents` itself lives in the MCP
server class, which the unit harness cannot import; its pieces
(enrollment mirror, summary, pruning) are tested individually. Worth one
call against the deployed endpoint after merge.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…dded (#96)

Part of #59 — the two bug-shaped pieces. The server-side `patch` tool
stays open on the issue.

## Every block in the range is verified

`replace` gains an optional `anchors` argument: every anchor in the
range, as `read_document` returned them. `staleAnchors` resolves each
before anything is charged or queued, and again when a queued (paced)
replace actually lands, and returns `stale_block` naming the blocks that
changed — with their current anchors and a snippet — so the agent
re-reads those and retries, and nothing anyone typed in the middle of
the range is overwritten. Without `anchors` the behaviour is unchanged
(endpoints only), so existing clients keep working; the tool description
and the skill's "revise in place" step tell agents to pass them.

## Charged for the delta

`replaceCharge` bills the hourly character budget for the lines the new
markdown adds over the range it replaces (a trimmed line-level delta, at
least one character), not for every line it re-states. A whole-document
replace that changes one paragraph costs that paragraph. A range that
doesn't resolve is charged in full and fails at dispatch as before.

## Verified

Typecheck and lint clean; 807 tests pass (the usual 16 Node 26 failures
aside). New: a middle block edited after the read stops the replace with
`stale_block` naming it and the document keeps the edit; fresh anchors
let the replace apply; a three-block rewrite changing one line is
charged that line; a rewrite sharing nothing is charged in full; the
tool maps `anchors` through.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Closes #85.

## What

A signed-in person mints a long-lived **personal access token** with a
label and a grant (suggest & comment, or full write) under **Share →
Invite an agent → Other → Access token**, sees it once, and sends it as
`Authorization: Bearer vpt_…` to the signed-in `/mcp` endpoint from any
client that can set a header. One secret in a password store for every
machine, no browser in the loop. The token carries the same identity and
counterpart agent the OAuth path would, and is revocable from the same
place.

- **Registry.** `createAccessToken`, `lookupAccessToken`,
`listAccessTokens`, `revokeAccessToken`. Records live under the token's
SHA-256 (`pat:<hash>`) with a per-principal index (`pats:<principal>`);
the raw token is never stored, the view carries a 12-character id and
the token's last four characters. Use is stamped at most once a minute.
Twenty tokens per principal.
- **Routes.** `/me/tokens`: GET lists, POST mints (201, the token in the
body, shown once), DELETE `?id=` revokes. Same-origin, cookie session,
pure and unit-tested like `/me/wake`.
- **MCP endpoint.** A `vpt_` bearer resolves through the Registry to `{
principal, email, caps }`; anything else takes the JWT path unchanged,
so OAuth clients see no difference.
- **UI.** `TokenSection` in the invite dialog's Other tab (signed in):
the list with grant and last use, New token with label and grant, the
minted token and its header line shown once, Revoke per row.
- **Docs.** The `/mcp` guide (HTML and markdown), README, and CLAUDE.md.

## Verified

Typecheck and lint clean; 825 tests pass (the usual 16 Node 26 failures
aside). New: Registry mint/lookup/list/revoke including a stranger's
revoke being a no-op and the per-principal cap; the routes' auth,
same-origin, validation, 201, 429, and revoke; the component signed out,
listing, minting and showing once, and revoking.

Not exercised end to end: the bearer path in `workers/app.ts` is wiring
the unit harness cannot import. After merge, mint a token in the dialog
and run `claude mcp add --transport http vapor https://vapor.fyi/mcp
--header "Authorization: Bearer vpt_…"` (or a curl `initialize`) to
confirm.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Main's build failed after #97 merged: the rebase of #97 onto #95 kept
both inserted blocks at the same spot in `agents/registry.ts` but
dropped `listEnrollments`' closing brace. Restored, plus the matching
describe nesting in the Registry test. Typecheck, lint, and the suite
are green again; the live site was unaffected since the failed deploy
never replaced the running version.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…underneath (#99)

## What was wrong

Undo was wired up already — `@tiptap/extension-collaboration` binds ⌘Z,
⇧⌘Z, and ⌘Y to the Yjs `UndoManager` — but it failed for most real
edits, for two reasons found with a jsdom probe against the editor's own
extension stack:

1. **Most edits were never captured.** `BlockId` appends a transaction
after any change that creates a block, flagged `addToHistory: false`.
The y-sync plugin folds every ProseMirror transaction from one update
into a single Yjs transaction and takes the batch's history flag from
the *last* transaction it saw (and even calls `stopCapturing` on the
undo manager for it). So typing inside an existing paragraph undid fine,
while anything that made a paragraph — the common case — was excluded,
along with the person's own words. The flag is removed from `BlockId`
and from the agent-instructions attribution stamp; undoing the batch
restores the attrs with the content, which is what you want anyway. A
new CLAUDE.md critical rule says why appended transactions must not
carry that flag here.
2. **Redo right after undo threw.** y-tiptap's selection restore
resolved a stale position against the current document ("Position 13 out
of range"): the undo plugin remembers the selection from the state
*before* the last transaction, so after an undo its memory pointed into
content the undo had just removed. The new `UndoRedo` extension,
registered after Collaboration so it replaces its `undo`/`redo`
commands, dispatches one empty transaction first, which moves that
memory onto the current document, then runs the library's undo/redo. The
collaboration shortcuts call the commands by name and so land here; the
menu buttons call them directly.

## What's added

Undo and Redo buttons at the top of the Format menu, enabled by
`editor.can().undo()` / `redo()`, with the shortcuts in their tooltips.
`redo` joins the Material Symbols subset.

## Verified

Typecheck and lint clean; 821 tests pass (the usual 16 Node 26 failures
aside). New tests against the real collaboration stack: undo and redo of
an edit that created a block; ⌘Z, ⇧⌘Z, ⌘Y through the keymap; a
collaborator's edit arriving as a Yjs update is never undone locally; an
edit to a standing-instructions block (which stamps attribution) undoes
as one step; the menu buttons' enabled state and click.

Not checked in a browser. Worth typing a few paragraphs on a document
after deploy and pressing ⌘Z a few times, then ⇧⌘Z.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Closes #100.

## EPUB export — `GET /:id.epub`

A single-chapter EPUB 3 built in the Worker (`app/shared/epub.ts`,
`fflate`): `mimetype` first and stored, container, package document
(title from the first heading, `urn:vapor:<id>`, modified time, source
URL), a nav, a small stylesheet, and the chapter rendered by markdown-it
in XHTML mode with CriticMarkup resolved as accepted, comments and
`agent` fences dropped, and mention ids hidden. Attachment images
referenced in the document are fetched from R2 and packaged, with the
chapter's URLs rewritten to them, so the file stands alone (Kindle does
not fetch remote images). File name `<slug>-<id>.epub`. **Download
EPUB** joins the Share menu and the new dialog, signed in or not.

## Send to device

A **Send to device** dialog, from the header menu and the Share menu,
with two rows and the download.

**Kindle.** Amazon takes documents by email to the reader's
`@kindle.com` address from an approved sender. The reader saves the
address once (`PUT /me/devices`, validated as a Kindle address); the
instance sends from `SEND_FROM_EMAIL` through Resend's HTTP API with
`RESEND_API_KEY` (`workers/kindle.ts`, one fetch). Both are optional
operator values — without them the row says so and offers the download
plus Amazon's upload page. The dialog shows the exact sender address the
reader must approve at Amazon, once.

**reMarkable.** The reader gets an eight-character one-time code from
my.remarkable.com/device/desktop/connect and pastes it (`POST
/me/devices`). The Worker exchanges it at the reMarkable token endpoint
for a device token and stores it **sealed** with the same key that seals
wake secrets. Each send exchanges it for a short-lived user token and
uploads the EPUB to the cloud's document endpoint
(`internal.cloud.remarkable.com/doc/v2/files`, with the extension's
`rm-meta` / `rm-source` headers); the document lands in the root folder.
Unpair from the same row (`DELETE`).

`POST /:id/send { target }` does the delivery; five sends a minute per
principal. Routes are same-origin cookie routes, pure and
dependency-injected (`workers/device-routes.ts`).

## Docs

README (a "Reading it elsewhere" section), `docs/self-hosting.md` (the
two mail values and the approved-sender step), `.dev.vars.example`,
wrangler comments, CLAUDE.md.

## Verified

Typecheck and lint clean; 842 tests pass (the usual 16 Node 26 failures
aside). New: the EPUB's zip layout (first entry `mimetype`, stored),
package document contents, resolved chapter text, packaged image and
rewritten URL, file naming; the reMarkable pairing, user-token, and
upload request shapes and their error mapping; the Resend request with
the base64 attachment; the routes' auth, same-origin, validation,
mailer-absent and unconfigured refusals, throttle, and both send flows;
Registry settings with the token stored sealed; the dialog signed out,
saving and sending, pairing, and the no-mail message; the Share menu
rows.

**Not verified with devices.** Neither delivery can be exercised here:
Send to Kindle needs a Resend account with a verified domain plus a
reader who has approved the sender, and reMarkable needs a paired
tablet. The request shapes follow Resend's documented API and the
reMarkable endpoints the Read-on-reMarkable extension and rmapi use; the
first real send of each needs a person with the device, and the EPUB
itself is worth opening in Apple Books or Calibre once.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Does the code side of #103 so an instance can be submitted to the
ChatGPT plugins directory (reaching free/Plus and mobile users).

## What changed
- **Tool metadata** (`agents/mcp-tools.ts`, `agents/mcp.ts`): every tool
now has a `title`, MCP `annotations`
(`readOnlyHint`/`destructiveHint`/`idempotentHint`/`openWorldHint`;
anything landing in a public doc is open-world) and `securitySchemes` in
`_meta` (`noauth` for the anonymous endpoint, `oauth2` with the
capability scope). `jsonContent` returns `structuredContent` alongside
the text.
- **UserInfo** (`workers/oauth.ts`): `GET /oauth/userinfo` → `{sub,
email, email_verified: true, name?}` for session JWTs and `vpt_` tokens;
`userinfo_endpoint` in server metadata. `sub` is the principal, never
the email.
- **Domain verification** (`workers/routes.ts`): `GET
/.well-known/openai-apps-challenge` serves the `OPENAI_APPS_CHALLENGE`
var verbatim, 404 when unset. Added to `env.d.ts`, wrangler comments,
`.dev.vars.example`, deploy-config known vars.
- **Privacy** (`app/routes/privacy.tsx`): data / purpose / recipients /
retention table.
- **Skill**: setup line names ChatGPT/Codex connectors too, not just
`claude mcp add`.
- **Docs**: self-hosting "Listing in ChatGPT" section; CLAUDE.md routes
table and tool notes.

## Tests
New: userinfo (metadata, session bearer, PAT, 401s), apps challenge
(token, unset, fallthrough), tool metadata invariants (titles,
annotations, anonymous reach). Full suite: 853 passing, the 16 Node
26-only failures unchanged.

## Not in this PR (operator steps)
Org identity verification, reviewer test account, listing assets, portal
submission — listed on #103.

Closes nothing on its own; #103 tracks the submission.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Follow-up to #100 / #101.

## EPUB type

The stylesheet (`READING_CSS` in `app/shared/epub.ts`) now mirrors the
editor's type from `docs/design-system.md` and `app.css`: a system sans
body at 1.6 leading; bold sans headings at 1.875 / 1.5 / 1.25 em with
the opening title larger and lighter (2.5em, weight 500), as the page
does; IBM Plex Mono with the reader's mono as fallback for code at
0.875em on a light ground; an italic, rule-left blockquote; ruled
tables. All relative units, so Kindle's and reMarkable's own text-size
settings still apply. Georgia is gone.

## PDF

Cloudflare Workers have no PDF renderer, so the PDF path is the
browser's: **`GET /:id/print`** serves the same rendering as a
standalone HTML page with the same stylesheet plus print rules (page
margins, no breaks inside code blocks, tables, or images, no headings
stranded at a page's foot), and `?print=1` opens the print dialog on
load, where Save as PDF lives. **Print or save as PDF** is in the Share
menu and the Send to device dialog; slugged ids are accepted like the
EPUB route. A server-rendered `/:id.pdf` would need Cloudflare Browser
Rendering (a paid-plan binding self-hosters may not have) and is left as
a possible follow-up.

## Verified

Typecheck and lint clean; 848 tests pass (the usual 16 Node 26 failures
aside). New: the stylesheet's key rules and its presence in the EPUB;
the printable page's title, page rules, resolved text, kept same-origin
image paths, and the auto-print toggle; the route for bare and slugged
ids and a missing document; the menu row.

Not checked in a reader or a print preview. Worth one look at
`/<id>/print?print=1` in a browser and the EPUB in Apple Books after
deploy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Follow-up to #104 for the two optional items the ChatGPT plugin portal
flagged.

## Output schemas
- `ToolDef.output` is a zod shape built with `output()`: the success
fields, each optional, plus `error`. The SDK validates
`structuredContent` against `outputSchema` on every call and every tool
can return `{ error }`, so the schema must admit both.
- All 19 tools registered with `outputSchema`, including
`create_document`, `list_documents`, `attach` in `agents/mcp.ts`.
- Verified against a local dev server: `tools/list` shows 19/19 with
schemas; `read_document`, `join`, `leave`, `comment` (success and
`stale_anchor`), `reply`, `resolve_thread`, `suggest`, `events_list`,
`events_poll`, and capability-denied `insert`/`list_documents` all pass
validation.

## OpenID Connect
- `GET /.well-known/openid-configuration`: the OAuth metadata plus
`subject_types_supported`, `claims_supported`. Routed alongside
`/.well-known/oauth-*`.
- `scopes_supported`: `openid email profile` + `suggest comment write`.
- The honoured OpenID scopes are stored on the auth code and refresh
grant (`scope?`, optional so existing records read fine) and every token
response's `scope` is those plus the granted caps, instead of `""`.
- No ID token is issued; identity is via `/oauth/userinfo` (#104), which
OpenAI's docs say is the required part.

## Tests
Output-schema invariants (error + success sample for every tool), OIDC
discovery agreement with OAuth metadata, `honouredScope`/`grantedScope`,
and a full code → token → refresh → userinfo round trip carrying scope.
Suite: 861 passing, the 16 Node 26-only failures unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Geist <agent@artifact.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
@alcor

alcor commented Sep 13, 2026

Copy link
Copy Markdown
Author

Closing because this branch is based on the fork's substantially diverged main branch; the focused PR belongs against arfct/vapor instead.

@alcor alcor closed this Sep 13, 2026
@alcor
alcor deleted the codex/chatgpt-app-submission branch September 13, 2026 04:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants