11 AUG 2026

Teaching agents to outline

How Bulleted documents its record types for agents, and what happened when one wrote a day of release notes into my PDS via atpmcp.



Bulleted is an outliner where every bullet is an AT Protocol record in your PDS. There is no separate Bulleted database of outlines and content. The site is an AT Protocol AppView that indexes records in PDS and space hosts, and the records are owned and controlled by the identities that created them.

That has a consequence I inherited rather than designed for: Anything that can write records to your PDS can write your outline, and Bulleted does not need to be told about it. Your writes reach the site through the relay firehose or space notifications, like anyone else's. That changes the development and design process. Where before I would ask "what should the editor support", now I also ask "do agents know enough?"

To get there, Bulleted needs to provide agentic resources. That took shape as /llms.txt.

What's in it

One Markdown file covering all six app.bulleted.* collections: node (a bullet), note, mirror, comment, commentPolicy, and outline. Required fields, record key rules, a JSON example each, and a worked example showing five records going in and the rendered outline coming out.

Most of its length goes to the parts that are easy to get wrong, because those are the parts models get wrong. Structure is child-points-to-parent by AT-URI, and there is no children array anywhere. Sibling order is a lexicographic sortKey fractional index, kept separate from the record key because record keys are TIDs and TIDs can't change without breaking every inbound reference. A todo is complete when completedAt is present; there's no boolean. A note is co-keyed with its node, same rkey in a different collection. Deletes don't cascade, and orphaned children land on your /detached page instead of quietly disappearing.

The don'ts list starts with "don't invent children arrays." (I wrote that one from experience.)

Why this suits agents

Those rules are precise, mechanical, and tedious. Appending a sibling means fetching the last one's sortKey and extending it. Adding a note means matching record keys across two collections in a single applyWrites. None of it is hard. All of it is annoying enough that I stopped doing it from the command line.

An agent with the schemas and a working client does it every time without complaining. I've been using atpmcp for this, an MCP server that exposes AT Protocol record operations and lexicon validation.

Reading the schema

The lexicons resolve, so an agent can check itself against the source instead of trusting the documentation:

❯ Fetch the app.bulleted.node lexicon.

⏺ atpmcp - get_lexicon (MCP)(nsid: "app.bulleted.node")
{
  "lexicon": 1,
  "id": "app.bulleted.node",
  "defs": {
    "main": {
      "type": "record",
      "key": "tid",
      "record": {
        "type": "object",
        "required": ["text", "sortKey", "createdAt"],
        "properties": {
          "text":        { "type": "string", "maxLength": 10000, "maxGraphemes": 2000 },
          "sortKey":     { "type": "string", "maxLength": 512 },
          "createdAt":   { "type": "string", "format": "datetime" },
          "parent":      { "type": "string", "format": "at-uri" },
          "completedAt": { "type": "string", "format": "datetime" },
          "layout":      { "type": "string", "knownValues": ["bullet","todo","h1","h2","h3","codeBlock","quoteBlock"] },
          "facets":      { "type": "array", "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } }
        }
      }
    }
  }
}

atpmcp resolved the NSID from its authority alone, with no repository hint. The descriptions come back with the schema, which is where I put the reasoning: parent is a bare AT-URI because a strongRef pins a CID and the parent's CID changes on every text edit. sortKey has to live in a mutable field because TIDs can't move. completedAt encodes completion by presence. layout uses knownValues so a value written by a later version validates instead of failing.

Writing a day

The schema is the easy half. Here is the prompt I've been using a lot recently:

❯ Use the atpmcp MCP to create "2026-08-11" under "Release Notes" and
  populate it with bullet records that summarize activity from that day.

The full output is long, so here it is in summary. Resolve "Release Notes" to 3msg3hslqz5zl. Read its existing children to learn where a new date belongs. Gather the day's activity from the repository. Mint record keys. Write the date node and its seven bullets as one atomic batch, all sharing rev 3msstzwihee4u. Read the parentage back to confirm the AppView indexed it: childCount: 7, every child naming the date node as its parent.

Then it was live, an h3 date node over seven fixes, without me opening the editor:

Three things came out of that run that I did not know going in.

The sortKey convention is inverted, and only reading the siblings reveals it. Existing dates run a0 for 08-06, then Zz, Zy, Zx, Zw for 08-10. Newest gets the lexicographically smallest key so it sorts to the top, and Z beats a in ASCII, which is what keeps the original a0 pinned at the bottom. So 08-11 took Zv. Guessing a7 would have appended the new day below the oldest entry, and nothing in the lexicon would have flagged it. The convention lives in the records. The schema says nothing about it.

One applyWrites beat eight createRecord calls. Because the keys are minted up front, the date node's AT-URI is known before the record exists, so its children can name it as parent in the same atomic commit. There is no window where a child is an orphan. This is the thing I would have gotten lazy about by hand.

Every result came back validationStatus: "unknown". The PDS did not validate these against app.bulleted.node at all, because it doesn't resolve third-party lexicons at write time. Required fields, sortKey format, layout values: all of that was my responsibility rather than the server's. So validate locally before writing. Publishing a lexicon describes the shape of a record. It does not make anyone check.

The rest

Other prompts in the same register, each an ordinary sentence hiding several record operations:

❯ Everything under "Ship the beta" that I marked done in yesterday's
  notes, set completedAt on.

❯ Attach a note to the "Importer" bullet with the schema decisions from
  this thread, then mirror the API subtree from @demo.bulleted.app under it.

That last one writes a note co-keyed with its node and a mirror pointing at a bare AT-URI in someone else's repository, transcluded live and read-only. I never built a UI for that combination, and I am no longer sure I need to.

Enjoyed this article?

Join our free newsletter and never miss an update.


Related Articles