TextShelf

Developers › File formats Beta

The .textshelf shelf bundle, v1

Status: v1: stable to read; fields may be added; a higher schemaVersion means stop.

This is the format of a TextShelf shelf on disk, as the code reads and writes it on 2026-10-12. Where the code and an earlier draft disagree, this follows the code. It replaces spikes/s2-shelf-bundle/BUNDLE_FORMAT.md ("v1 draft").

What reads and writes bundles today:

  • The TextShelf Mac app, through ShelfCore's ShelfDocument. It reads and writes every file below except agent-brief.json, which it keeps unread.
  • The app's Quick Look extension and its App Intents catalog. They only read.

The web app does not read or write bundles. Other programs may, for example to let an agent read and mark a shelf. What they must do is in §3 and with each file.

Every example in this document is the output of the code's own encoder for that file, byte for byte, and each one decodes with the code's own reader. The names, ids and text in them are made up.

1. What a bundle is

A bundle is a directory whose name ends in .textshelf. macOS shows it as one document: type com.textshelf.shelf, conforming to com.apple.package and public.composite-content.

The JSON files are UTF-8. A reader must not depend on key order, spacing or line breaks.

2. Layout

<name>.textshelf/
├── shelf.json                     The shelf. Required.
├── settings.json                  The rubric.
├── web-link.json                  Only on a shelf built from a web review packet.
├── agent-brief.json               What the shelf is for, for an agent.
├── assistant-policy.json          What readers' own AI assistants may do.
├── submissions/
│   └── <document id>/             One folder per document.
│       ├── submission.json        The document's record. Required.
│       ├── primary.pdf            The document. May be missing.
│       ├── attachments/
│       │   └── <file name>        Other files kept with the document.
│       ├── source.json            What primary.pdf was converted from.
│       ├── annotations.json       Highlights and their comments.
│       ├── reviews/
│       │   └── <review id>.json   This reviewer's review.
│       ├── external-reviews.json  Reviews imported from the web. Read-only.
│       ├── summary.json           A cached summary of the document.
│       ├── digest.json            A cached digest of the reviewer's marks.
│       ├── ink.json               Reserved. Nothing reads or writes it.
│       └── ink/                   Reserved. Nothing reads or writes it.
└── exports/                       Dropped on save. Nothing writes it.

What the Mac does with each one:

Path Written If absent If it can't be read
shelf.json every save the shelf does not open the shelf does not open
settings.json every save the kind's starting rubric is used, and written on the next save the shelf does not open
web-link.json every save, when the shelf has a link an ordinary shelf the shelf does not open
agent-brief.json by other writers only, never the app no brief treated as no brief; the app keeps the file byte for byte
assistant-policy.json by the person's own choice, never by an assistant (§5.5) the kind's default (§5.5) treated as off; TextShelf for Mac 1.0 doesn't read it and keeps the file byte for byte
submissions/ every save a shelf with no documents (only its non-empty folders are read)
submission.json every save the document is left unopened (§4) the document is left unopened
primary.pdf when it changes the document opens without its reading same as absent
attachments/ when it has files no attachments (only regular files inside are read)
source.json by the app on adding a converted file, and by other writers no record treated as no record; the app keeps the file byte for byte
annotations.json every save no highlights the document is left unopened
reviews/ when the document has reviews no reviews one bad file leaves the document unopened
external-reviews.json when the document has imported reviews none the document is left unopened
summary.json when the document has one none the document is left unopened
digest.json when the document has one none the document is left unopened

3. Shared rules

3.1 How the field tables read

  • required: a missing key, or a value of the wrong type, fails the file.
  • optional: the key may be missing. A value of the wrong type still fails the file. A value in brackets, as in optional ([]), is what a missing key means; the Mac always writes those keys. It leaves out an optional key that has no value.
  • lenient (X): a missing key, or a value that can't be read, gives X. It never fails the file.

What failing a file costs is in §3.8.

3.2 Identifiers

A UUID is a string in the 8-4-4-4-12 hex form. The Mac writes uppercase. Readers accept either case.

A document's folder is named by its id, and a review's file by the review's id. On read, the name doesn't matter: the id inside the file wins. On save, the Mac writes submissions/<ID>/ and reviews/<ID>.json in uppercase and removes any other name.

A SHA-256 is 64 lowercase hex digits.

3.3 Dates

There are two conventions. Which one applies depends on the file.

Files Read with What a reader accepts What the Mac writes
shelf.json, web-link.json, submission.json, annotations.json, reviews/*.json, external-reviews.json, summary.json, digest.json ShelfCore's ShelfJSON.decoder ISO 8601 date and time with fractional seconds, Z or a ±hh:mm offset. Fractions are kept to the millisecond. A date with no fraction fails. UTC, three digits: 2026-10-12T09:47:03.215Z
source.json, agent-brief.json Foundation's .iso8601 strategy ISO 8601 date and time in whole seconds. On macOS 27.0.1 it also accepted a fraction; older systems were not checked. UTC, whole seconds: 2026-10-12T08:12:31Z

(settings.json has no dates.)

In short: the bundle files require fractional seconds on read, and source.json and agent-brief.json use Foundation's plain .iso8601. Making them agree would be a change to the Mac code, and is not part of this spec.

What an unreadable date costs:

  • A required date (createdAt, submittedAt and the like) fails its file.
  • userModificationDate on a highlight, a comment or a review falls back to createdAt or submittedAt.
  • provenance.generatedAt makes the whole provenance unreadable, and an unreadable provenance is read as human (§6.5). Write the fraction.
  • A bad date in source.json or agent-brief.json makes that file read as absent.

The Mac rounds every date it holds to the millisecond, so a date it writes reads back equal.

3.4 Numbers and score values

  • Counts, indexes, bounds and web ids are JSON integers. A number with a fractional part fails where an integer is expected.
  • geometry.lineRects in annotations.json is the only place with fractional numbers.
  • A score value is an integer or a string, nothing else. That covers scores in reviews/*.json and external-reviews.json, baseline in web-link.json, and choices[].value in settings.json. A float, a boolean or null fails: in choices it costs the field its labels; anywhere else it fails the file.

3.5 Unknown keys

A reader ignores a key it doesn't know. The Mac does not keep it: on each save it rewrites every JSON file it models from what it decoded, so an unknown key is gone after the next save. A key added by a later build is lost when an earlier build saves the shelf.

agent-brief.json and source.json are kept byte for byte by the app, because it treats them as unknown files (§3.6). Another writer that rewrites source.json may drop the keys it doesn't know.

3.6 Unknown files and folders

  • At the bundle root and in each document folder, a child the reader doesn't know is kept byte for byte across read and save. A folder is kept whole.
  • exports/ at the root is dropped on save.
  • Symbolic links are dropped. So is a file whose contents can't be read.
  • Inside submissions/, only non-empty folders are documents. A file or an empty folder there is dropped on save.
  • Inside attachments/, only regular files are kept. A folder there is dropped on save.
  • Inside reviews/, every regular file is read as a review, whatever its name. A stray file there (a .DS_Store, say) leaves the document unopened. Put nothing else in reviews/.
  • A name the reader writes itself always wins over a kept child of the same name.

3.7 schemaVersion and kind

Every JSON file has an integer schemaVersion at its root. The current version is 1. It is the only version field; no file has another.

File Checked What a value above 1 does
shelf.json, settings.json, web-link.json yes The shelf does not open. The error reads: "<file> has schemaVersion N; this build supports up to 1. Update the app to open this shelf."
submission.json, reviews/*.json, external-reviews.json, summary.json, digest.json yes That document is left unopened (§4), with the reason "It was saved by a newer version of TextShelf. Update the app to open it." The rest of the shelf opens.
annotations.json no Nothing stops. The file is read as version 1, and the next save rewrites it with the number it had, without the keys this reader doesn't know.
source.json, agent-brief.json no Nothing stops. The key must be present; its value is not looked at.
assistant-policy.json by its readers A reader that meets a version above 1 reads the shelf as off (§5.5).

Only values above 1 are refused; 0 or a negative number passes. Writers write 1. The Mac writes back the number it read. The App Intents catalog reads shelf.json and each submission.json without checking the version; it writes nothing.

Four files also carry a kind string:

File kind Checked
web-link.json textshelf-web-link yes. Any other value fails the file, and the shelf does not open.
source.json textshelf-document-source no. The key must be present; its value is not looked at.
agent-brief.json textshelf-agent-brief no. The key must be present; its value is not looked at.
assistant-policy.json textshelf-assistant-policy by its readers. Any other value reads as off.

3.8 How far a failure reaches

  • A root file that can't be read (shelf.json, settings.json, web-link.json) stops the whole shelf from opening.
  • A document file that can't be read leaves that one document unopened (§4).
  • source.json and agent-brief.json that can't be read count as absent.

No reader skips a bad element and keeps the rest of its file. One bad highlight, comment, review, imported review, web-link entry or rubric field fails its whole file. The tolerance the code has is per key: the keys marked lenient in the tables. A writer must not write an element that current readers can't decode.

3.9 How the Mac writes JSON

Bundle files: pretty-printed with two-space indents, keys sorted, / not escaped. source.json and agent-brief.json: pretty-printed, keys sorted, / escaped as \/.

Two arrays are sorted before writing, so an unchanged shelf saves to the same bytes: web-link.json entries by sourcePk, then submissionId; external-reviews.json reviews by submittedAt, then externalReviewId. Other arrays keep their order.

4. A document that can't be read

A document is left unopened when its submission.json is missing or bad, when annotations.json, summary.json, digest.json or external-reviews.json is bad, when any file in reviews/ is bad, or when a checked file has a schemaVersion above 1.

Then the Mac:

  • opens the rest of the shelf;
  • lists the document with a reason ("Part of it (<file>) is damaged.", "Part of it (<file>) is missing." or the newer-version reason in §3.7);
  • leaves its folder byte for byte on every save, including files that arrive after the shelf was read.

A missing or unreadable primary.pdf does not leave a document unopened.

5. Root files

5.1 shelf.json

Key Type Meaning
schemaVersion integer required 1.
id UUID required Made with the shelf. Never changes. A copy of a shelf gets a new one.
name string required The display name. Writers keep it equal to the bundle's file name without .textshelf. The app takes the name from the file name when it opens a shelf and when the file is renamed, and writes it on the next save. A reader that has the path should prefer the file name.
type string required The kind of shelf, using the web's workspace_type strings. The Mac offers literary_contest, grant_program, academic_review and custom. Any other string opens as custom and is written back unchanged, until someone picks a kind.
createdAt date required When the shelf was made.
modifiedAt date required Moved forward when the shelf is edited.

A shelf built from a review packet takes the program's kind, which may be any of the web's strings. Per docs/native-shelf-types-plan.md, builds up to and including 50 read type strictly and fail on any other string.

{
  "createdAt" : "2026-07-20T16:41:07.392Z",
  "id" : "6F1C2A84-3B5E-4D0A-9C7E-2F4B8D1E6A30",
  "modifiedAt" : "2026-10-12T09:47:03.215Z",
  "name" : "Spring 2026 Fiction Prize",
  "schemaVersion" : 1,
  "type" : "literary_contest"
}

5.2 settings.json

Key Type Meaning
schemaVersion integer required 1.
rubric object required {"fields": [...]}. fields is required.
seededFieldIDs array of strings optional The field ids the kind's starting rubric wrote when the shelf was made, in order. When someone picks another kind, the app replaces the rubric only if it is empty, or if its field ids still equal this list and no review, imported review or highlight uses them. Absent when the shelf never had a starting rubric. A writer that sets a new rubric removes it.

Each element of rubric.fields:

Key Type Meaning
id string required Stable. Review scores and highlights' criterionIDs point at it, so it survives a relabel. The rubric editor makes field_<hex> ids; starting rubrics use the ids in workspace-packs.md.
label string required Shown to the reviewer.
type string required score or text. Any other value fails the file, and the shelf does not open.
min integer optional Lowest score. The app uses 1 when absent.
max integer optional Highest score. The app uses 5 when absent, and never less than min.
choices array lenient (none) A labelled scale: [{"value": ..., "label": "..."}], both required. value is an integer for a score field and a string for a text field; it is what the review stores. One unreadable choice drops all of them. The app writes it only on a shelf built from a review packet.
help string lenient (none) Guidance shown under the label.

With no settings.json, the app uses the kind's starting rubric (none for custom) and writes it, with seededFieldIDs, on the next save.

A shelf made with the literary contest rubric:

{
  "rubric" : {
    "fields" : [
      {
        "help" : "Language that sounds like this writer and no one else: controlled, specific, and alive on the page, or in the mouth for a play.",
        "id" : "voice",
        "label" : "Voice",
        "max" : 5,
        "min" : 1,
        "type" : "score"
      },
      {
        "help" : "The piece builds. Scenes, sections or stanzas earn their place, and the ending lands.",
        "id" : "structure",
        "label" : "Structure",
        "max" : 5,
        "min" : 1,
        "type" : "score"
      },
      {
        "help" : "Why it matters: the desire, tension or pressure that keeps a reader leaning in.",
        "id" : "stakes",
        "label" : "Stakes",
        "max" : 5,
        "min" : 1,
        "type" : "score"
      },
      {
        "help" : "A finished draft: precise word choice, rhythm in the prose, verse or dialogue, formatting suited to the form, few distracting errors.",
        "id" : "craft_and_finish",
        "label" : "Craft and finish",
        "max" : 5,
        "min" : 1,
        "type" : "score"
      },
      {
        "id" : "notes",
        "label" : "Notes",
        "type" : "text"
      }
    ]
  },
  "schemaVersion" : 1,
  "seededFieldIDs" : [
    "voice",
    "structure",
    "stakes",
    "craft_and_finish",
    "notes"
  ]
}

A shelf built from a review packet:

{
  "rubric" : {
    "fields" : [
      {
        "choices" : [
          {
            "label" : "No",
            "value" : 1
          },
          {
            "label" : "Maybe: worth further consideration",
            "value" : 2
          },
          {
            "label" : "Yes: strongly recommend",
            "value" : 3
          }
        ],
        "id" : "web-move-forward",
        "label" : "Recommend further consideration?",
        "max" : 3,
        "min" : 1,
        "type" : "score"
      },
      {
        "help" : "Describe any conflict with this entry. Leave empty if there is none.",
        "id" : "web-coi",
        "label" : "Conflict of interest",
        "type" : "text"
      },
      {
        "id" : "web-notes",
        "label" : "Notes",
        "type" : "text"
      }
    ]
  },
  "schemaVersion" : 1
}

5.3 web-link.json

Present only on a shelf built from a web review packet (review-packet.md). It ties each document to its web entry. While it is there, the app shows the source on the shelf's cover, locks the rubric editor and offers File ▸ Export Scores for Web…, and other writers should leave the shelf alone. A copy made with Send a Copy never carries it.

Key Type Meaning
schemaVersion integer required 1.
kind string required Must be textshelf-web-link.
source object required Where the packet came from.
source.host string required The web host that issued the packet. The return file sends it back.
source.contestId integer required The web contest's id. The return file sends it back.
source.contestSlug string required The contest's slug. Also stored as each document's externalSource, when not empty.
source.contestName string required The contest's name. A new packet shelf is named after it.
source.issuedAt date required When the packet was issued. The import time, if the packet didn't say.
source.uploadUrl string optional The web page that takes the return file. The app offers it only when it is on host and passes the app's link rules (https, on a host the app fetches packets from).
entries array required One per document that came from the packet. A document added by hand has none and is never exported.
entries[].submissionId UUID required The document this entry became.
entries[].sourcePk integer required The web entry's id.
entries[].externalId string optional The web entry's number. Also stored as the document's externalID.
entries[].bytes integer required The size of the file the packet declared.
entries[].sha256 string optional The file's SHA-256, as the packet gave it.
entries[].assistantPolicy string optional What the reader's own AI assistant may do with this document, as the packet's entry gave it: off, passages or drafts (review-packet.md). A missing or unknown value is off. TextShelf for Mac 1.0 doesn't keep it yet, which leaves the document at off.
entries[].baseline object optional ({}) The reviewer's web answers when the packet was issued, by rubric field id: field id → score value. Only fields this shelf's rubric has. The return file copies it, and the web reads "in the baseline, missing from the values" as cleared.

source holds the packet's source minus workspaceType, which becomes shelf.json's type.

{
  "entries" : [
    {
      "baseline" : {
        "web-assessment" : 4,
        "web-notes" : "Strong opening."
      },
      "bytes" : 1834221,
      "externalId" : "17",
      "sha256" : "9f2c4e8a1b7d3f6e0c5a9b2d8e4f1a7c3b6d9e2f5a8c1b4d7e0f3a6c9b2e5d8f",
      "sourcePk" : 91,
      "submissionId" : "3D9C1A6E-7B2F-4C58-A9E4-1F6B3D8C2A70"
    },
    {
      "baseline" : {

      },
      "bytes" : 402117,
      "externalId" : "19",
      "sourcePk" : 93,
      "submissionId" : "5E2B8D4A-1C7F-4A93-B6E0-8D3F1A5C7E24"
    }
  ],
  "kind" : "textshelf-web-link",
  "schemaVersion" : 1,
  "source" : {
    "contestId" : 42,
    "contestName" : "Spring 2026 Playwriting Prize",
    "contestSlug" : "spring-2026-playwriting",
    "host" : "textshelf.com",
    "issuedAt" : "2026-09-20T14:00:00.000Z",
    "uploadUrl" : "https://textshelf.com/submissions/scores/42/upload/"
  }
}

5.4 agent-brief.json

What the shelf is for, in an agent's words: its task, what each highlight category means for that task, and scoring notes. An agent's tool writes it and reads it back at the start of the next session, so that session reads with the same lens. The app does not read it and keeps it byte for byte. Send a Copy keeps it with Everything and leaves it out with Documents only.

Key Type Meaning
schemaVersion integer required 1. Not checked.
kind string required textshelf-agent-brief. Not checked.
task string required What the shelf is for. Never empty.
categoryGuide object optional Highlight category → what it means here. Keys are note, suggestion, question, praise or issue; write no other.
notes string optional Anything else, such as how to score. Left out when empty.
updatedAt date required When the brief was last set. Whole seconds (§3.3).

If any of this can't be read, a reader treats the shelf as having no brief. A writer replaces the whole file.

{
  "categoryGuide" : {
    "issue" : "A flaw that would cost the story a place on the shortlist.",
    "praise" : "What a judge would quote in the citation.",
    "question" : "Something a second reader should check."
  },
  "kind" : "textshelf-agent-brief",
  "notes" : "Score from the passages you mark; leave Notes for the overall case.",
  "schemaVersion" : 1,
  "task" : "Read each story as a first-round reader for the Spring 2026 Fiction Prize. Mark the passages each score rests on.",
  "updatedAt" : "2026-10-12T08:10:00Z"
}

5.5 assistant-policy.json

What readers' own AI assistants may do with the shelf's documents: the assistant a person brings, which reads the shelf through some other program. TextShelf itself never sends a document to an AI service. The person who keeps the shelf sets the rule; an assistant's tool never does, and never changes it.

Key Type Meaning
schemaVersion integer required 1.
kind string required textshelf-assistant-policy.
level string required off: an assistant doesn't read the documents. passages: it may read them and mark passages as evidence for the rubric, each marked as its own (§6.5), and writes no review. drafts: it may also draft a review, which counts only once the person adopts it.
setAt date required When the person set it. Whole seconds (§3.3).
setBy string optional Who set it, as the app names them.

How a reader decides the rule for a document:

  1. The shelf's level. From this file. If the file is missing, the kind's default: drafts on a custom shelf (the person's own reading), off on every other kind. If it is unreadable, has a higher schemaVersion, another kind or an unknown level: off.
  2. A packet's word. On a shelf with web-link.json, a document from the packet is held to its entry's assistantPolicy too (§5.3; missing is off). The stricter of the two applies: the web's rule was what the document's writer read before sending it.
  3. Strictest wins. The order is off, then passages, then drafts.

Any program that hands a document's text to an AI assistant follows the document's rule, and reading counts: at off it doesn't read the document's text into a model at all, nor its highlights' quoted words.

{
  "kind" : "textshelf-assistant-policy",
  "level" : "passages",
  "schemaVersion" : 1,
  "setAt" : "2026-10-13T09:00:00Z",
  "setBy" : "Ada Reader"
}

6. Document files

Each document is a folder in submissions/.

6.1 submission.json

Key Type Meaning
schemaVersion integer required 1.
id UUID required The document's id, and its folder's name on save.
title string required
author string optional
status string required unread, in_review or reviewed. Any other value fails the file.
externalID string optional The document's number on the web host it came from. Set from a packet, or from a file name like 17 - Title.pdf when the file is added. Matches imported reviews to the document.
externalSource string optional The contest slug externalID belongs to, so two contests' 17s stay apart. Set on packet shelves.
createdAt date required Documents are listed in this order.
modifiedAt date required
{
  "author" : "M. Okafor",
  "createdAt" : "2026-07-20T16:43:15.806Z",
  "externalID" : "17",
  "id" : "0B7E4C19-8A2D-4F36-B5C1-9E3D7A2F4C58",
  "modifiedAt" : "2026-10-12T09:47:03.215Z",
  "schemaVersion" : 1,
  "status" : "in_review",
  "title" : "The Lighthouse Keeper's Daughter"
}

6.2 primary.pdf

The document as a PDF. Highlights are anchored to its page text.

A file that isn't a PDF (Markdown, Word, RTF, OpenDocument, plain text) is converted to a PDF once, when it is added, and the original is kept in attachments/ (§6.3, §6.4). A locked PDF is stored unlocked.

If primary.pdf is missing or can't be read, the document still opens with its highlights and reviews. The Mac leaves whatever is at that path alone on save, and picks up a PDF that arrives there later.

6.3 attachments/

Other files kept with the document, by name, byte for byte. The app keeps the original of a converted document here. A writer that replaces a document keeps the earlier versions here (§6.4). The folder is written only when it has files, and holds regular files only.

6.4 source.json

What primary.pdf was made from, and each version the document has had. The app writes it when it adds a converted file (converter TextShelf). Another writer may write it when it adds a converted file or replaces a document's version, and names itself in converter. Readers use it to tell "the same file again" from a new document, and to point at lines of a Markdown or text original. A document added as a PDF has no source.json until a writer replaces its version.

ShelfDocument doesn't model the file: it is an unknown child to the bundle reader (§3.6), which is why the app keeps it byte for byte.

Key Type Meaning
schemaVersion integer required 1. Not checked.
kind string required textshelf-document-source. Not checked.
converter string required Who made the record: TextShelf, or the name of the program that did. A later version doesn't change it.
versions array required Oldest first. The last is what primary.pdf was made from.
versions[].filename string required The file's name when it was added. A converted original is kept in attachments/ under this name.
versions[].format string required pdf, markdown, word, richText, openDocument or plainText. Any other value makes the whole file unreadable.
versions[].sha256 string required SHA-256 of the file as it was added.
versions[].addedAt date required Whole seconds (§3.3).

A writer that replaces a document that has no record starts one whose first version is {"filename": "primary.pdf", "format": "pdf", ...}, dated with the document's createdAt. A replaced PDF is kept in attachments/ as <title> (version N).pdf, where N counts the versions before the new one. A converted original whose name is taken by different bytes moves the older file to <stem> (version N).<ext>. If such a name is taken too, a number is added (<stem> (version N) 2.<ext>).

If the file can't be read, readers act as if it were absent. A writer then starts a new record when it next replaces the document, and writes it over the old file.

{
  "converter" : "TextShelf",
  "kind" : "textshelf-document-source",
  "schemaVersion" : 1,
  "versions" : [
    {
      "addedAt" : "2026-07-20T16:43:15Z",
      "filename" : "17 - The Lighthouse Keeper's Daughter.md",
      "format" : "markdown",
      "sha256" : "3b7e9c1d5a2f8e4b6c0d9a3f7e1b5c8d2a6f4e9b3c7d1a5e8f2b6c0d4a9e3f7b"
    },
    {
      "addedAt" : "2026-10-12T08:12:31Z",
      "filename" : "17 - The Lighthouse Keeper's Daughter (revised).md",
      "format" : "markdown",
      "sha256" : "e4a8c2f6b0d3e7a1c5f9b2d6e0a4c8f1b5d9e3a7c0f4b8d2e6a1c5f9b3d7e0a2"
    }
  ]
}

6.5 annotations.json

The reviewer's highlights on primary.pdf, each with its comment thread. Written on every save, even when empty.

Key Type Meaning
schemaVersion integer required 1. Not checked (§3.7).
annotations array required The highlights. One element that can't be read fails the file.

Each highlight:

Key Type Meaning
id UUID required
type string required Must be highlight. Any other value fails the file. New kinds of mark go in other files, never here.
category string lenient (note) note, suggestion, question, praise or issue. What the reviewer means by the mark.
pageIndex integer required Page of primary.pdf, from 0.
selector object required Where the highlight is (below).
color string required yellow, green, blue, purple, red, pink or orange; any other name draws yellow. Set from the category when the highlight is made: note yellow, suggestion blue, question purple, praise green, issue red.
createdAt date required
userModificationDate date lenient (createdAt) Last-writer-wins key for sync. Each edit must move it forward, by at least a millisecond.
resolved boolean lenient (false) Whether the thread has been dealt with.
authorName string lenient ("") Display name of whoever made it. Empty means unknown.
provenance object lenient (human) Who made it, a person or an assistant. See "Provenance" below.
criterionIDs array of strings lenient ([]) Rubric field ids this passage bears on, in order. An id the rubric no longer has is kept and shown as unknown.
comments array optional ([]) The thread, oldest first. One comment that can't be read fails the file.

selector:

Key Type Meaning
kind string lenient (textQuote) The Mac writes textQuote. Any string is accepted and kept.
exact string required The selected text, whitespace-normalized. Always the words the reviewer chose, whichever tier holds the anchor.
prefix string lenient (none) Up to 32 characters of normalized page text just before exact.
suffix string lenient (none) Up to 32 characters just after.
lineQuotes array lenient (none) Tier 2: one quote per visual line, each {"exact", "prefix"?, "suffix"?}, exact required. Written when the whole quote isn't one run of page text but each line is.
geometry object lenient (none) Tier 3: where the selection was drawn. pageIndex (integer) and lineRects, one {"x", "y", "width", "height"} per visual line, all required numbers. PDF page space as PDFKit reports it: points, origin at the bottom left, y up. The Mac records it for every new highlight.
createdTier string lenient (none) wholeQuote, lineQuotes or geometry: the tier that held the anchor when it was made. Absent on records from before tiers, which are whole quotes. An unknown value reads as absent.

Normalization: every run of whitespace, line breaks included, becomes one space, and leading and trailing whitespace is removed. prefix and suffix are cut from the normalized page text and may start or end with a space. The page text is PDFKit's PDFPage.string. Another PDF library may extract different text, and then a quote may not be found.

How the Mac finds a highlight:

  1. With createdTier absent or wholeQuote: it looks for exact, with its prefix and suffix around it, in the normalized text of page pageIndex. One match there wins. Several there are settled by geometry when exactly one overlaps it; otherwise this step fails. Only when that page has no match does it look at the other pages, and there it needs exactly one match.
  2. With a lower tier and geometry: it searches for exact too, but takes a match only where it overlaps the stored rectangles.
  3. Then lineQuotes: every line must be found, on one page.
  4. Then geometry: it draws the stored rectangles that fall on the page.
  5. Otherwise the highlight is shown as lost.

A writer that moves a highlight to a new version of a document and can't place it there removes geometry, so the app shows the highlight as lost rather than drawing it over different text.

Each comment:

Key Type Meaning
id UUID required
body string required
authorName string required Display name. Required here, unlike on the highlight.
createdAt date required
userModificationDate date lenient (createdAt) As on the highlight.
provenance object lenient (human) As on the highlight.

Provenance

provenance says whether a person or an assistant made a highlight or a comment. It carries no content.

Key Type Meaning
authorKind string human or assistant.
model string, optional The model that made it.
promptVersion string, optional Which prompt made it, in the writer's own terms.
generatedAt date, optional When the model call ran. Not the same as createdAt.

The decode rule, as the bundle reader applies it:

  1. No provenance key: human. Every record written before the key existed was made by a person.
  2. An object with no authorKind: human.
  3. An authorKind other than human or assistant: unknown. Unknown is never shown as human.
  4. A provenance the reader can't decode at all: human. That covers a value that isn't an object, a non-string authorKind, model or promptVersion, and a generatedAt without fractional seconds.

Rule 4 is weaker than docs/ai-provenance-shape.md, which says unparseable provenance reads as unknown. That holds for the CloudKit record, not for the bundle. A writer of assistant-made records must write a well-formed object.

Only human counts as a person's work. A writer acting for an agent marks everything it writes assistant. It edits or removes only highlights that aren't human, removes none a person has commented on, and may add comments under any highlight. The app writes {"authorKind": "human"} on everything it makes.

The example holds a person's resolved question, an assistant's highlight, and a highlight anchored by line quotes:

{
  "annotations" : [
    {
      "authorName" : "Ruth Adler",
      "category" : "question",
      "color" : "purple",
      "comments" : [
        {
          "authorName" : "Ruth Adler",
          "body" : "Is the lamp failing, or is someone putting it out?",
          "createdAt" : "2026-08-05T19:22:40.118Z",
          "id" : "4E8B1C73-2D9A-4B65-8F3E-7C1A5D9B2E06",
          "provenance" : {
            "authorKind" : "human"
          },
          "userModificationDate" : "2026-08-05T19:22:40.118Z"
        }
      ],
      "createdAt" : "2026-08-05T19:22:40.118Z",
      "criterionIDs" : [
        "stakes"
      ],
      "id" : "9D4A6E21-5C3B-4E8F-A1D7-3B6C9F2E8A41",
      "pageIndex" : 2,
      "provenance" : {
        "authorKind" : "human"
      },
      "resolved" : true,
      "selector" : {
        "createdTier" : "wholeQuote",
        "exact" : "the lamp gave one last yellow shudder before going dark",
        "geometry" : {
          "lineRects" : [
            {
              "height" : 13.5,
              "width" : 468,
              "x" : 72,
              "y" : 512.25
            },
            {
              "height" : 13.5,
              "width" : 211.5,
              "x" : 72,
              "y" : 497.75
            }
          ],
          "pageIndex" : 2
        },
        "kind" : "textQuote",
        "prefix" : "She turned the great brass key, ",
        "suffix" : ", and the gulls went quiet on th"
      },
      "type" : "highlight",
      "userModificationDate" : "2026-10-12T09:47:03.215Z"
    },
    {
      "authorName" : "Claude",
      "category" : "praise",
      "color" : "green",
      "comments" : [
        {
          "authorName" : "Claude",
          "body" : "A first line that sets the stakes and the voice at once.",
          "createdAt" : "2026-10-12T08:15:09.554Z",
          "id" : "8B3D5F19-6A2C-4E71-9D4B-2F6A8C1E5D33",
          "provenance" : {
            "authorKind" : "assistant",
            "generatedAt" : "2026-10-12T08:15:09.551Z",
            "model" : "claude-opus-5",
            "promptVersion" : "highlights-v1"
          },
          "userModificationDate" : "2026-10-12T08:15:09.554Z"
        }
      ],
      "createdAt" : "2026-10-12T08:15:09.554Z",
      "criterionIDs" : [
        "voice",
        "structure"
      ],
      "id" : "2A5F8C37-9E1B-4D62-8B4A-6C3E1F7D9B15",
      "pageIndex" : 0,
      "provenance" : {
        "authorKind" : "assistant",
        "generatedAt" : "2026-10-12T08:15:09.551Z",
        "model" : "claude-opus-5",
        "promptVersion" : "highlights-v1"
      },
      "resolved" : false,
      "selector" : {
        "createdTier" : "wholeQuote",
        "exact" : "My father kept the light for thirty-one winters",
        "geometry" : {
          "lineRects" : [
            {
              "height" : 13.5,
              "width" : 301.25,
              "x" : 72,
              "y" : 640.5
            }
          ],
          "pageIndex" : 0
        },
        "kind" : "textQuote",
        "prefix" : "I. THE LOG ",
        "suffix" : " and never once let it go out. H"
      },
      "type" : "highlight",
      "userModificationDate" : "2026-10-12T08:15:09.554Z"
    },
    {
      "authorName" : "Ruth Adler",
      "category" : "issue",
      "color" : "red",
      "comments" : [

      ],
      "createdAt" : "2026-08-05T19:30:02.871Z",
      "criterionIDs" : [

      ],
      "id" : "7C2E9B54-1F3A-4A8D-B6E2-5D8F2A1C3E79",
      "pageIndex" : 4,
      "provenance" : {
        "authorKind" : "human"
      },
      "resolved" : false,
      "selector" : {
        "createdTier" : "lineQuotes",
        "exact" : "Q. Where were you standing when the lamp went out? A. On the gallery, by the north window.",
        "geometry" : {
          "lineRects" : [
            {
              "height" : 12,
              "width" : 318.5,
              "x" : 108,
              "y" : 430
            },
            {
              "height" : 12,
              "width" : 247.25,
              "x" : 108,
              "y" : 414
            }
          ],
          "pageIndex" : 4
        },
        "kind" : "textQuote",
        "lineQuotes" : [
          {
            "exact" : "Q. Where were you standing when the lamp went out?",
            "prefix" : "oud. 9 THE CORONER: Proceed. 10 ",
            "suffix" : " 11 A. On the gallery, by the no"
          },
          {
            "exact" : "A. On the gallery, by the north window.",
            "prefix" : "ding when the lamp went out? 11 ",
            "suffix" : " 12 Q. Alone? 13 A. Yes. 14 Q. A"
          }
        ]
      },
      "type" : "highlight",
      "userModificationDate" : "2026-08-05T19:30:02.871Z"
    }
  ],
  "schemaVersion" : 1
}

6.6 reviews/<review id>.json

The reviewer's own review, one file per review. Every regular file in reviews/ is read as one (§3.6). The folder is written only when the document has a review.

Key Type Meaning
schemaVersion integer required 1.
id UUID required Also the file's name.
submissionId UUID required The document reviewed.
reviewerName string required
scores object required Rubric field id → score value: an integer for a score field, a string for a text field. A key the rubric no longer has is kept.
submittedAt date required Shown as when it was submitted. The review form sets it on every save.
userModificationDate date lenient (submittedAt) Last-writer-wins key for sync.

The app shows and edits one review per document: the one with the earliest submittedAt. Another writer writes a review only when there is none or the earliest is its own, and never replaces a person's.

{
  "id" : "C3F7A9D2-6B14-4E8A-9D5C-1A7E3B8F4C92",
  "reviewerName" : "Ruth Adler",
  "schemaVersion" : 1,
  "scores" : {
    "craft_and_finish" : 4,
    "notes" : "Strong opening; the middle third repeats the storm.",
    "stakes" : 4,
    "structure" : 3,
    "voice" : 4
  },
  "submissionId" : "0B7E4C19-8A2D-4F36-B5C1-9E3D7A2F4C58",
  "submittedAt" : "2026-10-12T09:40:18.660Z",
  "userModificationDate" : "2026-10-12T09:46:55.013Z"
}

6.7 external-reviews.json

Reviews collected on the web and imported. Read-only in the app. They live apart from reviews/ so the reviewer's own form never edits them. The file is written only when the document has imported reviews. Format of the file they are imported from: review-inbox.md.

Key Type Meaning
schemaVersion integer required 1.
reviews array required One element that can't be read fails the file.

Each review:

Key Type Meaning
externalReviewId string required Identity, and the key a re-import replaces by. Prefixed by the host software: django:<id>.
reviewerName string required
anonymized boolean optional (false)
submittedAt date required When it was submitted on the web.
importedAt date required When it was imported.
source object required Where it came from, on every review: host, contestSlug, contestName (strings) and exportedAt (date), all required.
scores object optional ({}) Web rubric slug → score value. A slug this shelf's rubric lacks is kept.
scoreComments object optional ({}) Slug → comment string.
rubric array optional ([]) The web rubric for those slugs, for display: slug and name (required strings), maxScore (optional integer). Weights are never stored.
assessment integer optional The web's quick-evaluation fields, carried as they came.
moveForward integer optional
recommendation string optional
confidence integer optional
comment string optional
hasCoi boolean optional (false) Whether the reviewer declared a conflict of interest.
coiDescription string optional
complete boolean optional (true)
{
  "reviews" : [
    {
      "anonymized" : true,
      "assessment" : 4,
      "comment" : "Strong opening; pacing flags in the middle third.",
      "complete" : true,
      "confidence" : 3,
      "externalReviewId" : "django:1234",
      "hasCoi" : false,
      "importedAt" : "2026-08-03T17:12:44.031Z",
      "moveForward" : 2,
      "recommendation" : "accept",
      "reviewerName" : "Reviewer 2",
      "rubric" : [
        {
          "maxScore" : 5,
          "name" : "Innovation",
          "slug" : "innovation"
        },
        {
          "maxScore" : 5,
          "name" : "Craft",
          "slug" : "craft"
        }
      ],
      "scoreComments" : {
        "innovation" : "Fresh premise."
      },
      "scores" : {
        "craft" : 3,
        "innovation" : 4
      },
      "source" : {
        "contestName" : "Spring 2026 Playwriting Prize",
        "contestSlug" : "spring-2026-playwriting",
        "exportedAt" : "2026-08-03T17:04:00.000Z",
        "host" : "textshelf.com"
      },
      "submittedAt" : "2026-08-01T09:30:00.000Z"
    }
  ],
  "schemaVersion" : 1
}

6.8 summary.json

A cached summary of the document, made by a model. Builds with cloud summaries wrote it; that feature has been removed. The current app makes no new ones and shows none, but it reads the file, checks it and keeps it through saves. Written only when the document has one.

Key Type Meaning
schemaVersion integer required 1.
text string required The summary.
keyPoints array of strings required
concerns array of strings required
model string required The model that wrote it.
prompt string required The prompt's id, such as reviewer-prep-v1, not its text.
generatedAt date required
coverage object optional How much of the document the model saw: documentWordCount and summarizedWordCount (integers, both required). Absent on summaries from before it existed, where coverage is unknown.
{
  "concerns" : [
    "The middle section repeats the storm description."
  ],
  "coverage" : {
    "documentWordCount" : 6140,
    "summarizedWordCount" : 6140
  },
  "generatedAt" : "2026-08-07T10:11:12.345Z",
  "keyPoints" : [
    "The log book entries carry the plot.",
    "The ending returns to the opening image."
  ],
  "model" : "claude-sonnet-5",
  "prompt" : "reviewer-prep-v1",
  "schemaVersion" : 1,
  "text" : "A keeper's daughter tends the light alone through one storm after her father's death. The story moves between his log book and her memory of him."
}

6.9 digest.json

A cached digest of what the reviewer's own highlights add up to, made by a model from the highlights and the rubric labels. Same standing as summary.json: no current build makes one, and the app keeps it. Written only when the document has one.

Key Type Meaning
schemaVersion integer required 1.
overview string required What the marks show, in a few sentences.
throughLines array of strings required Concerns that recur across highlights.
untouchedCriteria array of strings required Rubric labels no highlight reaches.
model string required
prompt string required The prompt's id, such as reviewer-work-v1.
generatedAt date required
{
  "generatedAt" : "2026-08-07T10:14:40.902Z",
  "model" : "claude-sonnet-5",
  "overview" : "Your marks so far ask what the lamp scenes are for and flag the inquest transcript. Most of them sit in the first five pages.",
  "prompt" : "reviewer-work-v1",
  "schemaVersion" : 1,
  "throughLines" : [
    "Two questions ask who is keeping the light after the father's death."
  ],
  "untouchedCriteria" : [
    "Voice",
    "Craft and finish"
  ]
}

6.10 ink.json and ink/ (reserved)

Reserved for ink marks: metadata in ink.json, one drawing per mark in ink/<mark id>.drawing. Nothing reads or writes them. Current readers keep them as unknown children (§3.6). The intended shape is in docs/ink-annotation-record-shape.md. It is not part of v1.

7. Adding to the format

  • A new optional key keeps schemaVersion at 1. Readers must give it a default when absent. An earlier build drops it on save (§3.5).
  • New kinds of data go in new files. Earlier builds keep unknown files (§3.6).
  • Never add a new kind of element to an existing array. Every reader in the field fails the whole file over one element it can't read (§3.8).
  • Raise schemaVersion only for a change that makes an earlier reader wrong, not just incomplete: a redefined coordinate space, a renamed required key, a different date encoding. Every checked file then stops earlier readers. Note that today annotations.json, source.json and agent-brief.json are not checked (§3.7).
  • A reader must be in the field before the first writer of an addition.

Where the code is

Paths are relative to the repository root. ShelfCore/ stands for native/Packages/ShelfCore/Sources/ShelfCore/.

  • Bundle read and write, known and dropped children, the per-document rule, the version check: ShelfCore/ShelfDocument.swift:111-124, :176-269, :273-384, :401-411, :455-526, :574-589.
  • What a save keeps, rewrites or leaves alone: ShelfCore/ShelfBundleSnapshot.swift:34-72.
  • Unknown children: ShelfCore/BundleNode.swift:17-68.
  • Date encoding and decoding for bundle files: ShelfCore/CodingHelpers.swift:3-36.
  • The models (Shelf, ShelfSettings, RubricField, Submission, SubmissionSummary, AnnotationFile, AnnotationProvenance, Annotation, TextQuoteSelector, LineQuote, AnnotationComment, Review, ScoreValue, ExternalReview and its parts): ShelfCore/Models.swift:3-1035; the provenance rule at :490-538 and :648-652.
  • Anchor tiers and geometry: ShelfCore/Annotation/SelectorGeometry.swift:9-61.
  • Making and finding anchors: ShelfCore/Annotation/AnnotationHelpers.swift:93-214, :292-351, :421-483; normalization: ShelfCore/Annotation/WhitespaceNormalization.swift:11-27.
  • web-link.json: ShelfCore/ReviewPacket/WebLink.swift:17-116; its source: ShelfCore/ReviewPacket/ReviewPacketManifest.swift:8-37; how a packet shelf is built: ShelfCore/ReviewPacket/PacketShelfBuilder.swift:136-229; workspaceType and a missing issuedAt: ShelfCore/ReviewPacket/ReviewPacketManifest.swift:219-220, :244-258; the upload page rule: ShelfCore/ReviewPacket/PacketLinkPolicy.swift:109-126.
  • source.json: ShelfCore/Import/SourceRecord.swift:5-45; formats and hashes: ShelfCore/Import/DocumentConversion.swift:33-49, :64-66; the app writing it: ShelfCore/ShelfReferenceDocument+Ingest.swift:161-223.
  • digest.json: ShelfCore/AI/AnnotationDigest.swift:10-42.
  • When the kind menu replaces a rubric: ShelfCore/ShelfTypeChange.swift:22-34.
  • The name taken from the file: ShelfCore/ShelfReferenceDocument.swift:92-97.
  • The review the app edits: native/Packages/ShelfUI/Sources/ShelfUI/ReviewForm.swift:40-43.
  • Colour names: native/Packages/ShelfUI/Sources/ShelfUI/ShelfTheme.swift:37-48, :77-89.
  • Copies: ShelfCore/ShelfCopy.swift:41-97.
  • The App Intents catalog: ShelfCore/Intents/ShelfCatalog.swift:83-111.
  • Errors: ShelfCore/Errors.swift:3-18.
  • The document type: native/TextShelf/project.yml:114-122.

This specification is licensed under CC BY 4.0; the example files under MIT. Anyone may implement these formats in their own software.


This specification is licensed under CC BY 4.0, and its example and schema files under the MIT licence. Anyone may implement these formats in their own software. CC BY 4.0