Zest

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" }]
}
KeyWhat it does
contextLayered 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.
builtPages 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.
checksYour 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).
lintWords 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.
protectImages 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.
packsBuilt-in packs to switch on (they also switch themselves on for pages they fit). "-deck" switches one off.
extensionsFolders of code your team adds (below).
imagesPicker 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.
sitesFor 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.

PackSwitches on forWhat it adds
leversPages with a <script type="application/json" id="zest-levers"> blockA 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.
deckPages 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
};

``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.

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

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 }:

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

Suggest a change to this page