Your team's setup: zest.json, packs and extensions
On this page
Zest works with nothing set up. This page is for a team that wants every page, and every round its agent runs, to follow the same rules: one voice, one set of facts, one place for notes. You write those rules once, in the repo, where they are reviewed like code. That is the idea behind Zest's design: treat context like a codebase. A rule that lives in someone's head is a rule the next round breaks.
The editor's Setup panel and zest options page.html list everything below that a page is not using yet, with the one line that turns it on.
zest.json
Zest reads every zest.json from the page's folder up to the repository root. Nearer files add to farther ones; for a single value, such as a context file, the nearer one wins. Paths are relative to the zest.json that names them.
{
"context": { "brand": "brand.md", "canon": "canon.md" },
"built": [{ "match": "decks/*/index.html", "source": "deck.src.html", "build": "python3 build.py" }],
"checks": [{ "name": "House style", "run": "vale --output=JSON {file}" }],
"lint": [{ "find": "utilize", "say": "Write use." }, { "find": "—", "say": "No em dashes.", "block": true }],
"protect": ["brand/**", "assets/logos/**"],
"packs": ["deck"],
"extensions": ["./tools/zest-house"],
"sites": [{ "url": "http://127.0.0.1:8080/", "root": "public" }]
}
| Key | What it does |
|---|---|
context | Layered context. Files your agent reads before any note, every round, in this order: brand (voice, look, image rules), canon (names, numbers, approved claims every page must agree with). Then the page's own brief. Any other key is read too, after these. |
built | Pages made from a source by a command. You edit the page you see; Zest writes the source and runs the build. match is a glob from this file's folder; source and cwd are relative to the page. A page is never served older than its source. A later, narrower entry with "built": false says a page that matched is not built after all. |
checks | Your own scripts in Check. {file} is the page, {source} its source. Output may be JSON (an array, or { findings: [...] }, each with line and message) or plain lines (file:12: message). |
lint | Words flagged under the line while someone edits. "block": true refuses to save a change that contains it. "regex": true treats find as a regular expression; "flags": "i" makes it ignore case. |
protect | Images under these paths cannot be swapped from the editor, only through a note. Use it for logos and marks: a company's logo is its own published file, never a stand-in. |
packs | Built-in packs to switch on (they also switch themselves on for pages they fit). "-deck" switches one off. |
extensions | Folders of code your team adds (below). |
images | Picker tabs a team turns off, e.g. { "generate": false } when its images come from its own source (an extension's image source), so nobody bills a personal key for a picture the team makes elsewhere. |
sites | For the Zest Edit button: which local site serves which folder, so a page's address can be traced to its file. |
Approval: nothing runs until a person says yes
A build, a check and an extension all run code on your machine, and a zest.json arrives with every clone. So Zest runs none of them until someone approves that exact command, in that exact folder, from a terminal or Claude Code:
zest trust page.html # lists what this page's zest.json asks to run, then asks
zest trust --list # everything you have approved
zest trust --revoke <id> # take one back
An approval is a fingerprint of the command and its folder (for an extension, of its files). Change the command, or edit the extension, and it waits for a yes again. Approvals live in Zest's own config file (mode 600), never in the repository. The editor never approves anything: it shows what is waiting and the line to run.
Packs
Packs ship inside Zest, so they need no approval. Each switches itself on for the pages it fits.
| Pack | Switches on for | What it adds |
|---|---|---|
levers | Pages with a <script type="application/json" id="zest-levers"> block | A Levers tab built from the page's own schema: sliders, colour pickers with the page's palette, choices and switches, applied as you move them and saved into the block with a style beside it. See Editing. |
deck | Pages with <section class="slide" data-sid="…"> slides (either attribute order) | A Slides tab: order, hide, seconds, views, duplicate, new and remove, each written to the source (a built page's source) with a version kept; the talk track (aside.notes); a storyboard on T with thumbnails; notes and Canva aimed at a slide and its build. See Editing. |
Extensions
An extension is a folder with a zest-extension.json:
{ "name": "house", "title": "Our house tools", "description": "Shared notes and our image library.", "server": "server.mjs", "ui": "ui.js" }
server.mjs
The default export receives Zest's helpers and returns what the extension adds. Every field is optional.
export default function (zest) {
// zest: { version, httpError(status, message, extra), sniffMime(buffer), saveAsset(dir, {buffer, mime, name, sub}, provenance),
// download(url), readJson(path, fallback) }
return {
notes, // a shared store for notes and rounds (below)
imageSources, // [{ id, label, help, search(q, { file, page, filters, target, context }), pick(item, { file, context }) }]
placements, // { record(file, record) => { where } }: your ledger of pictures placed into a slot that names a concept
afterRound, // (file, trace) => {} when a round ends, its edits grouped by movement (below)
runPrompt, // (file) => the prompt the editor copies, e.g. your own round's slash command
listener, // what Submit starts for your pages (below)
runTools, // extra Claude Code tools a round may use, e.g. ["mcp__claude_ai_Canva__*"]
runButtonPrompt, // older form of listener.prompt; still works
runCwd, // older form of listener.cwd; still works
options, // (file) => [{ id, title, gives, how, where, done }] for the Setup panel
routes, // { "GET name": async ({ file, query, body, broadcast }) => json }
resolve, // (url) => absolute path of the page behind that address, or null
pages, // live pages: a copy of a page an app renders, saved into the app's source (below)
ledger, // { append(row), classify(path), gate(file) }: a copy of every ledger row for your pages (below)
owner, // (file) => null | { id, label, active_at, how, canWake }: an agent already working here (below)
deliver, // (file, entry) => { woke, message? }: tell that agent its mailbox has notes (below)
};
}
An extension that throws while loading is reported in Setup and zest doctor; the editor carries on without it. An edit to a file your server.mjs imports takes effect after zest stop and a fresh open: Zest loads a changed extension again, but Node keeps the files it has already imported.
A shared notes store
By default a page's notes live beside it in .zest/. A store keeps them where your whole team sees them. Zest calls it for every note the editor writes and every zest notes command the agent runs, so the two never disagree. Versions, the edit log and the brief stay beside the page.
const notes = {
label: 'the team database',
async claims(file) { return file.includes('/marketing/'); }, // which pages it holds
async read(file) { return { notes: [/* Zest's note shape */], runs: [], run: null }; },
async addNote(file, note) {}, async editNote(file, id, patch) {}, async deleteNote(file, id) {},
async saveAttachment(file, { buffer, mime, name }) { return { type: 'image', path: 'where/you/keep/it.png' }; },
async readAttachment(file, path) { return { buffer, mime }; },
async startRun(file, name) { return { already: false, run: { name, started } }; },
async answerNote(file, id, { said, run, result }) {}, async dropNote(file, id, why) {},
async finishRun(file, summary) { return { name, answered, stillOpen }; },
async revision(file) { return 'a stamp that changes when anything does'; },
};
A note: { id, kind, text, anchor, attachments, change, part, part_label, build, level, state, status, created, by, answer }. state is what the page showed when the note was made: { url, route, viewport: { w, h }, scroll: { x, y }, movement, dom, attrs, shot } (what each is). Zest fills shot in later, with editNote(file, id, { attachments, state }) once Chrome has drawn the freeze frame, so keep state when your store saves a patch. level ({ id, label }) is there only on a whole-page note about more than the page (see levels under a live page). kind is note, edit, image, canva or canva-back. status is open, answered or dropped; an answered note is read-only, and your store should refuse to change one. answer is { said, run, at, result }; a Canva answer carries result.canva = { design_id, url }. An answer may also offer next steps, result.actions = [{ id, label, args }] (at most four): each becomes a button under the answer, in Changes and on the page, when your ui.js has registered that id in answerActions (below). An agent writes them with zest notes answer <file> <id> --said "…" --result '{"actions":[…]}'. A store Zest cannot reach is shown as unreachable, never as "no notes". A note Submit handed to an agent already working in the checkout gets editNote(file, id, { routed: { to: { id, label }, at, entry } }), and { routed: null } when the person takes it back; keep the field if you can. A store that drops it works, with less on screen: the mailbox is the record of the hand-off, so the agent still gets the notes, but the page cannot mark them and Submit cannot tell them apart.
A live page
A page an app renders (React, Next.js, a template) has no HTML file that holds its words, so Zest cannot write it by itself. An extension that knows how the app is built can: Zest opens a copy of the rendered page, and every change goes to the extension, which writes the app's own source. Your resolve(url) makes the copy when someone opens the address.
const pages = {
async claims(file) { return isOurCopy(file) ? { origin: 'http://localhost:3000', route: '/pricing', label: 'Pricing' } : null; },
async render(file) { return html; }, // the page as the app renders it now, which Zest writes as the copy
async edit(file, ops, { route, origin, label, state }) { // Zest's edit ops (text, attr, url), in order; state: the page then
return ops.map((op) => ({ ok: true, lines: [12], where: 'app/pricing/page.tsx, line 12' })); // or { ok: false, reason, message, scopes? }
},
async revision(file) { return 'a stamp that changes when the app's source does'; },
hint: (file) => 'Words live in app/pricing/content.ts.', // optional: where the source is, for the agent
levels: async (file) => [{ id: 'section', label: 'Every page in Products' }, { id: 'site', label: 'Every page on the site' }], // optional
place: async (file, image) => ({ src: '/images/x.png', file }), // optional: move an inserted image where the app serves files
};
- Only an app on this machine.
originmust behttp://localhost,127.0.0.1or[::1], on any port; anything else is not treated as live. - Edited in place, too. With Edit here (Editing in place) the editor runs in the app's own page, and the same
resolve(url)says which copy its notes and history key on. Every save still goes toedit, withstate: what the page showed when the person made the change (its address, window, scroll, the markup around the part, its classes and states). Your app's hot reload repaints the page after you write the source; Zest needs nothing else. - The same checks as any page. Blocked words and locked images are refused before
edithears of them. - A failed change becomes a note, with your
messageas the reason the person reads. Usenot-foundorambiguous(withcount) as thereasonwhen they fit, so the editor offers Change all or a note. - Words from a shared part ask where the change goes. When the words exist once but come from a part many pages share, answer that op with
reason: 'scope', amessage, and one to fourscopes:
``js { ok: false, reason: 'scope', message: 'These words come from a shared part', scopes: [{ id: 'component', label: 'Change on all 9 pages', detail: 'Every page in Products', primary: true }, { id: 'instance', label: 'This page only' }] } ``
The editor shows message with the primary scope's detail (or the first one's), a button per scope and Put it back. A button sends that op again with op.scope set to the scope's id, and your answer to it is handled like any other: ok saves, and a failure with a message becomes a note (so "This page only" can answer not-found with a reason and leave the change to the agent). Undo sends the same scope, so the person is not asked twice. Each scope needs a string id and label; detail is optional, and primary: true marks the one to press.
- After a save Zest renders the copy again, waiting a few seconds for the app to pick up its own change, and the tab keeps going. While a tab is open Zest asks
revisionevery two seconds and renders the copy again when it changes, so an agent's round shows up on the page. - Files the copy asks for come from the app: a stylesheet, a font or an image Zest's folder does not have is fetched from
origin, so the copy looks the way the app serves it. Keep scripts out of the copy; it is a picture of the page to edit, not the running app. Live on the bar shows the running app itself, fromoriginandroute, for the motion the copy cannot show (see Editing). - Notes above the page.
levels(file)names what sits above this page, at most six, each with a stringidandlabel. A whole-page note then asks what it applies to: this page or one of them. The note keepslevel: { id, label }, Changes shows the label, and the round tells the agent to change the shared source those pages read, or to answer with a proposal when the change needs a decision above the page. A level note always goes to the composing lane.levelsfailing or returning nothing means no choice. See Notes and Submit. - What stays the app's: page settings, Restore and Check's fixes would change only the copy, so Zest refuses them on a live page and says so. Leave a note instead.
What Submit starts
By default Submit starts Zest's own round in Claude Code on this laptop, scoped to the page's folder. A listener sets it up for your pages instead. Every field is optional; the web stays denied whatever it asks.
const listener = {
label: 'Claude Code, with our tools', // what the editor calls it
prompt: (file) => `/our-run ${file}`, // the round (default: the plugin's own)
cwd: (file) => workspaceRoot, // where it starts, so your Claude Code settings and hooks load
tools: ['mcp__studio__*'], // MCP tools it may use
scope: (file) => ({ // what else it may touch
dirs: [appFolder], // folders it may read and edit, beside the page's own
allow: ['Bash(npx tsc --noEmit*)'], // more Claude Code permission rules
plain: ['Check the app\'s types'], // the same, in words, for Changes and Setup
}),
worker: (file, openNotes) => null, // another model for this round, or null for the person's Claude login
};
Zest's own round. Set packet: true and Submit runs a round split into tasks (see Rounds split into tasks). These fields shape it; each is optional:
const listener = {
packet: true,
lanes: { // the models a round may use, by name
strong: { label: 'Claude Opus', model: 'claude-opus-5-5', budgetFactor: 8, why: 'composing work goes to the strongest model' },
fast: { label: 'Claude Sonnet', model: 'claude-sonnet-5', why: 'a stated change goes to a fast model' },
cost: { label: 'A cheaper model', baseUrl: 'https://gateway.example.com/anthropic', key: 'GATEWAY_KEY', model: 'm', budgetFactor: 8 },
},
routes: { compose: 'strong', mechanical: 'fast' }, // which lane takes which kind of work
routesFile: '/path/to/routes.json', // where `zest eval tune --apply` writes routes learned from evals
fanout: true, fanoutMax: 4, // mechanical tasks in parallel, each in its own worktree
worktree: (file) => ({ sparse: ['app'], link: ['app/node_modules', 'app/.env.local'] }), // what a worktree needs
checks: (file) => [{ name: 'Types', cwd: 'app', run: 'npx tsc --noEmit -p .' }], // run once after the round, in sh (cmd.exe on Windows)
slim: (file) => ({ settings: { hooks: { /* your gates */ } }, mcp: { mcpServers: { /* only what tasks need */ } }, tools: ['Read', 'Edit', 'Write', 'Bash', 'Glob', 'Grep'] }),
kindBudget: { compose: 8, mechanical: 1 }, // the real cap by the kind of work, times the person's cap, on any lane
judge: { model: 'claude-opus-5-5', budgetUsd: 1 }, // the reviewer that labels answers after each round
serve: async (dir) => ({ origin: 'http://localhost:4000', stop: async () => {} }), // a dev server for an eval's worktree
};
Name the routes file in your manifest's data list ("data": ["routes.json"] in zest-extension.json): approval covers an extension's code by hash, and a file listed there is left out, so tuning routes does not switch your extension off until someone approves it again. Declaring the file changes the manifest, which needs one yes.
A lane with only label and model runs one of Claude's own models on the person's login, and its budgetFactor multiplies the person's cap for that lane; Submit shows the result ("Claude Opus · up to $8") before the person presses it. A lane with baseUrl is another endpoint, and its factor keeps the cap a cap on real spend. A lane that cannot run on this machine (its key is missing) falls back to the person's own Claude, and the page says why. ZEST_LANE=<name> in the Zest server's environment sends every task to one lane. A live page's extension can add pages.sources(file), the files the app builds the page from; each task gets them in its prompt instead of searching for them.
worker returns { label, baseUrl, token | key, model, budgetFactor } for a model behind an Anthropic-compatible endpoint, chosen per round from the notes waiting (a round with Canva notes needs the person's claude.ai login, for example). With no address and no key, { label, model, budgetFactor } names one of Claude's own models for the round, on the person's login: a route, not another endpoint. Use it to send the round that needs the strongest model there (a new section, anything interactive or drawn) and the rest to the cheaper one; there budgetFactor scales the person's cap for that route, and the page shows it. key names a variable Zest reads on this machine; token is the key itself, read by your server code. Either way the page sees only the label, the model and the host. Claude Code counts a round's spend at Claude's prices whatever model answers; set budgetFactor to how many times cheaper the worker is, and the person's cap stays a cap on what they really spend. Without a worker, the person's own zest runner worker setting applies.
The ledger
Zest writes a row to the person's own ledger (~/.zest/ledger.jsonl, see Notes and Submit) for each note a round takes, each reading, each result and each outcome. An extension can keep its own copy, for a team that wants every machine's record in one place, and can say what Zest cannot know on its own:
const ledger = {
append: async (row) => { await sendToOurWarehouse(row); }, // after the local write, for every row about your pages
classify: (relPath) => (relPath.startsWith('app/ui/') ? 'shared' : null), // a file's part; null leaves Zest's guess
gate: (file) => 'structured', // the page's build stage, recorded on each request
};
append is called once per row, after the row is on disk. A hook that throws or takes more than 10 seconds is logged in one line and skipped; it never stops a round, and the row stays in the person's own file. ZEST_LEDGER=0 on a machine means no rows, for the extension too. A note may carry its own gate, which wins over the extension's.
Every row, schema 1:
{ schema: 1, id, at, type: 'request' | 'reading' | 'result' | 'outcome',
round, note, // the round's id (null for a round nobody traced) and the note's id
file, // the page, relative to the repo's top
repo: { name, origin }, // the main checkout's folder name, and origin with any user name or token taken out
branch, commit, machine, by,
request?: { text, kind, anchor: { selector, tag, text, movement, route }, level, scope, gate,
state?: { url, route, viewport: { w, h }, scroll: { x, y }, movement, dom /* at most 8 KB */, attrs, shot } },
reading?: { intent: 'copy' | 'layout' | 'motion' | 'picture' | 'structure' | 'content-model' | 'bug' | 'other',
specificity: 'exact' | 'directional' | 'open', assumed: [string], reference: [string], confidence /* 0 to 1 */ },
answer?: { status, said, task_state }, // on a result: how the note ended
impact?: { before, after, // snapshot commits around the change
files: [{ path, role: 'page' | 'shared' | 'content' | 'token' | 'asset' | 'test' | 'other', added, removed }],
pages_affected, shared, planned_files, touched_files },
execution?: { lane, model, why, task_kind, task, turns, tools: { <step>: count }, ms, cost_usd, checks: [{ name, ok }] },
outcome?: { signal: 'verdict' | 'undone' | 'reverted' | 'followup' | 'hand-edit' | 'merged' | 'shipped',
verdict, tags, by, about /* the result row's id */, detail },
miss?: { class, would_have_helped, proposed_rule } }
A request's id is q:<round>:<note> and a result's r:<round>:<note>, so an outcome names the answer it is about without reading the file. A result's impact and execution belong to the task that answered the note: two notes one task answered share them. pages_affected counts the pages the round changed plus the files that import a shared file it changed: an estimate. Zest writes every signal but shipped, which is kept for a deploy signal Zest does not have yet.
Someone already working here
Two agents in one checkout trip over each other. If your extension knows that an agent is already working in a page's checkout (a Claude Code session in that worktree, say), say so with owner, and Submit offers to hand the notes to it instead of starting a second one (what the person sees).
const owner = async (file) => ({ // null when nobody is working there
id: 'c0ffee12-…', // yours: whatever reaches that agent again. Zest only files mail under it.
label: 'Claude in the terminal', // what the person reads: "Send to Claude in the terminal"
active_at: '2026-10-14T09:12:03Z', // when it last did something in the checkout (ISO time)
how: 'It reads its inbox after each step.', // one line on how it gets the notes, shown under the choice
canWake: true, // whether deliver() can wake it rather than wait for its next step
});
const deliver = async (file, entry) => ({ woke: true, message: 'Sent it a nudge.' }); // after Zest writes the entry
- Zest calls
owner(file)with the page's absolute path when the person presses Submit, and gives it 3 seconds. A throw, a timeout, or an answer with noidcounts as nobody. Several extensions may answer; the first inzest.jsonorder that names an owner wins. - Submit asks only when
active_atis within the last 15 minutes. An owner quieter than that, or none, and Submit starts its round as it always has. - On Send, Zest writes one entry to
~/.zest/mailbox/<id>.jsonl(the id keepsA-Z a-z 0-9 . _ -, every other character becomes_), marks the notes routed through your notes store, then callsdeliver(file, entry)with the entry as written (its shape).deliveris optional and may fail: the notes are in the mailbox either way, and the agent finds them withzest inbox <id>. - On Wait until it is quiet, the Zest service calls
owner(file)again every minute and starts the round when it answers null or anactive_atolder than 15 minutes.
An image source
Shown as a tab in the picker. search(q, ctx) returns [{ id, title, preview, credit }], or, to page and filter, { results, total, more, filters }:
filters:[{ id, label, value, options: [{ value, label, count? }], any?, anyLabel?, remember? }], drawn as a row of chips each; the person's choice comes back inctx.filters(''means any). A choice is kept for the next picker unless the filter saysremember: false(a slot's concept holds for that slot alone).any: falsedrops the Any chip when the options already cover every case;anyLabelrenames it.- each result may add
score(0 to 1, shown as a match percentage),flag(a word on the tile) anddetail([[label, value], ...], shown beside the pick). ctx.pagecounts from 1 for Show more;ctx.targetis the image being replaced (kind,src,alt), and the search box starts from its alt text or file name.
preview must be an address the browser may load. pick returns { buffer, mime, name, provenance } (Zest saves it into assets/) or { rel } if you saved it yourself. The picker lets the person try a pick on the page before using it.
The slot's context
ctx.context says what the picture is for, when the page says so: { placeholder, concept, note, size, heading, movement, selector, route }. A placeholder is an element with data-placeholder (and data-concept, data-size, data-note when the page names them); heading is the nearest heading before the slot, movement the data-movement section it sits in, route the page's address on a live page. Use it to open on the right concept before the person types a word: the search box starts empty for a placeholder. A result may carry concept (the one it was found for), which the placement record keeps.
A search may answer with related: [{ preview, label, why, href?, kind? }], at most twelve, shown in a rail beside the candidates and never pickable. Each says why it is there ("hero on /guide/campaign-factory"), and href opens where it is placed. note (one sentence) is shown above the results.
Placements
A pick into a slot that names a concept, or into any placeholder, is a placement. Zest records what was placed, from where, the asset id, the concept, the slot (its heading, size, movement and selector), who and when. With a placements.record(file, record) the record goes to your ledger (answer { where } for the toast); without one it is appended to .zest/placements.json beside the page. A placeholder becomes the picture in place: an <img> that keeps the placeholder's class and data attributes, so the page's size rules still apply (a live page's edit receives { op: 'placeholder', match: { concept?, note? }, occ, domCount, set: { src, alt }, keep: { class, size }, context }; a writer with no answer for it makes the pick a note).
Movements
A page may mark its steps with data-movement="<id>". A note on an element keeps the movement it sits in (note.movement), a placement keeps it in its slot, and a round's trace lists movements: which sections changed, with the notes each one carried, so every edit is attributable to a step of the page. afterRound(file, trace) is called when a round ends, after the trace is saved.
ui.js
Runs in the editor and adds to it through one door:
window.__zest.extend({
panels: [{ id: 'house', label: 'House', render(body, foot) {} }], // a tab in the drawer
selectionActions: [{ label: 'Ask legal', when: (el) => true, run: (el) => {} }],
imageActions: [{ label: 'Edit in our tool', icon: '/__zest/ext/…/icon.svg', when: (img) => true, run: (img) => {} }],
pickerTabs: [{ id: 'regen', label: 'Regenerate', when: (target) => true, render(main, { target, choose, use, xapi, h }) {} }],
keys: { t: () => {} }, // a letter Zest does not use
partOf: (el) => ({ part: 'slide-3', part_label: 'Pricing', build: 0 }), // what a note on this element is about
answerActions: { 'house.pick': { run: (note, args) => {} } }, // a next step an answer offers (result.actions)
onState: [(state) => {}], // after every refresh
});
window.__zest.xapi('house', 'thing', { method: 'POST', body: {} }); // your server routes
A picker tab's render gets context (above) and related(list) to fill the rail.
window.__zest also offers h (make elements), api, toast, state, openDrawer, select, imageTarget, editAttr, openPicker, stateOf(el) (the page's state around an element, as notes keep it), and placeRel(target, rel, alt) to put a picture your extension saved where an image is (with Undo, locked images refused). A file of your extension the browser loads by its address (a picture, a script) goes through window.__zest.src(address): on a page the Zest service serves, it adds the session the request needs. Your script runs inside the editor's page, with the same rights as the editor: write it like code your team reviews, because it is.
What a team gets for this
- Every round reads the same rules. Brand and canon come before any note, so a fix to one sentence keeps the voice of the page and the facts of the company.
- Settled stays settled. The brief's settled list and your checks stop a round from reopening a decision.
- One place for notes. A shared store means the person reviewing and the agent running see the same list.
- Nothing runs by surprise. Every command and extension waits for a person's yes, and the yes is tied to the exact command.