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 afile.url, a signed link straight to the stored file, valid for an hour. The app follows au=address only on a host it trusts (textshelf.com). - A zip. "Download packet (.zip)" holds
textshelf-packet.jsonand afiles/folder. Each entry carries afile.pathrelative to the manifest (files/<filename>) and afile.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 aftercontestName.workspaceType(string, optional) — the contest'sworkspace_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 alwaysform.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 forSubmission.externalIDon the Mac.title(string).author(string, optional) — omitted when the contest hasblind_reviewand the reviewer isn't an organizer. This is the web'sauthor_visiblerule.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, asfiles/<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) ordrafts(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, andoffwhere the rule doesn't reach this reader (on submitted work it covers the organizers' assistants only). A missing or unknown value isoff. 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 kindworkspaceTypenames. A kind this build offers becomes that kind; any other string is kept as the shelf'stypeand shows as Custom (Shelf.unrecognizedType); noworkspaceTypemakescustom. The type menu stays disabled either way. Its rubric isform.fieldsin order (RubricField.id,label,type,min,max, plus the additive keyschoicesandhelp). - Each entry: a
Submissionwithtitle, optionalauthor,externalID=externalIdandexternalSource=contestSlug. - A non-empty
baseline: the app creates aReviewwithscores = baseline, and the submission startsin_review. This seeds the review without going through the save path, so it doesn't register an undo or mark the entryreviewed. web-link.json: written at the bundle root (spec). It holdssource(includinguploadUrl) plus, per entry,submissionId,sourcePk,externalId,bytes, the optionalsha256, andbaseline. It is what Export Scores for Web reads.- A skipped field's baseline doesn't travel. When the app skips a form
field (a
typeit doesn't know), it also drops that field'sbaselinevalue from the link and the seeded review. So the return'sbaselineis the manifest's minus those keys. Carried, the value would be inbaselineand absent fromvalues, 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
uploadUrlonly when it is https on the manifest's ownhostand 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