TextShelf

Developers › File formats Beta

Review packet

Format textshelf-review-packet, version 1. Written by the web, read by the Mac app. Status: v1, beta; textshelf.com does not serve packets yet. It keeps to the rules every format follows.

A review packet gives one reviewer the entries they are assigned on one web shelf, the shelf's review form, and the values they have already given on the web. The Mac app turns it into a new shelf. When the reviewer is done, the app writes a review return, and the web applies it.

Delivery

The manifest is always a file named textshelf-packet.json. It reaches the app one of two ways.

  • A link. The web page's "Open in TextShelf for Mac" button opens textshelf://packet?u=<URL>, where <URL> is the percent-encoded https address of the manifest. That address is signed for the reviewer and the shelf and expires 15 minutes after it is issued; the web checks the reviewer's membership and assignments again when it is fetched. Each entry then carries a file.url, a signed link straight to the stored file, valid for an hour. The app follows a u= address only on a host it trusts (textshelf.com).
  • A zip. "Download packet (.zip)" holds textshelf-packet.json and a files/ folder. Each entry carries a file.path relative to the manifest (files/<filename>) and a file.sha256. The reviewer unzips it and opens the folder.

The manifest

{
  "formatVersion": 1,
  "kind": "textshelf-review-packet",
  "source": {
    "host": "textshelf.com",
    "contestId": 42,
    "contestSlug": "spring-2026-playwriting",
    "contestName": "Spring 2026 Playwriting Prize",
    "workspaceType": "literary_contest",
    "issuedAt": "2026-09-20T14:00:00.000Z",
    "uploadUrl": "https://textshelf.com/submissions/scores/42/upload/"
  },
  "form": {
    "fields": [
      {
        "id": "web-coi",
        "label": "Conflict of interest",
        "help": "Describe any conflict with this entry. Leave empty if there is none.",
        "type": "text"
      },
      {
        "id": "web-criterion-3f2a9c1e-7b44-4d0a-9d4e-2b8f1a6c5e01",
        "label": "Innovation",
        "help": "Is the premise fresh?",
        "type": "score",
        "min": 1,
        "max": 5
      },
      {
        "id": "web-move-forward",
        "label": "Do you recommend further consideration of this entry as a contender?",
        "type": "score",
        "min": 1,
        "max": 3,
        "choices": [
          { "value": 1, "label": "No" },
          { "value": 2, "label": "Maybe: worth further consideration" },
          { "value": 3, "label": "Yes: strongly recommend" }
        ]
      },
      {
        "id": "web-assessment",
        "label": "How positive is your assessment of this entry?",
        "type": "score",
        "min": 1,
        "max": 5,
        "choices": [
          { "value": 1, "label": "not at all positive" },
          { "value": 2, "label": "slightly positive" },
          { "value": 3, "label": "moderately positive" },
          { "value": 4, "label": "very positive" },
          { "value": 5, "label": "extremely positive" }
        ]
      },
      {
        "id": "web-notes",
        "label": "Notes",
        "type": "text"
      }
    ]
  },
  "entries": [
    {
      "sourcePk": 91,
      "externalId": "17",
      "title": "The Lighthouse Keeper's Daughter",
      "filename": "17 - The Lighthouse Keeper's Daughter.pdf",
      "file": {
        "url": "https://bucket.example/media/42/7-42-lighthouse.pdf?X-Amz-Signature=abc",
        "bytes": 1834221
      },
      "baseline": {
        "web-assessment": 4,
        "web-notes": "Strong opening."
      },
      "assistantPolicy": "off"
    }
  ]
}

source

  • contestId (int) — the web contest's pk. The return file must name the same id, and the upload page must be that contest's.
  • host (string) — also checked on return. It keeps a staging packet's scores away from production.
  • contestSlug, contestName (string) — for display. The shelf is named after contestName.
  • workspaceType (string, optional) — the contest's workspace_type (literary_contest, grant_program, …). The app shows it as the shelf's kind, so a reviewer holding packets from two programs can tell them apart. It never chooses the rubric: that is always form.fields. Added 2026-09-18 as an additive key; a build that predates it ignores it and makes a Custom shelf, as does a manifest without it.
  • issuedAt (date) — when the manifest was built.
  • uploadUrl (string) — the web page that takes the return file. After exporting, the app offers to open it. The app only opens it on the manifest's own host.

form.fields

Fields come in the order the app should ask them: conflict of interest first (the reading room asks it before the document opens), then criteria, Quick Evaluation, recommendation and confidence, then notes. A field appears only when the contest's settings show it on the web:

id Shown when type
web-coi require_coi_declaration text
web-criterion-<ScoringRubric.public_id> show_rubric_scoring, active criteria, in order, id order score, min 1, max = max_score
web-move-forward show_quick_evaluation score 1–3, choices
web-assessment show_quick_evaluation score 1–5, choices
web-recommendation show_recommendation_voting text, string choices
web-confidence show_recommendation_voting score 1–3, choices
web-notes always text

Field keys: - id (string) — the key the app stores the value under in Review.scores, and sends back. - label (string) — the web's own wording. The Quick Evaluation labels are the contest's custom move_forward_review_label and assessment_review_label. - help (string, optional) — the criterion's description, or guidance. - type — score (int value) or text (string value). A reader that doesn't know the type skips the field and reports it. - min, max (int) — score bounds. Required for score. - choices (optional) — [{value, label}]; value is an int for score and a string for text. A value outside the choices is refused on return.

Meaning of the text fields: - web-coi: empty means no conflict. Any text declares one, and the text is its description. - web-notes: plain text. The web stores Trix HTML, so the baseline is converted to text, and the return is converted back into paragraphs.

entries

  • sourcePk (int) — Submission.pk. The one identity the return file matches on.
  • externalId (string) — the entry number a judge sees. For display and for Submission.externalID on the Mac.
  • title (string).
  • author (string, optional) — omitted when the contest has blind_review and the reviewer isn't an organizer. This is the web's author_visible rule.
  • filename (string) — a display name, never the stored name, which embeds the uploader's id.
  • file — exactly one of:
  • url: a presigned GET, valid for an hour. Used by the "Open in TextShelf for Mac" delivery.
  • path: relative to the manifest. Used by the zip delivery, as files/<filename>.
  • file.bytes (int) — the size, for progress and a truncation check.
  • file.sha256 (string, optional) — present when the web knows it (the zip delivery computes it while streaming). A reader that gets a mismatch refuses that entry.
  • baseline (object) — the reviewer's current web values, keyed by field id. Only fields with a value appear. It prefills the Mac review, and it's what the return is compared against (how the web applies a row).
  • assistantPolicy (string, optional) — what this reader's own AI assistant may do with the entry: off (nothing; it doesn't read the entry), passages (it may read the entry and mark passages as evidence for the criteria, labelled as its own; no scores) or drafts (it may also draft scores, which count only once the reader adopts them). It is the shelf's rule for readers' assistants as this entry keeps it: the stricter of the rule in force when the entry arrived and the shelf's rule now, and off where the rule doesn't reach this reader (on submitted work it covers the organizers' assistants only). A missing or unknown value is off. Any software that hands the entry to an assistant follows it. Added 2026-10-13, as an optional field, so the version stays 1.

The shelf the Mac app builds

These are app decisions, recorded here so the web and the app agree about what the round trip preserves.

  • The shelf: named after contestName, of the kind workspaceType names. A kind this build offers becomes that kind; any other string is kept as the shelf's type and shows as Custom (Shelf.unrecognizedType); no workspaceType makes custom. The type menu stays disabled either way. Its rubric is form.fields in order (RubricField.id, label, type, min, max, plus the additive keys choices and help).
  • Each entry: a Submission with title, optional author, externalID = externalId and externalSource = contestSlug.
  • A non-empty baseline: the app creates a Review with scores = baseline, and the submission starts in_review. This seeds the review without going through the save path, so it doesn't register an undo or mark the entry reviewed.
  • web-link.json: written at the bundle root (spec). It holds source (including uploadUrl) plus, per entry, submissionId, sourcePk, externalId, bytes, the optional sha256, and baseline. It is what Export Scores for Web reads.
  • A skipped field's baseline doesn't travel. When the app skips a form field (a type it doesn't know), it also drops that field's baseline value from the link and the seeded review. So the return's baseline is the manifest's minus those keys. Carried, the value would be in baseline and absent from values, which the web reads as cleared on the Mac. Absent from both, the web leaves the field alone.
  • The upload page must be TextShelf's. The app offers uploadUrl only when it is https on the manifest's own host and that host is one it would fetch a packet from (textshelf.com; staging and localhost in debug builds). A folder packet is a file anyone can edit.
  • Downloads are capped at file.bytes. A body that grows past the declared size is stopped, not buffered. The manifest itself is capped at 16 MB.

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