Carrel: a private writing hub for articles across sites, books and manuscripts (app code only; no writing is stored here).
Current state is carrel/core.md in Capsid.
- The private shell: a React Router Worker that admits a request only when Cloudflare Access has signed it and its email belongs to an active person in Carrel. It adds per-project roles and a health check that emails on a change of state.
- Sites through site-api:
- the one view of the site's posts, with status and kind filters and FTS5 search;
- the site's CodeMirror editor in a prose layout, with autosave to D1;
- the site's own render in a sandboxed preview;
- saving and publishing, by role;
- a Content Security Policy on every page.
- Security headers: every response on both hosts, redirects and refusals included, carries the portfolio's shared set from site-runtime (the OWASP Secure Headers Project's values: HSTS, framing refused, nosniff, Referrer-Policy, Permissions-Policy, COOP, CORP and the rest) and a Content-Security-Policy built from Carrel's own list, all in
workers/security-headers.ts. Violations are reported to/csp-reporton the AI door, the one host a browser reaches without Access (workers/csp-report.ts, which logs and stores nothing).test/csp.test.tsruns site-runtime'scheckSecurityHeaderson rendered responses. - Books in the private writing repository:
- the book view, chapter view and a file editor for scenes, bible entries and outlines (the same editor, autosave to D1, Save commits to Git with the version it expects to replace);
- the checks on save: scene headers, continuity and timeline against the bible, world rules, the AI-habits lint and voice;
- the authorship record, as a Markdown report;
- export to ePub and Word, and a print page for PDF.
- AI work through MCP tools in the Worker: read, search, preview, AI drafts beside yours, flags from checks and reviewers, and publish by instruction for the Owner's own sessions, refused while a flag is open and emailed with an unpublish link.
- Google: the manuscripts index and search through a read-only service account, Send to Docs and Import through a
drive.filegrant, and Search Console per page. - Social (nothing posted until accounts are connected): a queue that announces pieces that went live, from a draft stored ahead, else a Claude Code routine, else a template, with the AI-habits lint, per-account switches and caps, and no reply code at all.
npm install # also creates wrangler.jsonc from the example
npm run db:migrate:local
npm run seed:owner -- you@example.com "Your Name"
npm test # Vitest in workerd with local D1
npm run typecheck
npm run build
npm run check:mcp-roles # no role logic in app/lib/mcp/
npm run check:conformance # the official MCP conformance suite, against the baseline in .conformance.json (devkit-conformance)
npm run check:plants # each gate above, seen red on a planted violation (several minutes)
npm run check:a11y # an axe scan (WCAG 2.2 A and AA) of every screen, both themes, desktop and phone
npm run check:bloat # warn-only: dead code (Knip), duplicates (jscpd), JS and CSS weight per route (devkit-route-weight)GitHub Actions runs all of these on every push and pull request (.github/workflows/ci.yml); none needs a secret. npm test also runs scripts/check-deploy.mjs, which proves the deploy script never deploys after a failed migration, build or id check. A push to main then runs the deploy job (secrets: see docs/auto-deploy.md).
Carrel's interface is Capsomer: its shell (rail, top bar, phone tab bar), tokens, components and fonts, with Carrel's own CSS in app/app.css limited to the page frame and the few rules the writing screens need. The fonts (Schibsted Grotesk, Martian Mono, Source Serif 4) are self-hosted from the build; nothing loads from another origin, so the policy (workers/security-headers.ts) names no font or style host. Carrel is private, so Web Analytics stays off it. A dashboard exclusion by hostname needs the Pro plan, so instead every HTML response carries Cache-Control: ..., no-transform (workers/gate.ts, and the consent page in app/lib/mcp/door.ts; test/access.test.ts and test/door.test.ts pin it), which Cloudflare's Web Analytics FAQ says keeps the proxy from injecting the beacon. The cost is that those HTML responses are not compressed by Cloudflare; scripts, styles and fonts still are. The CSP is deliberately not loosened for Cloudflare's beacon, and test/csp.test.ts pins that. The only components Carrel keeps are app/components/editor (the writing surface around Capsomer's markdown editor) and app/components/media (image upload and "Insert from library").
npm run harness builds and serves the real app on http://127.0.0.1:5199 with the Access gate replaced by a header naming the viewer (x-harness-viewer: dustin@harness.invalid the Owner, rosa@harness.invalid an Editor, sam@harness.invalid a Reader) and a fake site, writing repository and Google (test/harness). It needs no Cloudflare credentials. node scripts/screens.mjs <directory> writes a screenshot of every screen in both themes at desktop and phone widths, for looking at.
Every request without a valid Access token is refused, locally too, so the app answers 403 until it runs behind Access.
The tests never reach the network. Site tests run Carrel's code against site-api's own handler over its in-memory reference adapter (test/site.ts).
npx wrangler d1 create carrel, put the id inwrangler.jsonc,npm run db:migrate:remote,npm run seed:owner -- <email> "<name>" --remote.npm run deploywith no route:workers_devandpreview_urlsare false, so nothing reaches the Worker yet.- Turn on Access for the Worker (Workers & Pages, carrel, Access, All traffic) and
Get-Clipboard | npx wrangler secret put ACCESS_AUD. - Uncomment
routesinwrangler.jsoncand deploy again to addcarrel.dustinedwards.info. - From then on a merge to main deploys itself, migrations first: see
docs/auto-deploy.md.
A project is a site when its site column names an entry in app/lib/sites.server.ts. Migration 0002 creates the dustinedwards.info project, 0014 the germomics one, 0016 the Foxhound and Foxing ones and 0017 the txasm and julieedwards.info ones.
Each site's origin is a var in wrangler.jsonc.example (SITE_DUSTINEDWARDS_ORIGIN, SITE_GERMOMICS_ORIGIN, SITE_FOXHOUND_ORIGIN, SITE_FOXING_ORIGIN, SITE_TXASM_ORIGIN, SITE_JULIEEDWARDS_ORIGIN), exactly the domain the site's own job confirmed its Worker serves. Its key is a secret, the same value the site holds:
Get-Clipboard | npx wrangler secret put SITE_DUSTINEDWARDS_KEY
Get-Clipboard | npx wrangler secret put SITE_GERMOMICS_KEY
Get-Clipboard | npx wrangler secret put SITE_FOXHOUND_KEY
Get-Clipboard | npx wrangler secret put SITE_FOXING_KEY
Get-Clipboard | npx wrangler secret put SITE_TXASM_KEY
Get-Clipboard | npx wrangler secret put SITE_JULIEEDWARDS_KEYAdding a site is config: an entry in SITES (name, origin var, key secret, the site's own new-item source, the items it keeps public, its Search Console property), the origin var and key in app/env.d.ts and the example config, a migration inserting its project, and, when the site has its own figure syntax, one line in FIGURES in app/lib/site-markdown.ts (without one, an image goes in as plain Markdown). The health check, the image policy and the cron's index refresh loop over every entry. test/sites.test.ts covers germomics.
Until the key is set, the site shows as not connected, and the health check reports it as not connected rather than failing. Once it is set:
- the health check runs site-api's conformance suite against the site every 15 minutes and emails on a change of state;
- the same cron refreshes the one view's index.
A project is a book when its book column names a folder in DrDustinEdwards/writing (New book, on the home page, for the Owner). Carrel reaches the repository only through the GitHub App carrel-writer (design setup step 8), installed on writing alone with contents read and write. Each installation token Carrel asks for is narrowed again to that repository and to contents.
Get-Clipboard | npx wrangler secret put NOVELS_APP_ID # the App's numeric id
Get-Content <the-downloaded-key>.pem -Raw | npx wrangler secret put NOVELS_APP_PRIVATE_KEYThe key goes in as GitHub downloads it (PKCS#1, "BEGIN RSA PRIVATE KEY"); Carrel converts it. Until both are set, books open read-only from Carrel's index, Save explains why it is waiting, and the health check reports writing as not connected rather than failing. Once they are set, the health check asks GitHub for a token every 15 minutes.
shared/voice/*.md Dustin's own passages, which the voice scan reads a scene against
<book>/book.md title, author, language for export
<book>/bible/characters/*.md name, aliases, born, died
<book>/bible/places/*.md name, aliases
<book>/bible/rules/*.md name, forbidden: [patterns matched against every scene]
<book>/outline/*.md
<book>/chapters/NN-name/NN-scene.md
<book>/build/ ignored
Each scene opens with its header: pov, date (2031-04-12, or 2031-04-12 14:30), location, characters, Dustin's beats goal, conflict, outcome, and flashback: true for a scene out of order. The header is a narrow YAML subset (key: value, key: [a, b], or one - item per line); a line outside it is reported in the editor, never guessed at.
They run on every save, on the saved text, and after a refresh that finds changes; Check this text runs them on a draft without saving. They flag and never refuse: a flag never stops a save. An open flag holds export until the text is fixed or the Owner dismisses it, and a dismissal survives later saves of the same words.
| Check | Flags |
|---|---|
| header | a missing header, point of view, date, location or beat; a date that is not a story date |
| continuity | someone present who is not in the bible, dead at the scene's date or not yet born; a location not in the bible |
| timeline | a scene dated before the scene it follows, unless it is marked flashback (a flashback does not move the clock) |
| world-rules | a match for any rule's forbidden patterns; on the rule itself, a pattern that cannot compile |
| voice | site-runtime's slopScan against Dustin's own writing (shared/voice/ and up to four of the book's scenes whose status starts done, final, finished or complete, never the scene itself): a sentence well past his long ones, a run all the same length, a model's filler, a model's word his writing never uses, a joiner dash or "not X, it's Y" where his writing has none, a closing list of padding |
There is no global word list: a word Dustin uses is never flagged. A session drafting a scene gets excerpts of the same writing with carrel_read_book_file, among them a long sentence of his and a short one. Every check has a planted problem that must be flagged and a clean twin that must not (test/checks.test.ts).
A manuscript that starts as Word files (exported from Google Docs with File, Download, Microsoft Word) is converted once into a book in the writing repository, then reviewed as a pull request there. Nothing is sent anywhere; the importer reads the files and writes Markdown into a local clone.
git clone https://github.com/DrDustinEdwards/writing.git C:/dev/writing
git -C C:/dev/writing switch -c import-paluxy-portal
npm run import:docx -- C:/path/to/manuscript-folder --book paluxy-portal --novels C:/dev/writing- A folder's
.docxfiles are read in name order, so name them01-...,02-.... Single files can be listed instead. - In each file, a Heading 1 starts a chapter (
chapters/NN-name/); a file with no Heading 1 is one chapter named after the file. The Title paragraph becomes the title inbook.md(or pass--title "..."). - A paragraph that is only a scene-break mark (
* * *,***,#,~) starts a new scene (NN-first-words.md). Empty paragraphs never do. - Italics become
*...*, bold**...**. Each scene opens with an empty scene header for you to fill. - Anything it cannot convert cleanly (tables, images, footnotes, comments, tracked changes, lists, line breaks inside a paragraph, headings below Heading 1, centered text, text before the first chapter) is flagged, not guessed: an
<!-- import: ... -->line where it happened, and a row in<book>/build/import-report.md. Remove every marker before exporting. - It refuses to write into a book that already has
chapters/.
Then review the result in the clone, commit it, and open a pull request in the writing repository. The importer's tests (test/import.test.ts) run on a short test document built from test/fixtures/import/test-manuscript.mjs.
The AI door is its own hostname, carrel-mcp.dustinedwards.info (design decision 7, corrected). Every request that arrives there is an AI session, and every other hostname is the browser door behind Access; the hostname alone decides, never the email. The pages are never served on the AI host, and /mcp on the browser's host is just a page that does not exist.
- Protocol: the official MCP SDK v2 (
@modelcontextprotocol/server2.0.0) throughagents, stateless, at/mcp. The design-center era is 2026-07-28; 2025-era clients (Claude Code and claude.ai, as last measured) are answered by site-runtime's era router (routeMcpEra), handedisLegacyRequestat the one call site inapp/lib/mcp/server.ts, which carries the removal condition. - Sign-in: the door is an OAuth 2.1 authorization server (
@cloudflare/workers-oauth-provider1.1.0, exact). Claude, Claude Code and ChatGPT present a metadata URL as theirclient_id(CIMD). Grok and Gemini, which still need Dynamic Client Registration as of 2026-10-09, use a tightened/register(app/lib/mcp/registration.ts, ruling 2026-10-10 rule 13): only exact allowlisted callbacks (the fixed ones in that file, plus any exact https callbacks in the varDCR_REDIRECT_URIS), only the code and refresh grants, a few registrations an hour per address and a ceiling a day, and a registration grants nothing by itself. After a consent page, shown for every authorization of every client, the person signs in with Cloudflare Access for SaaS (OIDC; secretsACCESS_SAAS_CLIENT_IDandACCESS_SAAS_CLIENT_SECRET). The ID token's email must belong to an active person in Carrel; an unknown or disabled email gets no grant, and a person disabled later loses the door on their next request. - Scopes: a client that asks for
carrel:readalone gets reading only. A client that asks forcarrel:write, or names no Carrel scope at all, gets read and write, and the person can narrow any of these to reading with "Allow reading only" on the consent page. A tool that writes refuses a grant withoutcarrel:writeand says to reconnect. A grant from before 2026-10-10 carries the old scopecarrel, read as both. A scope only narrows the person's role, never widens it. - Security floor (ruling 2026-10-10, rule 12): every id-taking tool refuses a person the project is not shared with (
test/mcp-ownership.test.ts); a token must be bound tohttps://carrel-mcp.dustinedwards.info/mcp, checked by the provider and again in the door, and is never passed on to the site or the novels repository; every tool call is one row in the append-onlymcp_callstable (caller, client, tool, arguments, result); and every answer that carries text from the site, the novels repository or other people's flags and drafts leads with a notice that it is data, never instructions. - Named agent keys: for an agent that cannot complete OAuth (Grok Build could not do CIMD as of 2026-09-07). One Worker secret per agent, and the secret's name is the agent:
AGENT_KEY_GROKisgrok. The agent sends it asAuthorization: Bearer <key>on/mcp; it is compared in constant time over SHA-256 digests (app/lib/agent-keys.server.ts), and the name is never read from the request. The agent is the person rowagent:grokin People (added in People under "Add an AI agent"), found again on every request, so disabling the row revokes the key at once. Its role is whatever People says (Editor ondustinedwards.infoto start: read, save AI drafts, flag, preview); the key grants nothing, socarrel_publish_itemrefuses an agent with the Owner-only message, and a row marked Owner is refused as an agent. It is inert until a secret exists, and any other Authorization header (an OAuth token, a malformed one) goes to the OAuth path unchanged. Writes are credited toagent:grok, in the AI draft's client and person and in a flag's text, never to Dustin. Setting up one: generate the key into a file in your home folder without printing it,Get-Content <file> -Raw | npx wrangler secret put AGENT_KEY_GROK, add the agent and set its role in People, then point the agent athttps://carrel-mcp.dustinedwards.info/mcpwith that key as a bearer token (Grok Build:bearer_token_filein~/.grok/config.toml, orgrok mcp add --transport http carrel <url> --header "Authorization: Bearer <key>"). To rotate, put the secret again; to revoke, delete the secret or disable the agent in People. - Credit: an AI session is credited by the client's CIMD name (for example "Claude Code") on every draft, flag and publish.
- No role logic in the MCP layer: tools call the same functions as the buttons, and those refuse.
npm run check:mcp-rolesfails on a role test anywhere inapp/lib/mcp/. - Reachability:
carrel-mcp.dustinedwards.infohas a hostname Access application with a Bypass policy, which Cloudflare applies before the Worker-level application;carrel.dustinedwards.infostays fully behind Worker-level Access.
Every tool is named carrel_<action>_<resource> and carries all four annotation hints. carrel_search_items returns 50 posts to a page by default (limit up to 100), with has_more and an opaque next_cursor to pass back as cursor.
| Tool | Who | What |
|---|---|---|
carrel_list_projects, carrel_search_items, carrel_read_item, carrel_preview_item, carrel_get_checks |
anyone with a role | read through the same functions as the pages |
carrel_voice_brief, carrel_check_post_text |
anyone with a role | what to read before drafting (a few writing rules, the Owner's guide, and excerpts of the published posts holding a long and a short sentence of the author's), and the scan run on text without saving |
carrel_save_draft |
Editor, Owner | a new AI draft beside the person's own; never over it, never to the site. The answer carries the writing checks on what was saved |
carrel_add_finding |
anyone with a role, reviewers above all | a flag on a post, credited to the client |
carrel_publish_item |
the Owner's own sessions | the post as saved on the site at expected_version; refused while any flag is open; recorded as "published by on Dustin's instruction"; emails Dustin an unpublish link |
carrel_list_book_files, carrel_read_book_file, carrel_check_book_text |
anyone with a role on the book | the book's files, a file with its version and flags, and the checks run on text without saving |
carrel_save_book_draft |
Editor, Owner | a new AI draft of a book file beside the person's own; never committed to Git |
carrel_add_book_finding |
anyone with a role, reviewers above all | a flag on a book file; a recheck never withdraws it, and it holds export |
carrel_draft_social_post |
the Owner's own sessions | a post for the social queue, by event id or by account, project and item |
A named agent is a person row agent:<name> that a key resolves to; it is neither Owner nor reviewer, and its role on each project is set in People like anyone's. A reviewer is a person row with is_reviewer = 1 (another company's agent), signed in through the same door with its own email: it reads and flags through /mcp, never saves or publishes, and is refused at the browser door. AI drafts and flags show in the post's editor, and the project's Posts list shows a post that exists only as a draft (yours or an AI's, test/waiting.test.ts) and marks a post with AI drafts waiting; only the Owner dismisses a flag. If the email after an AI publish fails, the publish stands and the health check reports it until it is sent.
Drafts an AI session writes should read like the author, not like a model (app/lib/voice.server.ts, the project's Voice tab). Nothing here measures or aims to pass an AI detector.
- The brief: the Owner's guide and six excerpts from the newest 40 published posts, one per post, among them one holding a long sentence of the author's and one a short one. Real examples copy a voice better than a description does. No word lists and no measured averages: an average sentence length is a length a model then writes every time.
- The scan: site-runtime's
slopScan, the same one the book checks and the social lint run, against those excerpts: a sentence well past the author's long ones, a run of sentences all the same length, a model's filler, a model's word the excerpts never use, a joiner dash or "not X, it's Y" where the excerpts have none, a closing list of padding, and a run copied from an excerpt. At most twelve findings, each with its line and words, and one instruction: edit only those spans in place and leave every other sentence as it is. - Before and after drafting: a session reads
carrel_voice_brief, writes from the guide and excerpts, runscarrel_check_post_textand fixes only what it names;carrel_save_draftanswers with the scan on what it saved, and the AI draft page shows it above the draft. It flags; the author edits, and publishing still waits for the Owner. - Kept, unused:
voice_packs.phrases,allow,profileand thevoice_cutstable from the earlier word lists, so the lists the Owner typed are not lost.
Each site's files stay in that site's own storage and are served by the site; Carrel is only the screen, over the site API's media group (site-api v0.2.0, and the optional writes of v0.4.0). Nothing about serving an image depends on Carrel.
- The library (
/p/<project>/media): browse and search the site's files, see each one's details and every place the site's reference check finds it used, upload, edit alt text and tags, use the trash, and delete. - In the editor: paste or drop an image, or use Insert image, and it uploads to the site; From library inserts a file already there. Either way the alt text is asked for before anything goes into the post, and the post gets the site's own figure markup (
app/lib/site-markdown.ts), pointing at the site's own address for the file. - Limits are the site's. Carrel reads the accepted types and the size limit from the site's meta and refuses anything else before sending it. The site API checks again, bytes included.
- Deletes are the site's to decide. A file a post uses is refused, with every post named. Delete asks once more before it acts, because nothing brings the file back.
- Images load from the site. The page's image policy names each configured site origin, and nothing else.
- Health. The conformance run includes the media checks, all of which a conforming site refuses. The suite's real upload round trip stays off, so the health check never stores a file.
- Writes beyond upload and delete (site-api v0.4.0): a file's alt text and tags are edited in the details panel (a form each; the versions are the site's, so a stale save is a plain "the file changed" message and the page refreshes to what the site holds). Trash and restore are reversible, so an Editor may; the library's Trash view lists the trashed files, and the Owner can Empty the trash behind a confirm dialog that names every file (without script, the page asks on its own). The tag filter narrows the library to one tag.
- Selection and the bulk bar: Editors and the Owner tick files (Capsomer's tile checkbox and bulk bar; the bar needs script) and Add tag, Remove tag, Move to trash (or Restore, in the Trash view) or, for the Owner, Delete for good, whose confirm dialog names every file.
POST /p/<project>/media/bulkanswers JSON. The site answers file by file, so nothing aborts: the Result panel lists each file as done or left as it was, with the reason, and a file a post uses is refused by the site's check with the post named. At most 100 files a request, as the site allows. - Only the Owner deletes for good or empties the trash (
delete_media, checked inmedia.server.tsbefore the site is asked). Alt text, tags, trash and restore are the Editor's (edit).test/media-writes.test.tsnames each refusal. - Each write is optional per site. Carrel reads
mediaAlt,mediaTagsandmediaTrashfrom the site's meta and hides the alt form, the tag form and filter, the Trash view and the selection tools for what the site lacks; a write that arrives anyway and is answered 501 says "This site does not offer that." The site's adapter for dustinedwards.info is specified in docs/dustinedwards-media-adapter.md. - Not here: folders (the dustinedwards media schema has none; a site whose schema grows them gets a contract change first) and "rebuild from the bucket" (a repair, in Capsid's allowlist work, not a Carrel screen). Capsomer's own media inspector autosaves through a client call; this page keeps server forms, so the versioned writes, their refusals and the authorship record are one path.
- Authorship: every media action the site carried out writes one row in
changes: the person, the AI client when it came through the AI door, the project, the site's media id, and the same change id Carrel sent the site, so the two histories join. Alt text, tags, trash and restore are recorded asmedia-alt,media-tags,media-trashandmedia-restore, with the file's version before and after. A bulk action writes one row per file, each under its own change id; emptying the trash writes amedia-deleterow per deleted file under the id the site gave it (<change id>-<n>). A refused action writes nothing.
The editor's History panel (and the page behind it, /p/<project>/e/<item>/history) shows what the site remembers of one post, for any kind of item. It is read-only: looking at history writes nothing to the site or to Carrel, and a Reader may read all of it. The reads live in app/lib/history.server.ts, which checks the role like content.server.ts does.
- Revisions come from the site, newest first, each with who and when. Each opens its source (site-api v0.3.0's
GET /content/:id/revisions/:version; on an older site the page says it cannot open one). - Any two revisions can be picked and compared. The patch is the site's own (
GET /content/:id/diff), drawn with Capsomer'sDraftPatch. "By word" sets the same two texts against each other with Capsomer'sDraftCompare. - Your working draft can be compared against the site's current text, and against each AI draft saved beside it (also linked from the AI draft page). Both sides are in Carrel, so the patch is built here by
unifiedPatchinapp/lib/patch.ts, with thediffpackage the site API already uses, and drawn the same way. "On the site" means the site's current version, published or not. - Not here: restoring a revision. Opening a revision is read-only; putting one back is a wider job.
test/history.test.ts drives the route's loader: a Reader's reads, that only GETs reach the site, the order of a picked pair, and each comparison.
On the posts list, Editors and the Owner can tick posts and use Capsomer's bulk bar: Add tag and Remove tag (only the frontmatter tags line changes, through site-api's setTags; every other byte of the file comes back as it was), Duplicate (a new draft with the first unused id of -copy, -copy-2, ..., draft: true, a "(copy)" title and its own slug, created as a new item), and Delete (the Owner's alone; the confirm dialog names every post). The work is in app/lib/bulk.server.ts; POST /p/<project>/bulk answers JSON.
- One post at a time, nothing aborts. Each post is its own write through
writeToSite, so the editor's rules apply to it: a change to a live or scheduled post is the Owner's, and an Editor's tag on one is refused for that post alone. A stale version, a refusal, a post the site does not have, or a post you have an unsaved working draft of (a save to the site would clear that draft) leaves that post as it was. The response lists every post's outcome, and the page shows them under "Result". - Delete needs the site to offer it. It uses site-api v0.3.0's
DELETE /content/:idwith the version the site holds and a change id. A site whose adapter has nodeleteanswers 501 andmeta.capabilities.contentDeleteis absent: the Owner then sees "Delete is not offered" with the reason, and the action is not shown. A deleted post leaves Carrel's index; your working drafts of it stay in Carrel. - Authorship: a delete writes one row in
changeswith actioncontent-delete: the person, the project, the post, the version deleted, and the change id sent to the site. Tag and duplicate are ordinary saves and write their usual rows. - Roles are checked in
bulk.server.tsandcontent.server.ts(delete_contentinapp/lib/roles.tsis the Owner's alone), before any post is read.test/bulk.test.tsnames each refusal.
The Mentions tab of a site project is the queue of webmentions other sites sent about its posts, over site-api v0.5.0's mentions group. Moderation stays on the site until Dustin confirms it has moved here; Carrel is the screen, and the site keeps the table and the rules. Files: app/lib/mentions.server.ts, app/routes/mentions.tsx, POST /p/<project>/mentions/api (JSON), test/mentions.test.ts.
- The screen. A table of the mentions in one status (a filter with counts: Pending, Failed, Approved, Rejected, Unverified, All; it opens on Pending, or All when none is pending), 50 to a page. Tick rows and use Capsomer's bulk bar: Approve and Reject (a decision can be changed later) and Delete (the confirm dialog names every mention; it removes the only copy of what the sender sent). A Retention panel shows how many failed and rejected mentions are past the site's window and Remove N expired sweeps them behind a confirm. Everything a stranger sent is drawn as text; the source address is never a link.
- Who may. The Owner alone, to read and to decide: the inbox belongs to Dustin alone (his design decision, 2026-09-26), and the site reserves deleting a mention to its admin.
read_mentionsanddecide_mentioninapp/lib/roles.tsare separate actions, so opening the queue to Editors or Readers to read is one line there. The tab is shown only to a person who may open it; anyone else who asks for the page gets 403, and the site is never asked. - One mention at a time, nothing aborts. Each is its own site write with its own change id, in order. A stale version (the mention was re-sent or decided elsewhere since the page loaded), a mention the site no longer has, and one the site refuses (it will not decide an unverified or failed mention) leave that mention as it was; the others go on, and the page lists every outcome under "Result". The site saves the decision and clears the post's cache as one step; if the clear fails Carrel says so.
- A version is the site's own opaque string for the mention; it must move when the status, the verification or the decision does.
- Offered per site.
meta.capabilities.mentionssays the site has the group; without it the screen says so and the routes answer 501. The health check runs the mentions conformance checks on a site that declares the group (none changes the site) and expects 501 from one that does not. - Authorship: every write the site carried out writes one row in
mention_decisions(migration 0011, a table of its own because thechangestable holds posts and files): the person, the AI client if one ever decides, the project, the site's mention id (empty for a sweep), the status before and after, and the change id sent to the site. A refused write writes nothing.
Two separate accesses (design decision 6), and no drive.readonly anywhere:
- The service account (
GOOGLE_SA_KEY, the whole JSON key file) reads the manuscripts folder's metadata (drive.metadata.readonly) and Search Console (webmasters.readonly). Every request it makes passes an allowlist inapp/lib/google/service-account.server.ts: listing files and the Search Console query. Anything else, a Drive write or a file's content, is refused in code before it is sent. - Dustin's grant (
drive.fileonly) is for Send to Docs and Import. Its refresh token sits in D1, encrypted with AES-GCM underGOOGLE_TOKEN_KEY.
What it gives:
- Manuscripts (
/manuscripts, the Owner's): the shared folder and its subfolders, as metadata (name, type, dates, owners and last editor), refreshed by the cron so files added later appear. Search runs in Drive's own full-text search, so manuscript text never enters Carrel. Open goes to Google. - Send to Docs copies a post's body into a new Doc. The frontmatter stays in Carrel. Import brings the Doc back as a new working draft under that frontmatter, and is refused while a working draft exists.
- Search Console per page, 28 days of settled data, fetched at most daily, shown in the post's editor.
- Health: the key's age (counted from the first time Carrel saw it; fails past 90 days), whether Google still takes it, and whether the
drive.filegrant still refreshes.
/social (the Owner's) holds the accounts, the posts waiting on Dustin, and the record of every post. A post only ever announces a piece that went live through Carrel, and it comes from, in order (design decision 5):
- a draft stored ahead by the session that finished the piece (
draftSocialPost, which thecarrel_draft_social_postMCP tool calls); - else the Claude Code routine, fired through its API trigger with every waiting item in one run (after a 30-minute gathering delay, at most 3 runs a day, plus a nightly sweep); the routine stores its drafts the same way;
- else, when the routine refuses the run, its daily cap is reached, or it drafted nothing within 3 hours, the account's template. The health check fails until Dustin marks each template post seen.
Every post is linted (the AI-habits list, the "not X, it's Y" construction, the platform's length, an opening @handle). A post with a finding is held. Otherwise the account's switch decides: approve each post, or automatic. The personal account is always approval, and the database refuses otherwise. Sending keeps to each account's daily cap and gap between posts, and to X's monthly budget ($0.015 a post, $0.20 with a link). Every request to a platform passes an allowlist of the endpoints that create a session or an original post. A reply, quote, follow, like or message is refused before it is sent.
Credentials are secrets named for the account key, with dashes as underscores: SOCIAL_<KEY>_BLUESKY_APP_PASSWORD, or SOCIAL_<KEY>_X_API_KEY, _X_API_SECRET, _X_ACCESS_TOKEN and _X_ACCESS_SECRET. The routine is SOCIAL_ROUTINE_URL and SOCIAL_ROUTINE_TOKEN. No AI API key or subscription token is stored anywhere.
- People (
/people, the Owner only; anyone else gets a 404): who is in Carrel, their kind (Owner, person, or reviewer), and their role on each project. The Owner adds a person, shares a project as Reader or Editor, removes a role, and disables or re-enables someone. Everything goes throughapp/lib/people-admin.server.ts, which checks the Owner. It refuses a second Owner (the database refuses one too), disabling or re-roling the Owner, and a reviewer as Editor (reviewers read and flag only). - Access is outside Carrel. Adding a person here does not let them past Cloudflare Access, and the page says so after each add, naming where to allow the same email:
- Carrel's pages: the Worker's Access policy. The Worker's Access tab offers only Cloudflare account and Email domain, so one extra address goes in an Emails rule in Zero Trust > Access controls > Policies, or on the Worker's application in Zero Trust.
- The AI door: the Carrel people policy of the Carrel AI Door application.
- Someone without a Cloudflare account signs in with an emailed code only if One-time PIN is set up, under Zero Trust > Integrations > Identity providers.
- Flags (
/p/<project>/flags): every flag on a site project, open first, from checks, AI sessions and reviewers, including flags on items the site does not have, which no editor page reaches. Anyone who can read the project sees them. Only the Owner dismisses, through the editor's own dismiss function, so a dismissal is recorded the same way.
A privacy or terms page is an ordinary page on the site (item page.privacy or page.terms, registered on the site, never made by Carrel) and opens in the normal editor. The Legal tab (/p/<project>/legal) lists the two, says which are not set up on the site, and holds the shared sections.
- Fields: a legal page's frontmatter carries
legal_type(privacyorterms),site_name,operator_name,contact,jurisdiction,data_held,last_updatedandbanner. The editor shows them as labelled fields.banneris a checkbox that writes the one wordDraft; the Owner clears it when the page is done. The site's layout decides how to show them. - Shared sections (the
legal_sectionstable, migration 0008): text every site says alike, written once. A page names one with<!-- shared:hosting -->; on every write to the site Carrel writes the section's text between that marker and<!-- /shared:hosting -->, with{{site_name}},{{operator_name}},{{contact}},{{jurisdiction}}and{{data_held}}filled in from the page's own fields. Each site still publishes its own page, so changing a section changes nothing public until a page is next saved; the Legal tab marks a page whose text is behind its sections. Only the Owner changes a section. A save as draft keeps an empty field's{{token}}; a publish, or a save to a live page, is refused naming the empty field or the unknown section. Tests:test/legal.test.ts. last_updatedis stamped with the day when the Owner publishes a text that changed, or a draft going public, and not by a draft save or a change to a field other than the text.- The license page: dustinedwards.info's
/termsis the license named by the Dataset JSON-LD and theLink: rel="license"header, so Carrel refuses to unpublishpage.terms(keepPublicinapp/lib/sites.server.ts) and refuses to change the path of any legal page. Its wording can change. The test isPLANT: the page the site's data license namesintest/legal.test.ts. - AI sessions treat legal pages like any other page: drafts are saved as labelled AI drafts, and only the Owner publishes.
A person with no role on a project gets the same 404 as for a project that does not exist.
The roles are checked in app/lib/content.server.ts, app/lib/books.server.ts and app/lib/ai.server.ts, not in the routes or the MCP tools, so both doors meet the same rule. A first publish asks for confirmation, as the site's own editor does.
