chant workspace box
Synopsis
Section titled “Synopsis”chant workspace box listing set <member> [--from <file|->] [--cover <image> [--cover-path <path>]] [--by <principal>] [--dry-run] [--json]chant workspace box factory set <member> --from <file|-> [--by <principal>] [--dry-run] [--json]chant workspace box publish <member> (<item> | --records) [--by <principal>] [--head <owner/name>] [--dry-run] [--json]Description
Section titled “Description”The command has three verbs: box listing set, described first, box factory set, under The factory, and box publish, under Publishing a box’s work.
A box’s listing is what a home site that lists boxes shows of it, such as its title and its cover picture. The listing lives on the member’s box block as members[i].box.listing in chant.workspace.json (#3146, ws-077). Under ws-074 a tool writes the repo only through chant. A tool such as hud therefore changes a listing with this command and never edits the declaration itself (#3308).
box listing set changes only the listing’s own properties, editing the declaration in place. The rest of the file is left byte for byte, comments and trailing commas in a chant.workspace.jsonc included. A property the write adds goes after the listing’s last one, and a box with no listing gets one after the box block’s last property. Before writing, the command checks the declaration it would write against the declaration schema. It never commits. As after the record writes, the caller commits the change and opens the pull request.
The member must declare a box block. A member without one is refused with listing-box-missing.
The fields
Section titled “The fields”--from names a JSON file, or - for standard input, holding an object of the fields to change. A field left out keeps its value, and null takes a field out of the declaration so it reads as its default.
| Field | Holds |
|---|---|
published | true or false. Default true |
title | at most 60 characters, with no control character. Default "" |
line | at most 140 characters, with no control character. Default "" |
cover | a PNG, JPEG or WebP file already in the workspace, as a path from the workspace root |
x-* | any value, kept as given |
Any other field is refused with write-input-invalid, and so is a value the declaration schema refuses, such as a 61-character title.
The cover
Section titled “The cover”--cover <image> copies a picture into the repository and points the listing at it, so one call sets the fields and the cover. The image is read from the path given, relative to the current directory, and must be a PNG, JPEG or WebP of at most 5 MiB, told by its first bytes. It goes to:
--cover-path <path>, from the workspace root, when given. Its extension must fit the picture’s format.- Otherwise the listing’s current cover, when its extension fits.
- Otherwise
<member dir>/listing/cover.png,.jpgor.webp.
When the cover moves to another file, cover.replaced names the one the listing named before. That file stays in place, and the caller removes it in the same commit if nothing else uses it. --cover and a cover field together are refused with write-usage-invalid. Bytes the destination already holds are not written again.
Who may write
Section titled “Who may write”The write is judged as check --changes judges the commit that will carry it, by the write scope in the declaration at base (#2548), so a working-tree edit can’t widen it. The writer is the agent session CHANT_AGENT names, the session listing the --by principal, or the class the principal’s role grants give.
- The declaration is at the workspace root, in no member. A class whose
writeScope.<class>.membersleaves out paths in no member is refused withwrite-scope-member, and so is every agent session, since a session writes only the members it is bound to. - A
protectedentry covering the declaration refuses the write withwrite-scope-protected, unless itsexceptallows the change.{ "path": "chant.workspace.json", "except": ["/members/*/box/listing"] }keeps the declaration protected and lets any box’s listing change. - The cover’s path is judged the same way, as a file in its member.
- When the declaration at base sets
identity.attributiontoidentified, a bare name in--byis refused withprincipal-unidentified. Pass a forge identity such asgithub:alice, or a signer (ws-080).
Output
Section titled “Output”The command prints one JSON document following https://intentius.io/chant/schemas/workspace/box-listing-write/v1/box-listing-write.schema.json, with or without --json.
chant workspace box listing set app --by github:alice --cover /tmp/upload-7f3a.png --from - <<'EOF'{ "title": "Fern", "line": "A garden planner for a small allotment" }EOF{ "$schema": "https://intentius.io/chant/schemas/workspace/box-listing-write/v1/box-listing-write.schema.json", "contract": 1, "chant": "0.103.0", "member": "app", "declaration": { "path": "chant.workspace.json", "sha256": "20dd0267..." }, "paths": ["app/listing/cover.png", "chant.workspace.json"], "changed": true, "dryRun": false, "previous": null, "listing": { "published": true, "title": "Fern", "line": "A garden planner for a small allotment", "cover": { "path": "app/listing/cover.png", "sha256": "7343d363..." } }, "cover": { "path": "app/listing/cover.png", "sha256": "7343d363...", "bytes": 48213, "type": "image/png", "replaced": null }}| Field | Holds |
|---|---|
member | the member whose box holds the listing |
declaration | the declaration file from the repository root, and the sha256 of its bytes after the write |
paths | every file the write changed, from the repository root. Empty when the listing and cover already held what was given, and then changed is false |
previous, listing | the listing before and after, as status --json prints it, with defaults filled in and the cover’s sha256. previous is null when the box had no listing |
cover | the image --cover copied, or null |
--dry-run prints the same document and writes nothing. A refused write leaves every file as it was and exits 1. Its document carries error: { code, message } instead.
| Code | When |
|---|---|
listing-member-unknown | the declaration names no such member |
listing-box-missing | the member declares no box block |
listing-cover-invalid | the cover can’t be read, isn’t a picture of a format above, is over 5 MiB, or has a bad path or extension |
write-usage-invalid | the command line is missing a value or gives a conflicting one |
write-input-invalid | the fields aren’t a JSON object of listing fields, or the declaration schema refuses them |
write-scope-member, write-scope-protected, write-scope-class-unknown, agent-unknown | the write scope at base refuses the writer |
principal-unidentified | --by is a bare name under identity.attribution: "identified" |
| a declaration code | the declaration can’t be read, as for any workspace read |
The writer conformance suite drives this command as its box listing set action, and its amnesia test reads the listing back through status --json.
The factory
Section titled “The factory”box factory set <member> --from <file|-> changes the box’s factory, members[i].box.factory, the same way box listing set changes its listing (#3600). A template can’t carry factory.publish, because its repo must be a real owner/name and every box planted from the template publishes to its own repository. Whoever plants a box knows that repository, and sets the target once the box is planted.
echo '{ "publish": { "repo": "alex/fern", "base": "main" } }' | chant workspace box factory set box --from -The factory takes builds, check, checks, builders, tiers, publish and x- keys at its top level, and the object --from gives may hold any of them. Each one given replaces that field’s whole value, null deletes the field, and the fields not given stay as they are. The write is refused with write-input-invalid for any other field, and when the declaration would no longer read, such as with a repo that is not owner/name. A box with no factory gets one, and then the fields must include builds. A member with no box block is refused with factory-box-missing, and an unknown one with factory-member-unknown.
The write scope at base judges the write, as under Who may write. A protected declaration takes it when the entry’s except names what changes, such as /members/*/box/factory/publish.
The document printed follows https://intentius.io/chant/schemas/workspace/box-factory-write/v1/box-factory-write.schema.json. Its member, declaration, paths, changed and dryRun mean what they mean for the listing. previous and factory hold the factory before and after the write as status --json prints it, or null when there is none. Giving the same fields again changes nothing (changed: false), and chant never commits the change.
Publishing a box’s work
Section titled “Publishing a box’s work”box publish is the one call a surface such as hud makes to publish a box’s work (#3165, ws-088). It replaces the HUD_APPLY_CMD a box’s run script used to set.
chant workspace box publish app W-12 --by github:alicechant workspace box publish app --records --dry-runchant workspace box publish app --records --by github:aliceThe work is the orchestrator’s. The box block names a publisher, a command an orchestrator such as studio supplies (declaration). The publisher applies a built work item to the box’s checkout. When the box’s factory.publish names a target, the publisher also pushes the item’s branch and opens the pull request. With --records it sends the records a person kept uncommitted instead. chant runs the publisher and checks what it did, and leaves git and the forge to it.
What chant does, in order:
- Checks the command line. It needs an item or
--recordsbut not both,--byunless--dry-runis given, and a--headof the formowner/name. Anything else iswrite-usage-invalid. - Reads the member’s
box.publisher:publish-member-unknownwhen the member isn’t declared,publish-nonewhen it names no publisher. - Applies the identity rule to
--by: a bare name underidentity.attribution: "identified"isprincipal-unidentified(ws-080). - Runs the publisher from the workspace root, split into words as a shell splits plain words but with no shell, with
CHANT_PUBLISH_CONTRACT=1in its environment and one JSON request on stdin. It is stopped after ten minutes. - Reads its exit code and the last JSON object on its stdout, the answer.
- Unless
--dry-run, checks the commit the answer names for the apply record of ws-075.
The publisher’s protocol
Section titled “The publisher’s protocol”An orchestrator implements this. The request on stdin is $defs.request of box-publish.schema.json:
{ "contract": 1, "action": "item", "member": "app", "item": "W-12", "by": "github:alice", "head": null, "dryRun": false, "workspace": { "root": "/home/box/app" }, "factory": { "builds": ["app"], "check": null, "checks": null, "builders": null, "publish": { "forge": "github", "repo": "acme/fern", "base": "main", "branchPrefix": "box/", "head": null } }}action is item or records. item is null for records. head is the fork --head named, which takes the place of factory.publish.head. factory is the workspace’s factory as status --json prints it, so a publisher reads the target from the request rather than parsing the declaration.
The publisher answers by exit code:
| Exit | Meaning | chant’s code |
|---|---|---|
| 0 | done, with the answer as the last JSON object on stdout | none, or publish-answer-invalid when the answer is missing or malformed |
| 2 | refused, nothing published; stderr says why | publish-refused |
| any other | failed, possibly after part of the work, such as applying an item and failing to push it; stderr says what | publish-failed |
The answer is $defs.answer. Only ok: true is required, and x- fields are passed through:
| Field | Holds |
|---|---|
commit | the commit made in the box’s checkout. Required unless dryRun |
records | the records sent, each { kind, id, path, title }. With dryRun and records, the ones that would go |
pullRequest | { url, number, branch, base, head }, the pull request opened or found open |
pushed | { branch, repo } when the branch was pushed and no pull request could be opened |
local | why the work stayed in the box although factory.publish names a target |
The commit carries the apply record. An item’s commit has Chant-Applied-By naming the request’s by, Chant-Applied-At, Chant-Applied-Commit with the applied branch’s tip, and Chant-Record: <kind>:<item>. A records commit has one Chant-Record per record sent. When the commit is missing or lacks one of these trailers, chant refuses the result with publish-unrecorded. The document still holds the publisher’s answer, so the caller can show the pull request.
What a surface reads instead of the environment
Section titled “What a surface reads instead of the environment”| It needed | It reads, from status --json |
|---|---|
whether to offer Apply (HUD_APPLY_CMD) | the member’s box.publisher: offer it when not null, and call box publish rather than running it |
where an applied item goes (HUD_APPLY_TO) | box.factory.publish: a pull request on its repo when set, the box’s own checkout when null. The answer’s local says when an orchestrator kept it in the box anyway |
what an ask constrains (HUD_ASK_CONSTRAINS) | member:<box.factory.builds[0]> (ws-077) |
The publish document
Section titled “The publish document”box publish answers on stdout in the https://intentius.io/chant/schemas/workspace/box-publish/v1/box-publish.schema.json shape, whether or not --json is given.
{ "$schema": "https://intentius.io/chant/schemas/workspace/box-publish/v1/box-publish.schema.json", "contract": 1, "chant": "0.103.0", "member": "app", "action": "item", "item": "W-12", "by": "github:alice", "dryRun": false, "publisher": "node box/ops/factory/publish.mjs", "commit": "9f2c4e1a...", "records": [], "pullRequest": { "url": "https://github.com/acme/fern/pull/41", "number": 41, "branch": "box/W-12", "base": "main", "head": null }, "pushed": null, "local": null, "applied": { "by": "github:alice", "at": "2026-10-03T18:22:05Z", "commit": "5b1d07c3..." }, "answer": { "ok": true, "commit": "9f2c4e1a...", "pullRequest": { "url": "https://github.com/acme/fern/pull/41", "number": 41, "branch": "box/W-12", "base": "main" } }}applied is the apply record read back from the commit’s trailers, for an item; null for records and with --dry-run. When chant has no result to print, the exit code is 1 and error names one of the codes below. The publisher’s answer is kept beside it whenever the publisher printed one.
| Code | When |
|---|---|
publish-member-unknown | the declaration names no such member |
publish-none | the member declares no box block, or its box block names no publisher |
publish-refused | the publisher exited 2 |
publish-failed | the publisher could not be run, exited with another nonzero code, or ran out of time |
publish-answer-invalid | the publisher exited 0 with no JSON object on stdout, or one the schema doesn’t allow |
publish-unrecorded | the commit the publisher named is missing or lacks its apply record |
write-usage-invalid | the command line is missing a value or gives a conflicting one |
principal-unidentified | --by is a bare name under identity.attribution: "identified" |
| a declaration code | the declaration can’t be read, as for any workspace read |
Exit codes
Section titled “Exit codes”| Code | Meaning |
|---|---|
| 0 | The listing was written, would be with --dry-run, or already held what was given; or the publisher published, or said with --dry-run what it would |
| 1 | Nothing was written, or the publish printed no result: the document’s error says why |