Plan: Leaflet AI Agent API #
Context #
LLM agents need a way to read and modify leaflet documents programmatically. This API provides simple REST endpoints authenticated via permission tokens, under a dedicated /api/ai/ path separate from the existing /api/rpc/ routes.
Page Model (Important) #
The root entity has a single root/page ordered-reference pointing to the main page entity. That page contains blocks via card/block ordered-references. Some blocks have type card with a block/card reference to another page entity (a subpage). Subpages are opened by navigating from card blocks -- they're not siblings of the root page.
So the hierarchy is: root_entity → (root/page) → main_page → blocks → [card block → subpage → blocks → ...]
The API defaults to the main page. Subpages appear as titled references in the markdown output. Agents can request a specific subpage by entity ID.
API URL Structure #
app/api/ai/
lib.ts -- Shared auth, DB queries, YJS utilities
doc/route.ts -- GET: read page as markdown
search/route.ts -- GET: search blocks
blocks/route.ts -- POST: add new blocks
blocks/[blockId]/route.ts -- PATCH: edit block text, DELETE: remove block
Authentication: Authorization: Bearer <token-uuid> header on all requests.
File 1: app/api/ai/lib.ts -- Shared utilities #
Auth: authenticateToken(request) #
- Extract token UUID from
Authorization: Bearerheader - Query
permission_tokens+permission_token_rightsvia Supabase client - Return
{ tokenId, rootEntity, tokenRights, permissionSet }or 401 permissionSet: first entity_set with write=true
Page resolution: resolvePageEntity(rootEntity, pageParam?) #
- Query facts via Supabase:
entity=rootEntity, attribute='root/page'→ get[0].data.valueas main page - If
pageParamprovided: verify it exists as a page entity in this document, return it - Otherwise return the main page entity ID
Block fetching: getPageBlocks(pageEntity) → Block[] #
Server-side version of getBlocksWithTypeLocal from src/replicache/getBlocks.ts:
- Query
card/blockfacts for the page via Supabase, sort bydata.position - For each block: get
block/type, handle list blocks recursively (same logic asgetBlocksWithTypeLocal) - Return array matching the
Blocktype fromcomponents/Blocks/Block.tsx
Block-to-HTML: blocksToHTML(blocks, allFacts) #
Server-side version of getBlocksAsHTML from src/utils/getBlocksAsHTML.tsx:
- Reuse the same
BlockTypeToHTMLrendering logic but reading from pre-fetched facts instead of ReplicachescanIndex - Uses
RenderYJSFragment+renderToStaticMarkupfor text/heading/blockquote - For
card(subpage) blocks: render as<a href="subpage:entityId">Subpage Title</a>(extracting title from the subpage's first heading) - Uses
parseBlocksToListfromsrc/utils/parseBlocksToList.tsfor list structure
Blocks-to-markdown: blocksToMarkdown(blocks, allFacts) #
- Call
blocksToHTML()to get HTML array - Join with
\nand convert viahtmlToMarkdown()fromsrc/htmlMarkdownParsers.ts - This reuses the exact same copy-to-clipboard conversion pipeline (
copySelection.ts)
YJS plaintext extraction: extractPlaintext(base64Value) #
Reuse established pattern (e.g., app/[leaflet_id]/page.tsx:143):
let doc = new Y.Doc();
Y.applyUpdate(doc, base64.toByteArray(base64Value));
let nodes = doc.getXmlElement("prosemirror").toArray();
return YJSFragmentToString(nodes[0]);
Uses YJSFragmentToString from src/utils/yjsFragmentToString.ts.
YJS text creation: createYjsText(plaintext) #
Create Y.js doc matching ProseMirror's expected XML structure:
let doc = new Y.Doc();
let fragment = doc.getXmlFragment("prosemirror"); // matches mountProsemirror.ts:328
let paragraph = new Y.XmlElement("paragraph");
let textNode = new Y.XmlText();
textNode.insert(0, plaintext);
paragraph.insert(0, [textNode]);
fragment.insert(0, [paragraph]);
return base64.fromByteArray(Y.encodeStateAsUpdate(doc));
YJS text editing: editYjsText(existingBase64, operation) #
Operations:
{ type: "replace", content: string }-- replace full text{ type: "insert", position: "start" | "end", content: string }{ type: "insert", position: { before: string } | { after: string }, content: string }
Implementation:
- Create Y.Doc, apply existing state via
Y.applyUpdate - Get XmlElement, navigate to the paragraph's XmlText child
- Get plaintext via
toDelta()to find character offsets - For
replace: delete(0, length), insert(0, content) - For
insertatstart/end: insert at offset 0 or text.length - For
before/afterstring: find offset in plaintext, insert at offset or offset+search.length - If search string not found: return
{ error: "search_not_found", fullText } - Return base64 of
Y.encodeStateAsUpdate(doc)
Realtime poke: broadcastPoke(rootEntity) #
Pattern from push.ts:170-174:
let channel = supabase.channel(`rootEntity:${rootEntity}`);
await channel.send({ type: "broadcast", event: "poke", payload: { message: "poke" } });
await supabase.removeChannel(channel);
File 2: app/api/ai/doc/route.ts -- Read Page as Markdown #
GET /api/ai/doc?page=<pageEntityId>
- Authenticate token
- Resolve page entity (main page or specified subpage)
- Fetch blocks via
getPageBlocks() - Convert to markdown via
blocksToMarkdown() - Find all
cardtype blocks to list subpages with titles and IDs
Response:
{
"title": "Document Title",
"markdown": "# Heading\n\nParagraph text...\n\n[Subpage Title](subpage:entity-id)",
"subpages": [{ "id": "entity-uuid", "title": "Page Title" }]
}
Subpages appear inline in the markdown where the card block is, as links. The subpages array also lists them separately for easy lookup.
File 3: app/api/ai/search/route.ts -- Search Blocks #
GET /api/ai/search?q=<query>&page=<pageEntityId>
- Authenticate token
- Resolve page (defaults to main page)
- Get all blocks for the page
- For text-bearing blocks: extract plaintext and case-insensitive substring match
- For code blocks: search
block/codestring directly
Response:
{
"results": [
{ "blockId": "uuid", "type": "text", "text": "Full block text" }
]
}
File 4: app/api/ai/blocks/route.ts -- Add Blocks #
POST /api/ai/blocks
Request:
{
"page": "page-entity-id (optional)",
"position": "start" | "end" | { "after": "block-id" } | { "before": "block-id" },
"blocks": [
{ "type": "text", "content": "Hello world" },
{ "type": "heading", "content": "Title", "level": 2 },
{ "type": "code", "content": "console.log('hi')", "language": "js" },
{ "type": "horizontal-rule" }
]
}
- Authenticate token, verify write access
- Resolve page entity
- Open DB transaction via
pool.connect()+drizzle(client) - Acquire advisory lock:
SELECT pg_advisory_xact_lock(<tokenHash>)(same hash aspush.ts— serializes with Replicache pushes) - Get existing blocks to compute positions (inside transaction, after lock)
- Use
cachedServerMutationContext(tx, tokenId, tokenRights) - For each block:
v7()for new entity ID and fact ID- Compute position with
generateKeyBetweenbased on anchor and neighbors - Call
addBlockmutation fromsrc/replicache/mutations.ts - Assert text content via
createYjsText(), heading level, code content, etc. - Chain positions: each subsequent block goes after the previous
flush()the cached context- Broadcast poke
Position computation:
"start":generateKeyBetween(null, firstBlock?.position)"end":generateKeyBetween(lastBlock?.position, null){ "after": id }:generateKeyBetween(targetBlock.position, nextBlock?.position){ "before": id }:generateKeyBetween(prevBlock?.position, targetBlock.position)
Response:
{ "blocks": [{ "blockId": "new-uuid", "type": "text" }] }
File 5: app/api/ai/blocks/[blockId]/route.ts -- Edit & Delete Block #
DELETE /api/ai/blocks/<blockId>
- Authenticate token, verify write access
- Open DB transaction via
pool.connect()+drizzle(client) - Acquire advisory lock:
SELECT pg_advisory_xact_lock(<tokenHash>) - Verify block entity exists and belongs to permitted entity_set
- Call
ctx.deleteEntity(blockEntity)— this retracts all facts for the entity (block/type, block/text, card/block reference, etc.) - If the block has a
block/imagefact, clean up the storage object viasupabase.storage.from("minilink-user-assets").remove(...)and clear anycover_imagereferences (same logic asremoveBlockinsrc/replicache/mutations.ts) - Broadcast poke
Response:
{ "deleted": "block-uuid" }
404 if block not found or not in permitted entity_set.
PATCH /api/ai/blocks/<blockId>
Request:
{
"action": "replace" | "insert",
"content": "text to insert or replace with",
"position": "start" | "end" | { "before": "search" } | { "after": "search" }
}
- Authenticate token, verify write access
- Open DB transaction via
pool.connect()+drizzle(client) - Acquire advisory lock:
SELECT pg_advisory_xact_lock(<tokenHash>)(same hash aspush.ts— serializes with Replicache pushes and other AI API writes) - Verify block entity exists and belongs to permitted entity_set
- Read block type
- For text/heading/blockquote (YJS content):
- Read existing
block/textfact (inside transaction, after lock) - Call
editYjsText(existingBase64, operation) - If search not found: return 400 with
{ error: "search_not_found", blockText: "..." } - Write new base64 directly to facts table via Drizzle UPDATE
- Read existing
- For code blocks (plain string):
- Read
block/codefact, perform string manipulation, update fact
- Read
- Broadcast poke
Why advisory lock + direct UPDATE: The lock serializes this endpoint with Replicache pushes (which use the same token-hash lock in push.ts). Since we read→modify→write the full YJS state while holding the lock, no concurrent write can interleave — direct UPDATE is safe. We don't need assertFact's Y.mergeUpdates because the lock guarantees we're working on the latest state.
Success response:
{ "blockId": "uuid", "newText": "full updated text" }
Error response (search not found):
{ "error": "search_not_found", "blockText": "the actual full text so agent can retry" }
File 6: .claude/skills/leaflet-ai-api/SKILL.md -- Agent Skill #
Non-user-invocable skill describing how agents use the API.
Key sections:
- URL extraction: How to get the token from
leaflet.pub/<token>URLs - Authentication:
Authorization: Bearer <token>header - Endpoint reference: All endpoints with request/response formats
- Reading pattern: Read doc first to understand structure, note subpage IDs
- Update pattern: Read → Search → Edit/Delete loop
- Read the document to understand structure
- Search for specific content to find block IDs
- Make targeted edits using block IDs + search strings for precise positioning, or delete blocks that should be removed
- Verification: After each edit, re-read the document or search for the block to confirm the edit was applied correctly. If
search_not_founderror occurs, use the returnedblockTextto adjust the search string and retry.
- Subagent recommendation: Strongly encourage agents to delegate the mechanical read→edit→verify loop to a subagent. The main agent decides WHAT to change, the subagent handles the API calls and verification. This prevents API responses from polluting the main conversation context.
- Example subagent prompt template included in the skill
Key Existing Code to Reuse #
| File | What |
|---|---|
src/utils/yjsFragmentToString.ts |
YJSFragmentToString() |
src/htmlMarkdownParsers.ts |
htmlToMarkdown() for blocks→markdown |
src/utils/getBlocksAsHTML.tsx |
BlockTypeToHTML rendering patterns |
src/utils/parseBlocksToList.ts |
parseBlocksToList() for list structure |
src/replicache/mutations.ts |
addBlock mutation |
src/replicache/cachedServerMutationContext.ts |
Write transaction context |
src/replicache/getBlocks.ts |
getBlocksWithTypeLocal block fetching pattern |
components/Blocks/TextBlock/RenderYJSFragment.tsx |
RenderYJSFragment for HTML rendering |
drizzle/schema.ts |
Table definitions (write endpoints only) |
supabase/pool.ts |
DB connection pool (write endpoints only) |
Implementation Order #
app/api/ai/lib.tsapp/api/ai/doc/route.tsapp/api/ai/search/route.tsapp/api/ai/blocks/route.tsapp/api/ai/blocks/[blockId]/route.ts.claude/skills/leaflet-ai-api/SKILL.md
Verification #
npm run dev- Get a test token UUID from the browser URL
- Test each endpoint with curl:
curl -H "Authorization: Bearer <token>" localhost:3000/api/ai/doccurl -H "Authorization: Bearer <token>" "localhost:3000/api/ai/search?q=hello"curl -X POST -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d '{"position":"end","blocks":[{"type":"text","content":"Test"}]}' localhost:3000/api/ai/blockscurl -X PATCH -H "Authorization: Bearer <token>" -H "Content-Type: application/json" -d '{"action":"insert","position":"end","content":" appended"}' localhost:3000/api/ai/blocks/<blockId>curl -X DELETE -H "Authorization: Bearer <token>" localhost:3000/api/ai/blocks/<blockId>
- Verify edits appear in the leaflet UI in real-time