# Bulleted > Bulleted (https://bulleted.app) is an outliner where every bullet is an AT > Protocol record in the writer's own repository. There is no Bulleted database > of documents: the site is an app view over public records, and the records > belong to the identities that wrote them. An agent that understands the six > record types below can read, write, and restructure outlines with ordinary > AT Protocol repository operations. ## Technical overview - Every bullet is an `app.bulleted.node` record in its author's Personal Data Server (PDS), addressed `at://{did}/app.bulleted.node/{rkey}`. - bulleted.app is a confidential OAuth backend-for-frontend: the browser holds no tokens, and the server performs XRPC writes against the user's PDS on its behalf. Reads come from an app view fed by the network relay firehose, so records written by any client — including yours — appear on the site without telling Bulleted about them. - Structure is child-points-to-parent only. A node names its `parent` by AT-URI; there is no children array anywhere, and inventing one does nothing. - Sibling order is a lexicographic `sortKey` string on each record (a fractional index), separate from the record key because record keys are TIDs, which sort by creation time and cannot change without breaking inbound references. - Deletes do not cascade. Deleting a parent leaves its children as records whose parent does not resolve; Bulleted surfaces those on the owner's `/detached` page rather than hiding them. ## The lexicon Six record collections, all under the `app.bulleted.*` namespace. Facets are `app.bsky.richtext.facet`, parsed from plain text — links, mentions, and tags. ### app.bulleted.node — a bullet Key: `tid`. Required: `text`, `sortKey`, `createdAt`. - `text` (string, max 10,000 bytes / 2,000 graphemes) — the bullet's content. - `parent` (at-uri, optional) — the parent node. Absent means top level. A bare AT-URI rather than a strongRef, because the parent's CID changes on every edit. - `sortKey` (string, max 512) — fractional index among siblings. Lexicographic: "a" < "b" < "ba". To append after the last sibling, take its key and append a character; to insert between two keys, choose any string strictly between them. - `layout` (string, open set) — `bullet` (default), `todo`, `h1`, `h2`, `h3`, `codeBlock`, `quoteBlock`. Unknown values are preserved, not rejected. - `display` (string, open set) — `expanded` (default) or `collapsed`: the author's fold hint for readers. Readers' own view state overrides it. - `completedAt` (datetime, optional) — presence marks a `todo` complete. There is no boolean anywhere; presence encodes completion. - `createdAt` (datetime, required). ```json { "$type": "app.bulleted.node", "text": "Ship the beta", "parent": "at://did:plc:example/app.bulleted.node/3kproject111", "sortKey": "a1", "layout": "todo", "completedAt": "2026-08-01T12:00:00.000Z", "createdAt": "2026-07-30T09:30:00.000Z" } ``` ### app.bulleted.note — longer text under a bullet Key: `tid`, and **co-keyed with its node**: the note for `at://{did}/app.bulleted.node/{rkey}` lives at `at://{did}/app.bulleted.note/{rkey}` — same repository, same record key. Required: `subject`, `text`, `createdAt`. - `subject` (at-uri) — the node this supplements. Derivable from the note's own address, but stored so the record is legible in isolation. - `text` (string, max 100,000 bytes / 20,000 graphemes), plus optional `facets`. ```json { "$type": "app.bulleted.note", "subject": "at://did:plc:example/app.bulleted.node/3kshipbeta22", "text": "Cut scope to the three launch features. Ask @teammate.example first.", "createdAt": "2026-07-30T09:31:00.000Z" } ``` ### app.bulleted.mirror — transclusion Key: `tid`. Required: `original`, `sortKey`, `createdAt`. A live reference that renders another node's subtree in place — including another identity's. A bare AT-URI, deliberately: a strongRef would pin a CID and freeze the content. ```json { "$type": "app.bulleted.mirror", "original": "at://did:plc:other/app.bulleted.node/3ktheirnode1", "parent": "at://did:plc:example/app.bulleted.node/3kproject111", "sortKey": "a2", "createdAt": "2026-07-30T10:00:00.000Z" } ``` ### app.bulleted.comment — a comment, owned by its author Key: `tid`, in the **commenter's** repository, not the bullet owner's. Required: `subject` (the node's at-uri), `text` (max 10,000), `createdAt`. Whether it renders is decided by the bullet owner's comment policy at view time. ```json { "$type": "app.bulleted.comment", "subject": "at://did:plc:example/app.bulleted.node/3kshipbeta22", "text": "The importer is ready when you are.", "createdAt": "2026-08-01T15:00:00.000Z" } ``` ### app.bulleted.commentPolicy — who may comment Key: `any` — `self` for the whole repository, otherwise any key with `root` naming a node, governing that node and its descendants. Required: `allow`, `createdAt`. **Absence of any policy means nobody may comment; silence is the closed state.** An empty `allow` closes a level that would otherwise inherit. The one defined rule is `mentioned`: every identity mentioned in the bullet or any bullet above it may comment. Unknown rules grant nothing. ```json { "$type": "app.bulleted.commentPolicy", "allow": ["mentioned"], "createdAt": "2026-07-30T09:00:00.000Z" } ``` ### app.bulleted.outline — title and description Key: `any` — `self` for the identity's whole forest, otherwise a key with `root` naming the subtree it titles. Optional `title` (200 graphemes), `description` (600 graphemes), and a preview `image` blob (png/jpeg/webp, 1 MB). ```json { "$type": "app.bulleted.outline", "title": "Product notes", "description": "Everything in flight, one level per team.", "createdAt": "2026-07-30T08:00:00.000Z" } ``` There is also `app.bulleted.space` (a shared outline under AT Protocol permissioned-space proposal 0016) and two published permission sets (`app.bulleted.appAccess`, `app.bulleted.authFull`) an OAuth client requests to write these collections on a user's behalf. ## Worked example: records in, outline out These five records in `did:plc:example`'s repository: ``` node/3ka { text: "Groceries", sortKey: "a" } node/3kb { text: "Eggs", parent: …/node/3ka, sortKey: "a", layout: "todo" } node/3kc { text: "Bread", parent: …/node/3ka, sortKey: "b", layout: "todo", completedAt: "2026-08-01T12:00:00.000Z" } note/3kb { subject: …/node/3kb, text: "Jumbo, if they have them" } mirror/3kd { original: at://did:plc:other/app.bulleted.node/3krecipes, parent: …/node/3ka, sortKey: "c" } ``` render at `https://bulleted.app/public/did:plc:example` as: - **Groceries** — a parent bullet with three children, in sortKey order - ☐ Eggs — an unticked todo, with a note ("Jumbo, if they have them") beneath - ☑ Bread — a ticked todo, struck through - *Recipes…* — `did:plc:other`'s subtree, transcluded live and read-only here; edits land in the original author's repository and appear on the next render Add a `commentPolicy` with `["mentioned"]` and mention `@friend.example` in "Groceries", and friend's `app.bulleted.comment` records against any of these nodes render as a thread on the bullet's own page, with a chat mark beside the bullet everywhere else. ## Reading, for agents - `GET https://bulleted.app/xrpc/app.bulleted.getOutline?did={did}&node={rkey}&depth={1..5}` — the public outline as JSON, no authentication, zoomed to one bullet with `node`, `depth` clamped to 5 (default 3). Use this rather than scraping HTML. - `GET https://bulleted.app/public/{did}/export/markdown` (also `/export/opml`, `/export/json`) — whole-outline exports. JSON is the fidelity format. - Pages at `/public/{did}` and `/public/{did}/{rkey}` are server-rendered HTML. - Records are also readable directly from the identity's PDS with `com.atproto.repo.listRecords` / `getRecord` — the app view is a convenience, not the authority. ## Writing, for agents Write records into the **user's own repository** over AT Protocol — Bulleted needs no notification; the firehose brings your writes to the site. For the mechanics of listing, getting, creating, updating, and deleting records agentically, use atpmcp and its documentation: https://ngerakines.me/atpmcp.html atpmcp is an MCP server exposing AT Protocol record operations (list, get, create, update, delete) plus lexicon validation — pair it with the schemas above and validate before writing. ## Dos, don'ts, and rate limits Rate limits, via bulleted.app's own API (the editor's write path): - A rolling per-identity write budget of **5,000 points per hour**: a create costs 3, an update 2, a delete 1. Exhaustion answers **HTTP 429 with a Retry-After header** — honor it; the queue is paused, not rejected. - Writes made directly to a PDS are governed by that PDS's own rate limits (hourly and daily), which are separate. Batch with `com.atproto.repo.applyWrites` where possible. - An app-view repository is bounded at **250,000 records**; exports are bounded similarly. Do not treat an outline as unbounded storage. Do: - Read structure with `getOutline` or the JSON export; subscribe to the page's SSE event stream rather than polling when you need liveness. - Point `parent` at an `app.bulleted.node` in the **same repository**; give every node a `sortKey`; append siblings by extending the last sibling's key. - Create a todo complete by writing `completedAt` on the create — one write, not two. - Keep a note co-keyed with its node, and create both in one `applyWrites`. - Validate records against the lexicons before writing (atpmcp can). Don't: - Don't invent children arrays, ordering integers, or any field the lexicon does not define; unknown *layout* values are preserved, unknown *structure* is ignored. - Don't use strongRefs for `parent`, `original`, or `subject` — these are bare AT-URIs by design, because content changes and CIDs pin. - Don't delete a parent without re-parenting its children; nothing cascades, and the children become detached. - Don't write `commentPolicy` records naming other people's bullets as `root`; the app view ignores a policy from a repository that does not own the bullet. - Don't expect a comment to render just because it was written: rendering is policy-filtered at view time, and silence is the closed state. - Don't hammer: respect Retry-After, back off on 5xx, and prefer one batched commit over many single-record writes. ## More - Help for humans: https://bulleted.app/help - AT Protocol primers: https://bulleted.app/help/atprotocol - Security policy: https://bulleted.app/security - Privacy: https://bulleted.app/privacy