agentleFS
Sign inSign up

mill / userdocs

millhq/mill/userdocs/llms-full.txt

A script writes new shapes into a .drawio file on disk. Drop that file on an Atlas board and it keeps rendering the current version, updating itself every time the file changes outside Mill — no re-import, no stale diagram. That's Mill: a desktop app for building guardrailed automations — workflows you compose from typed steps, run by hotkey, schedule, or watcher, with every risky action gated for your approval. Three ideas carry the whole product: Workflows are visible machines.…

llms.txt1 starsChanged 22 days ago
  • Reads credentials
  • Deletes or force-pushes
  • Installs packages
  • Sends data out
# Mill — full documentation

---


# What is Mill

A script writes new shapes into a `.drawio` file on disk. Drop that file
on an Atlas board and it keeps rendering the current version, updating
itself every time the file changes outside Mill — no re-import, no
stale diagram.

That's Mill: a desktop app for building guardrailed automations —
workflows you compose from typed steps, run by hotkey, schedule, or
watcher, with every risky action gated for your approval.

Three ideas carry the whole product:

**Workflows are visible machines.** A workflow is a chain of steps on a
canvas. Every step declares what it takes and what it produces (shown
right on its card, like `HTML → Markdown`), so a workflow reads like a
sentence, and connecting steps that can't work together is refused with
an explanation at the moment you try.

**Nothing external happens without a gate.** Every step carries an
effect class — reading your clipboard is not the same as calling an
API. Steps with external effects park their run and ask for approval by
default; you decide once, or write a guardrail rule that decides for
you, scoped exactly as narrowly as you want.

**Agents are first-class users.** Mill exposes everything a human can
do through an MCP server, with the same guardrails. An AI agent can
compose, run, and inspect workflows on your behalf — and its writes
wait for your approval exactly like any other external effect. Mill
itself never calls an AI API and never phones home: it is the workbench
agents use, not an agent.

Mill is a single binary. Your data stays in local files you can back
up, export, and inspect. Install it by cloning the repository and
building, or grab a release — see [Install](install.md).

---


# Install

Two ways to get Mill running: download a release, or build it yourself.
Either way you end up with one binary — no hosted service, no account.

## From a release (macOS)

1. Download the newest `.zip` from the
   [releases page](https://github.com/millhq/mill/releases) — beta
   releases carry every merged change; stable releases are tagged.
2. Unzip and drag `mill.app` to Applications.
3. First launch: the app is not notarized, so macOS shows "Apple could
   not verify…". Click **Done** (not Move to Trash), then open System
   Settings → Privacy & Security, scroll to the Mill message, and
   choose **Open Anyway** — one time only.
4. From then on, update in-app: Settings → Updates → Check for
   updates → Update now. With automatic checks on, a new release on
   your channel also shows a notification — that notification is
   the "Notify when an update is available" workflow, editable like
   any other. If your network blocks app downloads, set
   Settings → Updates → Outbound proxy, or use the browser-download
   button the app offers on failure.
5. A build you downloaded with a browser (steps 1–3, or the
   fallback button) arrives quarantined by macOS. If the app won't
   open even after Open Anyway, clear the quarantine in Terminal:

   ```
   /usr/bin/xattr -dr com.apple.quarantine /Applications/mill.app
   ```

   Write out `/usr/bin/xattr` in full. If Python's `xattr` is
   installed, it shadows the macOS one on your PATH and has no `-r`
   flag at all.

   In-app updates never need this — only browser downloads do.

## From source

```
git clone https://github.com/millhq/mill.git
cd mill
task install:app
```

Requires Go, Node, and the Wails v3 CLI (`go install
github.com/wailsapp/wails/v3/cmd/wails3@latest`). `task dev` runs a
hot-reloading development copy instead of installing.

A source build updates by pulling and rebuilding — the in-app updater
deliberately refuses to overwrite a copy it didn't install.

## Where your data lives

Everything is local: settings and entities in a JSON settings store,
run history in a SQLite file, both under your user's application
support directory. Settings → Backups snapshots them automatically and
can export everything to one file for another machine.

---


# Your first workflow

Mill ships with working examples, and the fastest way to understand it
is to run one, then rebuild it yourself.

## Run the seeded one

1. Open **Workflows**. Find **Clipboard → Markdown** — it captures
   whatever HTML is on your clipboard, converts it to Markdown, writes
   the result back to the clipboard, and notifies you when it's done.
2. Copy something from a web page.
3. Click **Run** on the workflow. Paste anywhere: you'll get Markdown.

Every example named `Example: …` demonstrates one capability the same
way — open any of them on the canvas to read how it works.

## Build it yourself

1. **Workflows → New workflow.** A Manual run trigger is already on
   the canvas — every workflow starts with exactly one trigger.
2. Click **+ Add step**. Search `clipboard` and drag **Read
   clipboard** onto the canvas. Connect the trigger to it.
3. Add **Convert HTML to Markdown** and connect it. Notice the card
   says `HTML → Markdown` — that's the step's contract. If you tried
   to connect two converters in a row, Mill would refuse and tell you
   why.
4. Add **Write text to clipboard**, then **Notify me**. Connect them
   in order.
5. Name the workflow and **Save workflow**.
6. Copy some page content, then **Run**. The notification tells you
   the Markdown is ready.

## Where to go next

- Give it a **hotkey**: change the trigger's type to Hotkey pressed in
  the step inspector, record a combo, and run it from any app.
- See what happened: every run is recorded under the workflow's
  **Runs** tab, step by step.
- Try a step with an external effect (like Call an API) and watch Mill
  park the run for your approval — that's the guardrail model,
  explained in [Guardrails](../concepts/guardrails.md).

---


# Your first board

Atlas is the board where cards, tables, and diagrams sit side by side.
This walk places one of each, lines them up, and undoes a change —
ten minutes, nothing to install.

## Place a card

1. Open **Atlas**. The seeded board opens with a few cards already on
   it.
2. Press **C** (or pick **Card** in the tray at the bottom of the
   board) and click an empty spot. The card appears where you clicked.
3. Type a title — *Launch checklist*, say — and press Enter. The card
   keeps the kind the tray offered; change it later from the card's
   own page.

## Place a table

1. Pick **Table** in the tray. A size grid opens: sweep across it to
   the shape you want (3 × 3 is plenty) and click.
2. Move the pointer over the board. A dashed outline carrying the new
   table's name follows it, so you see where the table lands before
   you commit. Click to place it.
3. Click the table once to select it, then click a cell and type.
   Click a column header to rename it. Scroll inside the grid and the
   board holds still — only the table's rows move.

## Place a diagram

1. Drag a `.drawio` or `.mmd` file from Finder onto the board (or
   copy the file in Finder and press ⌘V over the board). It lands as
   a diagram at your pointer and renders right there.
2. Edit the file in its own app and the diagram on the board updates
   itself — no re-import.
3. Right-click the diagram and choose **Edit diagram…** to open the
   built-in editor instead.

## Line things up

Drag the card toward the table. As one of its edges or its center
comes within a few pixels of the table's, a guide line appears across
both and the card snaps to it. Release, and the two share that line
exactly. The same guides appear against every card and object nearby,
at any zoom level.

## Undo

Press **⌘Z**. The card moves back to where it was. Press **⇧⌘Z** to
redo. Undo covers almost everything on the board — placing, moving,
resizing, deleting, pasting, drawing — and deleting also shows a
brief **Undo** button on the board itself.

## Where to go next

- What every building block on the board can do, in
  [Atlas](../concepts/atlas.md).
- Let an agent read and edit that diagram by shape id, in
  [Edit a diagram with an agent](../agents/diagrams.md).

---


# Store and reference a secret

Put a value in the vault, then pick it from any field that needs one.
Why every such field is a pick, not a text box, is in
[Secrets are references](../concepts/secrets.md).

## Store it

1. Open **Secrets**. The first time, press **Create vault**; after
   that, press **Unlock** if the vault is locked.
2. Press **New secret**.
3. Give it a **Title** you will recognise in a picker — *GitHub token*,
   say — and paste the value into **Password**. Username, Website,
   Notes and Tags are optional.
4. Press **Save**. The entry appears in the list; **Copy password**
   puts the value on your clipboard for ten seconds.

## Reference it from a step

1. Open the workflow and select the step, or open the Configure entry
   (an Integration, an MCP server, an AI provider, an Environment).
2. In the secret field, open the picker. Entries are grouped under
   **Vault** and **Secret sources**; **Add new secret** creates one
   without leaving the field.
3. Pick the entry. The field shows its title, never the value, and the
   run resolves it when the step executes.

## Use a value you keep elsewhere

1. Open **Secrets › Sources** and add the source — your shell
   environment, a `.env` file, or a password manager's CLI. For `.env`
   files, **Find .env files…** scans a folder you pick (also from the
   command palette) and ticks what it found; a file that is already a
   source is left unticked, and one Mill cannot read is named with the
   reason.
2. **Import keys** copies a picked file's entries into the vault
   (importing again updates them in place); **Add as sources** reads
   them live from the file instead.
3. A `.env` row shows its key count; **Show keys** lists the key
   names — never the values.
4. Its keys appear in every picker under **Secret sources**. Pick one
   exactly as you would a vault entry; Mill reads the value at run
   time and never copies it into the vault.
5. Edit the file in place any time — every open picker and the Sources
   list pick up the change on their own.

## If a picked key is gone

The field says "Unresolved" and names the key and the source: someone
removed it from the file, or renamed it. The pick still names the
source, so putting the key back (with the same name) resolves it
again. Starting a run that needs a key like this refuses before
anything runs, naming the same key.

## If the picker is greyed out

The vault is locked. Open **Secrets** and press **Unlock**, or search
"unlock vault" in the command palette (⌘K).

---


# Fire a workflow from a webhook

Any tool or service that can send an HTTP request can start a
workflow: post JSON to Mill's webhook address with a token from
Settings.

## Add a token

Settings → Connections → **Webhooks** → *Add webhook token*. Give it
a label naming the tool, copy the token the moment it appears — Mill
shows it exactly once — and paste it into the sending tool's own
configuration. Mint one per tool; revoke any of them from the same
list.

Mill binds the door to this Mac only; the token is the credential,
even from this Mac. The notification reaches a paired phone only if
one is paired (Settings → Connections → Remote access).

## Post an event

Replace `<token>` with the minted token:

```
curl -s -X POST http://127.0.0.1:8092/__mill/webhook \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"source":"<tool>","title":"...","body":"..."}'
```

Any JSON object is accepted — a wrong or revoked token answers 401,
anything that isn't a JSON object answers 400. `source` names which
tool posted (lowercase, exact); the seeded **Notify when a webhook
fires** workflow catches every source and notifies on every channel,
including a paired phone. Scope a workflow to one tool by setting the
trigger's Source field.

Any JSON fields you post become attributes the catching workflow
declares by name — a workflow that declares `title` and `body`
attributes receives the posted `title` and `body`, and a sender can
carry whatever else it needs (`run`, `duration`, `url`) for a workflow
declaring those to use. The posted body also arrives whole as the
run's payload.

## Examples

- **A CI job**: a build or deploy step posts on success or failure.
- **A shell script**: any command that can shell out to `curl` at the
  point it wants to fire a workflow.
- **A monitoring alert**: an alerting rule posts when a threshold
  trips.
- **An agent tool's hook configuration**: some tools let you declare a
  command to run at a lifecycle point without writing a script
  yourself — the declared command is the same curl above:

  ```json
  {"command": "curl -s -X POST http://127.0.0.1:8092/__mill/webhook -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' -d '{\"source\":\"<tool>\"}'"}
  ```

## Reply to the caller

A workflow can also answer the tool that posted, rather than only
reacting to it — see [Reply to a webhook](reply-to-a-webhook.md).

---


# Reply to a webhook

A workflow that starts from **Webhook fired** can answer the tool that
posted the event, not just react to it. Add an **Answer the webhook**
step, and Mill holds the caller's connection open until that step
runs, then sends its status, body, and content type back verbatim.

## Add the step

Drag **Answer the webhook** onto the canvas from the Apply group and
connect it after whatever steps decide what to say. Set:

- **Status code** — the HTTP status the caller receives.
- **Reply body** — usually JSON, in the calling tool's own schema. Use
  `{{attributeName}}` to drop in a value the trigger or an earlier step
  captured.
- **Content type** — the reply's Content-Type header.

Set **Reply within (seconds)** on the trigger's own **Webhook fired**
step to how long the caller should wait. The sending tool's own
timeout must be longer than this — a caller that gives up first never
sees the reply Mill sends.

## The sender reads the response body

Some tools post an event and read the decision straight from the
response, no script in between — a configuration entry pointing
directly at a URL, rather than running a command:

```json
{"url": "http://127.0.0.1:8092/__mill/webhook", "headers": {"Authorization": "Bearer <token>", "Content-Type": "application/json"}}
```

A tool that only runs a command reads the same reply from the curl
response instead:

```
curl -s -X POST http://127.0.0.1:8092/__mill/webhook \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d @-
```

Either way, whatever the Answer the webhook step sent — status, body,
content type — is what the sending tool receives.

## A quiet failure proceeds, at the sender

If nothing answers, if the reply is malformed for what the tool
expects, or if the sending tool's own timeout runs out first, most
tools treat that as a non-blocking failure and let the action proceed
anyway. A workflow meant to gate a tool call needs to say so plainly
in its own reply body, in the tool's own schema — Mill never assumes
what "allow" or "deny" look like on the other end.

## Order decides what a run answers with

Only the FIRST Answer the webhook step to run in a given execution
sends anything; every one after it is skipped; a step's own run notes
say so ("Reply already sent by step …"). Put the steps that decide
WHAT to say — a Branch, a lookup, a guardrail check — before the
Answer the webhook step, not after it. A reply step placed too early
answers before that evaluation ever runs.

---


# Install a plugin

Read the [plugin standard](plugin-standard.md) first.

A plugin adds a new object type to the canvas without rebuilding Mill.
It is a folder holding two files — `manifest.json` (name, version, and
what the plugin is allowed to ask for) and `main.js` (its code) — and
installing one is copying that folder into Mill's plugins folder.

## Installing

**Extensions** is its own page — press ⇧⌘X, or pick it in the sidebar.
It has three tabs: **Installed**, **Browse**, and **Updates**.

Browse lists everything your marketplaces offer that you have not
installed yet, including the examples Mill ships when policy allows
them. While sources load, fail, or return only installed entries,
Browse says which state it is in instead of calling the result empty. Press **Install**
on a row. Mill shows what the extension can do — the hosts it reaches,
whether it writes to your boards, what it adds — and installs it only
after you confirm. The new extension appears under **Installed**;
reload to load it.

On a Mac an organisation manages, Extensions says **Managed by
<organisation>** and a policy file decides what may install and run;
see [Managed extensions](managed-extensions.md).

### Installing from a link or a folder

Not everything lives in a marketplace. Mill also installs from:

- a repository, as `owner/repo` or `owner/repo@v1.2.0`
- a direct address of a `.zip` archive
- a folder on this Mac, for a plugin you are writing yourself

The folder name must match the plugin's id, and a folder you point
Mill at is copied, not linked.

### How much Mill checked

Every installed extension wears one badge, and it says exactly what
was checked:

| Badge | What it means |
| --- | --- |
| **Verified** | Its files match the hash the marketplace published, and a key this Mill trusts signed them. |
| **Hash-pinned** | Its files match the hash the marketplace published. |
| **Unverified** | Nothing checked these files. Mill asks you to acknowledge that before installing. |
| **Dev** | You installed it from a folder on this Mac. |

The badge is on the row, on the extension's page, and on its
**Verification** tab, which also lists what the extension can do — the
same list you saw before installing.

### Marketplaces

A marketplace is any repository or folder with a `.mill/marketplace.json`
file at its root, listing the plugins it offers. Press **Sources** in
the Browse tab to add one: `owner/repo`, a repository address, a direct
address of a `marketplace.json` file, or a folder path.

Sources shows the canonical location, publisher, last successful refresh,
and any current failure. A failed refresh keeps the last usable catalog.
Removing a source removes its registration and cached catalog; extensions
already installed from it stay installed. Re-adding the same name creates
a new source identity, so an older refresh or confirmation cannot change it.

Opening Sources and pressing **Retry** after a local read error do not use
the network. Mill reads a marketplace only when you add it, press **Refresh**,
install from it, or check for updates. It never reaches out on its own.

### Updates

Mill never looks for a newer version on its own. Open **Extensions →
Updates** and press **Check for updates**: Mill re-reads every
marketplace you added, then asks each installed extension's own
source what it offers now — the marketplace entry it came from, a
repository's latest release, or the folder you installed it from.
Only a strictly newer version is listed; a downgrade is never offered.

Each row has **Update**, and **Update all** applies every row at once.
An update goes through the same door the first install did, with the
same badge and the same prompt: an unverified update still asks you to
acknowledge it, so **Update all** leaves those rows for you to press
one by one. The Updates tab shows a count until you have applied them,
and the row's **…** menu offers the same **Update** and **Check for
updates**.

A repository publishes an update as a release whose tag is the
version and whose asset is named `<id>-<version>.zip`; Mill fetches
that asset by name. An extension you copied in by hand has no source
to ask, so it never appears here.

### MCP servers an extension ships

An extension can ship the definition of an MCP server — the command
that starts it and the environment it needs. Its page lists each one
under **Contributions → MCP servers** with **Add to Configure**: one
press creates the MCP Server entity in Configure, ready for a workflow
to call tools on.

A secret the server needs is named by one of the extension's own
secret settings, never written into the extension. Pick the secret on
the extension's **Settings** tab first; the entity is created with a
reference to it, and Mill resolves the value only when the server is
started. If no secret is picked yet, **Add to Configure** tells you
which setting to fill in.

### Installing by hand

Copying a folder into the plugins folder still works. Open
**Extensions → Installed → Open plugins folder**, copy the folder in,
and press **Reload all**.

A plugin that can't load shows exactly why on its page — a missing
file, invalid manifest, or a capability Mill doesn't recognize —
instead of silently doing nothing. A plugin whose manifest sets
`minMillVersion` to a version newer than your Mill is refused the
same visible way: update Mill, then reload.

### What an extension's page shows

Click a row to open it. **Overview** is the plugin's own README;
**Contributions** is what it adds, what it can reach, and what it
catches; **Changelog** is its CHANGELOG; **Verification** is what
checked it and what it can do; **Settings** is whatever it declares.

## Starting a plugin

`mill plugin new <name>` writes a folder holding `manifest.json` and
`main.js`, already named and valid, and prints where to copy it. Add
`--dir <path>` to create it somewhere other than the current folder.

For autocomplete while you write it, install the types:

```
npm i -D github:alicoding/mill#path:frontend/plugin-sdk
```

Then put two lines at the top of `main.js` and annotate `activate`:

```js
// @ts-check
/// <reference types="@alicoding/mill-plugin-sdk" />

/** @param {import('@alicoding/mill-plugin-sdk').MillPluginAPI} api */
export function activate(api) {}
```

Nothing is compiled — the types are read by your editor, and Mill
loads the same plain file either way. Every type is listed in the
[plugin API reference](plugin-api/index.md).

The SDK also publishes `manifest.schema.json`. Point your editor or
manifest-checking tool at that JSON Schema for field completion and structural
errors. Run the loader's checks below for semantic rules and files named by the
manifest.

To preview the ordered manifest migrations available for a source plugin, run:

```sh
mill plugin migrate path/to/your-source-plugin
```

The preview prints the ordered migration IDs and their aggregate RFC 6902 patch,
and writes nothing. Add `--json` for a versioned machine-readable plan with
`formatVersion`, `migrations`, `patch`, `manual`, and `applied`, or `--apply` to
replace `manifest.json` after the full plugin validates.

The current migrations rename deprecated `contributes.settings` to
`contributes.configuration` and namespace a bare lower-camel command ID with
the plugin ID. Command references in declared tools and menus change with the
declaration. Existing JavaScript may keep registering the old bare command: the
host connects it to the exact namespaced manifest declaration centrally.

Mill leaves the source unchanged when both setting keys or both command
identities exist, when the canonical command target collides, when a bare
command needs an authored rename such as `send-again` to `sendAgain`, or when a
reference is ambiguous. The command refuses installed plugin folders and
install receipts; run it only against the source folder you author.

## Reloading one plugin while you work

Each installed plugin's page has a **Reload** button, and the command
palette carries the same action as "Reload <plugin>". It re-reads that
plugin's `main.js` and re-registers everything it contributes — its
tools, views, captures, and commands — without restarting Mill.
Objects already on your boards stay where they are.

Editing a plugin's files changes what you allowed it to run, so the
reload asks you to allow it again on its page first. **Reload all** at
the top of the list is the other half: it restarts Mill's plugin
loading entirely, which is how a folder you just copied in is noticed.

## Turning a plugin off

Each installed plugin has the same switch every built-in extension
has, on its row. Turning it off removes its tool from the tray and
palette; objects it already placed stay on your boards untouched.

## Removing a plugin

On the plugin's page, open the **…** menu and choose **Remove…**. Mill
asks first, then moves the plugin's folder to the Trash — nothing is
deleted, so you can put it back. Objects it created stay on the board
as unknown kinds until it is installed again, and a folder you restore
asks to be allowed again, the way any newly installed plugin does.
Plugins that ship inside Mill have no Remove.

## Before a plugin runs

A plugin you install after Mill first ran with this check waits for
your review: its page in Settings > Extensions states what it can
request, which hosts it can reach, and what it catches, and nothing of
it runs until you click **Allow** there and reload. A notice in the footer tells you
when one is waiting. Plugins that were already installed when the
check arrived keep running; only new arrivals wait.

Mill remembers what you allowed: the plugin's files are fingerprinted
at that moment, and if they change later — an update you copied in,
or an edit — the plugin stops until you look again and allow it once
more. Its page says "Its files changed since you allowed it."

If an update asks for more than you allowed before — a new capability,
a new host, or drawing directly in Mill's window — the plugin waits
for review again even though you already allowed an earlier version.
Its page shows "Now also asks to" above the full list, naming only
what is new, and its Allow button reads "Allow the new permissions."
An update that asks for the same or less keeps running without asking
again.

An administrator can pin which plugins may run at all by writing an
allow-list into Mill's settings file — the key
`settings-plugin-allowlist`, a JSON array of plugin ids, placed the
way device-management tooling places any managed setting. When it is
set, Settings > Extensions reports it and every plugin off the list
shows as blocked on its page, with no switch on its row. The Drawing
plugin built into Mill is exempt.

An administrator can also require signatures: the key
`settings-plugin-signing-keys`, a JSON array of minisign public keys.
With keys pinned, a plugin runs only when its folder holds
`mill-plugin.minisig`, a minisign signature of the folder's content
hash (Export plugin audit shows each plugin's `contentHash`; sign that
string with `minisign -S`). Unsigned plugins show as such and cannot be
turned on.

**Export plugin audit** (Settings > Extensions, or the command
palette) saves one JSON file: every installed plugin with its declared
reach and whether it is allowed and on, every action a plugin asked
Mill to perform within the last day, and every secret a plugin read.

## What a plugin can and cannot do

A plugin draws its own objects and edits their data through Mill.
It is never handed the ability to open network connections, touch
files, or leave the app on its own. When it needs something like
that — opening a web address in your browser, say — it must:

1. **Declare** the capability in its manifest, visible on its
   Extensions row before you ever run it.
2. **Ask** at the moment of use. Every ask runs through your
   guardrail rules: you can allow it, deny it, or leave the default,
   which parks the request in **Review** for your explicit approval.

Undeclared asks are refused outright. Approved actions are performed
by Mill itself, never by the plugin's own code.

## Catching drops and pastes

A plugin can claim the two ways outside content lands on the board:
a file dragged in from your file manager, and content pasted from
another app. Claims are declared in the manifest, so a plugin's
Extensions page shows what it catches before it ever runs:

```json
"contributes": {
  "canvasObjects": [
    { "kind": "bookmark", "pastesURLs": true, "fileExtensions": [".webloc"] }
  ]
}
```

- `fileExtensions` — dropped files with a listed extension land as
  this plugin's object, pointing at the file where it is. Requires
  `source: "file"` on the registered object.
- `pastesURLs` — a web address pasted from any app lands as this
  plugin's object instead of a note. Requires `source: "url"`.

Mill's own built-in shapes always win first — a diagram, image, or
spreadsheet file keeps landing as its built-in object — and anything
no one claims still lands the way it does today. With the Bookmark
example installed, pasting a link from your browser drops a bookmark
right on the board.

When two plugins claim pasted links (Bookmark and the Web clipper both
do), the first one lands and the board's toast offers the other:
"Pasted as Bookmark · Paste as Web clipper instead" re-types that same
object in place, undo included. Settings > Extensions' "Pasted links
become" picks which one lands first; without a choice, plugins take
turns in id order.

## Drawing tools

An extension isn't limited to click-to-place objects: it can register
a DRAG tool that rides the same gesture engine, style picker and live
preview Mill's own drawing tools use — in fact Mill's own pencil,
shape, eraser and laser ARE such an extension (**Drawing**, built into
the app; its row sits under Installed, and a folder named
`mill-drawing` in your extensions folder replaces it).

`api.registerCanvasTool(decl)` is the door. Mill owns every pointer
event and paints the live preview itself, so the tool runs fully
sandboxed: it never reaches the board, and nothing it draws can escape
its own frame. Declare the kind with `"tool": true` beside it in
`contributes.canvasObjects`.

- `onPointer(event, ctx)` — called once per phase, once per frame.
  `event.phase` is `"down"`, `"move"`, `"up"`, `"cancel"` (the drag
  was abandoned) or `"fade"` (a trail still aging out after release).
  `event.point` is the newest sample in BOARD coordinates and
  `event.coalesced` carries the samples folded into the same frame,
  oldest first, so a freehand stroke loses no detail. `event.zoom` is
  the board's current scale — divide by it to keep a trail the same
  thickness however far the board is zoomed out. `event.modifiers`
  carries the keys held; `event.styleValues` the picker's current
  values; `event.target` the object under the pointer, if any.
- `ctx.createDraft({ at, size?, data?, preview? })` — starts the
  in-progress object. `draft.patch(...)` merges into it as often as
  you like: nothing persists, nothing syncs, and nothing lands in the
  undo history until `draft.commit({ select? })`, which places it as
  ONE undo step. `draft.discard()` throws it away and the board is
  exactly as it was.
- `preview` — what Mill draws while the drag is live, instead of
  mounting the object's face on every move. `kind` is `"rect"`,
  `"ellipse"`, `"line"`, `"path"` or `"shapes"`, and `from` names the
  draft key carrying the geometry: `"x,y,width,height"` for a box,
  `"x1,y1,x2,y2"` for a line, an SVG path for `"path"`, and a JSON
  list of parts for `"shapes"` — the one to use when each part carries
  its own paint, like a trail whose points fade independently.
  `fill`, `stroke`, `strokeWidth` and `opacity` each name a key
  holding that value. A draft's `data` is what a commit saves;
  `preview` is drawing state that is never saved, and the declaration
  reads across both.
- `styleFields` — the tool's styleable properties (`color`,
  `color-or-none`, `stroke-width`, and `shape-kind`, an icon-button
  picker whose options name their icons from the same named glyph set
  as `icon`), each with its own options, default and optional `label`.
  Declaring any renders Mill's style picker next to the armed tool
  automatically.
- `ephemeral: true` — the drag draws a trail and places nothing (a
  laser pointer, an eraser). Add `fadeMs` and the trail keeps aging
  out after release, one `"fade"` frame at a time.
- Identity — `icon` takes an emoji or a named glyph (`pencil`, `zap`,
  `trash`, `diamond`, `square`, `circle`, `arrow-up-right`);
  `cursor` sets the pointer shape while the tool is armed;
  `shortcutKey` gives it a bare-letter shortcut and tray key chip;
  `group: "annotate"` files its button into the tray's Annotate
  drawer; `objectKind` lets the saved object kind differ from the tool
  id (the pencil places `ink` objects); `dragBand: false` drops the
  drag-handle band when the object's whole body already drags. Drag
  tools stay armed across strokes by default (`sticky: false` opts
  out, and a non-sticky tool may add `lockable: true` so re-clicking
  its armed button locks it for deliberate repetition).
- `api.files.saveImageBytes(base64, ext, title)` bakes drawn bytes
  into a Mill-owned file, ready to use as a file-backed object's
  payload — how the pencil turns a finished stroke into a picture.
- `api.measure(markup, maxWidth)` lays markup out off the board at
  real pixel size and answers `{ width, height }`, for a face whose
  own layout depends on how big its content turned out.
- Erasing — a tool that erases instead of creating declares the
  `erase-board-items` capability in its manifest. Its ctx then carries
  `eraseAt(point)` (accumulates whatever board item sits under the
  point) and `commitErase()` (erases the accumulated set through the
  same undoable quick-delete a Delete key press uses — one undo step
  per pass). What was hit stays on Mill's side; the extension never
  sees item identities.

The object's own face is an entry page: name it as `"entry"` beside
the kind, the same way any other framed face is declared. A tool that
places nothing needs none.

## Settings

A plugin declares its own settings in the manifest, and Mill does the
rest: the controls render on the plugin's page under Installed, the
values are stored centrally, and the plugin reads them
back through `api.settings`. A plugin never builds a settings screen.

```json
"contributes": {
  "settings": [
    { "key": "titleStyle", "type": "enum", "label": "Title",
      "description": "What a bookmark shows as its title.",
      "default": "hostname",
      "options": [
        { "value": "hostname", "label": "Site name" },
        { "value": "address", "label": "Full address" }
      ] },
    { "key": "placeholderTitle", "type": "string",
      "label": "Title before an address", "default": "Bookmark" }
  ]
}
```

Six types: `boolean` (a checkbox), `string` (a text field),
`number` (a number field, with optional `min` and `max`), `enum`
(a dropdown over `options`), `secretRef` (a picker over the
vault's entries — see below), and `entityRef` (a picker over a
Configure entity — see below). `default` is the value in effect until
the user changes the control; a mistyped manifest — a default of the
wrong type, an enum default missing from its options, a default on a
`secretRef` or `entityRef` — blocks the plugin from loading and names
the key on its page.

A `secretRef` setting names a credential without ever holding it:

```json
{ "key": "auth", "type": "secretRef", "label": "Authorization",
  "description": "Sent as a bearer token with every request." }
```

The user picks one of their vault entries in the plugin's row; the
stored value is a reference, and `api.settings.get('auth')` answers
the entry's title (or an empty string when nothing is picked). The
value itself only ever travels inside `api.fetch` — see Reaching the
network. A picked entry that is later deleted shows "This secret no
longer exists. Pick another." on its page, and a request naming it is
refused with the same words.

An `entityRef` setting points at a Configure entity — an Integration,
a List, an MCP server, and the rest of the kinds a workflow node's own
reference field can point at — through `entityKind`:

```json
{ "key": "integrationId", "type": "entityRef", "entityKind": "request",
  "label": "Integration",
  "description": "Which Integration this view reads from." }
```

The user picks it from the same live picker a workflow node's own
reference field uses; the stored value is the entity's id, and
`api.settings.get('integrationId')` answers that id back — never a
label, since the plugin already has the doors that resolve one.
Deleting an entity a plugin still has picked is refused, naming the
plugin.

```js
export function activate(api) {
  const style = api.settings.get('titleStyle')        // 'hostname' | 'address'
  const stop = api.settings.onChange('titleStyle', (next) => {
    // Redraw whatever depends on it -- a face only re-renders on its
    // own data changes, so this is the door for a settings change.
  })
}
```

Commands a plugin registers can carry `enabled: () => boolean`, the
same state check built-in commands use; a disabled command is left
out of the palette rather than shown doing nothing. A default
keyboard shortcut is not something a plugin ships — the user assigns
one under Keyboard shortcuts.

## Notices

A plugin tells the user something through Mill's own notice pill in
the footer, labelled with the plugin's name. Info and success notices
leave on their own after a few seconds; warnings and errors stay until
dismissed. A user-started action that fails should always say so here,
never only in the console.

```js
const dismiss = api.notify({ level: 'error', text: 'Could not save the bookmark address.' })
// Optional: a secondary link running one of this plugin's own commands.
api.notify({ text: 'Imported 12 rows.', action: { label: 'Show', commandId: 'showImport' } })
```

## Storage

A plugin keeps its own state in `api.storage`, saved centrally under
the plugin's id: any JSON value, read synchronously, written through
on `set`. Nothing else in Mill reads it. The Drawing plugin uses it to
remember the last-used pencil and shape style across restarts.

```js
const saved = api.storage.get('pencil') || {}          // undefined when never set
await api.storage.set('pencil', { color, size })
await api.storage.delete('pencil')
api.storage.keys()                                      // ['pencil', …]
```
## Reading the board

A plugin lists what is on the board through `api.query`, and hears
about changes through `api.on`. Every entry carries the name a person
sees it by: a card's title, a note's first line, an object's title or
kind. Cards, notes, and every object kind — built-in or another
plugin's — come back the same shape.

```js
const notes = await api.query({ kind: 'note' })        // [{ id, kind, title, parentId, position, size, payload }]
const everything = await api.query({})
const children = await api.query({ parentId: someCardId })

const stop = api.on('contents:changed', ({ id }) => {
  // Something on the board was created, edited, moved, or deleted.
  // A face only re-renders on its own data, so re-list here.
})
```

The Board index example (`examples/plugins/mill-index`) is exactly
this: one object whose face lists the board by kind and re-renders
on every change.

## Reaching the network

A plugin never opens a connection itself. It declares the hosts it
needs in the manifest, asks Mill to fetch, and Mill decides through
your guardrail rules — allow, ask you first, or deny — the same way it
decides an agent's write. Approved requests run inside Mill, confined
to the declared host on every hop (a redirect elsewhere is refused),
and the response comes back to the plugin. A host or method the
manifest does not declare is refused before any rule runs.

```json
"capabilities": ["fetch"],
"contributes": {
  "network": [
    { "host": "api.example.com" },
    { "host": "hooks.example.com", "methods": ["GET", "POST"] }
  ]
}
```

An entry without `methods` allows GET only. Responses are capped at
4 MB. A plugin whose hosts are typed by the user — a request tester,
say — declares `{ "host": "*" }`: every request to a host not
otherwise declared then asks you first, every time, and no rule can
make it silent. The Extensions row says so before the plugin runs.

```js
const res = await api.fetch('https://api.example.com/issues?open=1')
if (res.approved) console.log(res.status, res.body)   // headers in res.headers
else api.notify({ level: 'warning', text: 'Not allowed' + (res.ruleLabel ? ' (' + res.ruleLabel + ')' : '') })
```

Credentials belong in the vault, not in plugin code. A request that
needs one names a `secretRef` setting, and Mill attaches the entry's
value itself — after you approve the request — as a header:

```js
const res = await api.fetch('https://api.example.com/me', {
  secret: { settingKey: 'auth' }                 // Authorization: Bearer <value>
  // or: secret: { settingKey: 'auth', header: 'X-Api-Key', prefix: '' }
})
```

Every request that carries a secret asks you first, whatever your
other rules say: the Review row reads "GET api.example.com · uses
secret ‘Jira PAT’", and the vault's access history records the read
as sent by the extension. The value is redacted from the response
before the plugin sees it — a server echoing the token back gets
`[redacted]`.

## Opening a path in another app, and listing a folder

Two more guarded doors, both declared as capabilities:

- `open-app` — `ctx.requestGuardedAction('open-app', { app: 'Bruno', path: '/abs/folder' }, 'Open the collection in Bruno')` hands a local path to a named application through the OS's own open-with. It asks like every guarded action (Review shows the plugin's name) and, once approved, opens the app.
- `list-files` — `api.files.list('/abs/folder')` returns the folder's direct children (`{ name, path, isDir, size }`), hidden entries and dependency folders left out. It is a read: allowed unless one of your rules denies or parks it, and audited either way; `entries` is empty when it was not approved.

The Bruno collection example uses both: its face lists the collection's requests and offers "Open in Bruno".

## Writing to the board

A plugin creates notes and cards, updates cards, and adds rows to a
List through the same guarded door an agent uses: each write goes to
your guardrail rules — allow, ask you first in Review, or deny — with
the plugin named as the source, and lands with its own undo history.
The manifest declares the capability; without it every write is
refused before any rule runs.

```json
"capabilities": ["write-content"]
```

```js
const note = await api.content.createNote({ text: 'Call the bank\ntomorrow', parentId })
const card = await api.content.createCard({ kindId, title: 'Acme', fields: { status: 'active' } })
await api.content.updateCard(card.id, { note: 'Renewal due in March' })
await api.content.appendListRow(listId, { vendor: 'Acme', tier: 'gold' })
const list = await api.content.createList({ title: 'Vendors', columns: [{ name: 'Vendor' }, { name: 'Tier', type: 'text' }], rows: [{ Vendor: 'Acme', Tier: 'gold' }] })
if (!note.approved) api.notify({ level: 'warning', text: 'Not allowed' + (note.ruleLabel ? ' (' + note.ruleLabel + ')' : '') })
```

A note without a position lands just right of the last item in its
parent. A denied write resolves with `approved: false`; an approved
one carries the new entity's `id`. `createList` takes columns by
display name with an optional type (`text`, `number`, `integer`,
`boolean`, `date`, `datetime`; text when omitted) and first rows keyed
by column name.

## Workflow steps

A plugin can add steps to the workflow palette. Declare them in the
manifest and implement them in a `steps.js` next to `main.js`:

```json
"contributes": {
  "steps": [
    { "id": "text-case", "label": "Text case", "description": "Changes the text's case.",
      "config": [ { "key": "mode", "label": "Mode", "type": "options", "options": ["upper", "lower", "title"], "default": "upper" } ] }
  ]
}
```

```js
// steps.js -- plain script, no imports or exports
registerStep('text-case', {
  perform: function (input) {
    // input.payload: the text arriving from the previous step
    // input.config: this step's authored fields (input.config.mode)
    // input.attributes: the run's attribute values
    return input.payload.toUpperCase()          // or { payload, attributes }
  },
})
```

`steps.js` runs inside Mill's workflow engine, not in the window: a
step works in a scheduled or headless run exactly as it does from the
editor, and Try this step in the Inspector runs the same function. It
sees only its input — no network, no files, no other plugin — and a
step that runs longer than a few seconds fails the run instead of
hanging it. Config fields are text or a fixed option list. The step
appears in the palette under Transform as "<label>", and the
Extensions row lists "Adds workflow steps". The **Text case** example
(`examples/plugins/mill-textcase`) is the whole pattern in one file.

## Secret sources

A plugin can turn a store of credentials on your machine into secrets
Mill offers everywhere a secret is asked for. Declare each store in the
manifest and implement it in a `secrets.js` next to `main.js`:

```json
"capabilities": ["read-file"],
"contributes": {
  "secretSources": [
    { "id": "netrc", "label": "Netrc file",
      "path": { "kind": "file", "label": "File", "placeholder": "~/.netrc", "default": "~/.netrc" },
      "capabilities": ["list", "resolve"] }
  ]
}
```

```js
// secrets.js -- plain script, no imports or exports
registerSource('netrc', {
  list: function (ctx) {
    // ctx.path: the file the user configured; ctx.readFile() its bytes
    return namesIn(ctx.readFile())            // names only, never a value
  },
  resolve: function (ctx, key) {
    return valueFor(key, ctx.readFile())      // one value, at the moment of use
  },
})
```

`secrets.js` runs on Mill's own side, never in the window, and a source
reaches nothing but the file — or, for a `"folder"` path, the folder —
the user pointed it at. `list` returns names; `resolve` returns one
value, which Mill applies itself through the same gate and access
history every other secret passes. The value never returns to the
plugin, and nothing is copied into Mill's vault.

Add the source under **Secrets › Sources**: its label appears in the
Kind picker, and the path field renders with the label, placeholder and
default the manifest declares. A source whose extension is removed or
turned off says so in its own row instead of listing nothing. Two
optional capabilities go alongside: `"discover"` offers stores found
under a configured folder, and `"import"` reads several names at once.
The **Netrc file** example (`examples/plugins/netrc-secrets`) is the
whole pattern in one file.

## Captures

A plugin can offer a quick capture: a small face that opens in its own
floating window from the Quick Panel or the command palette, away from
the canvas, and lands what the user writes where they choose. Declare
it in the manifest and register the face:

```json
"contributes": { "captures": [ { "id": "thought", "label": "Thought", "description": "A one-line thought." } ] }
```

```js
api.registerCapture({
  id: 'thought',
  render(el, ctx) {
    // Draw the face into el. ctx.destinationId is the card the user
    // chose in the window's header ("" for the top level) -- pass it
    // as parentId to a content door, then call ctx.done().
    // ctx.cancel() closes without writing.
  },
})
```

The Quick Panel lists "New <label>…" straight off the manifest, so the
row is there before any plugin code runs; the capture window loads the
plugin and calls `render`. Writes go through the same guarded content
doors as everywhere else. Mill's own note is the first capture (the
"New note…" row); the destination is remembered per capture.

## Views

A plugin can own a work tab — the same strip a workflow editor opens
in. Declare the view in the manifest (its title is the tab's label,
and the Extensions row lists it), register how to draw it when the
plugin activates, and it appears in the command palette under that
title. The panel keeps what you drew while another tab is in front;
after Mill restarts, the tab comes back and draws again.

```json
"contributes": { "views": [{ "id": "issues", "title": "Issues" }] }
```

```js
export function activate(api) {
  api.registerView({ id: 'issues', render(el, ctx) {
    el.replaceChildren()            // plain DOM, same as renderFace
    // ... list issues here; api.query / api.fetch / api.storage all work
  } })
}
```

Your own commands can open it too: run the registry command
`view.open.<plugin id>.issues`.

## Context-menu items

A plugin can add items to the right-click menu of its own objects.
Each item acts on the object that was clicked and receives the same
context the face gets; an item can say when it applies, and Mill leaves
it out of the menu until then. Other kinds of object never show it.

```js
api.registerCanvasObject({
  kind: 'bookmark',
  menuItems: [
    { id: 'open', label: 'Open in browser',
      enabled: (ctx) => !!ctx.object.Payload.url,
      run: (ctx) => ctx.requestGuardedAction('open-url', { url: ctx.object.Payload.url }, 'Open the bookmark') },
  ],
  // ...
})
```

## The example plugins

Mill's repository ships seven working examples: **Bookmark**
(`examples/plugins/mill-bookmark`) — a web address pinned to the
board, edited in place, opened through a guarded ask, with two
declared settings — **Scribble** (`examples/plugins/mill-scribble`) —
a freehand drawing tool exercising the drag interaction, style fields,
and live preview above — and **Board index**
(`examples/plugins/mill-index`) — a live listing of the board through
`api.query` and `api.on` — and **Request tester**
(`examples/plugins/mill-request-tester`), a real tool on nothing but
the doors: a work tab, any-host guarded fetch, a storage-backed
history, and a declared setting — and **Mind map**
(`examples/plugins/mill-markmap`), a view over a note's headings that
follows the note as it changes, its rendering engine vendored as one
committed bundle (`scripts/vendor-markmap.sh`) so it never loads
anything from the network — and **Web clipper**
(`examples/plugins/mill-clipper`), which fetches a page through the
guarded network door, extracts the article with Mozilla's Readability
(vendored the same way), converts it through the SDK's convert door
(`api.convert.htmlToMarkdown`), and saves it as a note through the
guarded content door — and **Netrc file**
(`examples/plugins/netrc-secrets`), which turns the machines in a
`.netrc` file into secrets Mill can reference. Copy any folder into
your plugins folder to try it, or use it as the starting point for
your own.

## Integrating a real tool

Mill never rebuilds a tool you already use; it puts the tool's files
and its command line on the board and in workflows. The pattern, with
Bruno (an API client) as the worked example:

1. **Find the tool's own seams.** Bruno keeps a collection as a folder
   of `.bru` files with a `bruno.json`, and its CLI runs one with
   `bru run --reporter-json`. Files and a CLI are exactly what Mill
   integrates through — a file-backed object kind and a shell step.
2. **Place the artifact as an object.** The Bruno collection example
   (`examples/plugins/mill-bruno`) registers a file-backed kind over
   `bruno.json`: the face names the collection, lists its requests
   through the files door, and offers "Open in Bruno" through the
   open-app door. Editing stays in Bruno.
3. **Run it as a workflow.** The seeded "Example: Run a Bruno
   collection" runs the CLI on an execution environment, reads the JSON
   report it wrote, and lands the results as rows of the seeded "Bruno
   results" List — guarded and audited like every command Mill runs.
4. **Keep secrets in the tool's own store.** Bruno reads a `.env` at
   the collection root; point Secrets > Sources at that file
   and the keys appear in every secret picker without a copy.

Nothing here is Bruno-specific in the platform: the same four moves
fit any tool with files and a command line.

## Checking a plugin

Two commands run the same checks Mill's own examples pass:

```sh
go run ./internal/pluginconform path/to/your-plugin   # the loader's rules, ahead of time
cd frontend && npm run plugin:conform                 # activates every example against a recording host
```

The first refuses what the loader would refuse — id and folder name,
capabilities, contributions, file types the plugin route serves, a
symlink leaving the folder. The second activates each plugin against a
recording stand-in for `api` and checks that every object, view, and
setting it registers or reads is declared in its manifest.

## Writing one

A plugin's `main.js` is a plain JavaScript module — no build step —
exporting one function:

```js
export function activate(api) {
  api.registerCanvasObject({
    kind: 'my-thing',
    label: 'My thing',
    icon: '⭐',
    source: 'board-local',
    editRoute: 'inline', // or 'external-app' | 'none', or (object) => one of them
    defaultPayload: {},
    renderFace(el, ctx) {
      // Draw into el with plain DOM. ctx.object holds the data;
      // ctx.updatePayload(patch) saves changes (undo included);
      // ctx.requestGuardedAction(kind, attrs, description) asks Mill
      // to act on the plugin's behalf.
    },
  })
}
```

`el` is scaled with the board: zoom out and every pixel inside it
shrinks. Plain DOM does not care, but a rendering engine that measures
its own labels or fits a layout from screen rectangles (a mind map, a
graph layout, a text-measuring chart) lays out wrong inside a scaled
box. For those, `ctx.mountOffBoard(element, { w, h })` parks your
element off the board at exactly that size, unscaled; render there,
copy the finished drawing into `el`, then call the detach it returned.
The Mind map example does exactly this on every repaint, and Mill
detaches anything you left mounted when the object leaves the board.

## Tools: make it reachable by an agent

Anything your plugin already built can be declared as a tool, and an
agent connected over MCP can then call it. Declare it in
`manifest.json` under `contributes.tools`:

```json
"tools": [
  {
    "name": "change_text_case",
    "description": "Changes the case of text: upper, lower or title.",
    "inputSchema": {
      "type": "object",
      "properties": {
        "text": { "type": "string" },
        "mode": { "type": "string", "enum": ["upper", "lower", "title"] }
      },
      "required": ["text", "mode"]
    },
    "effect": "read",
    "run": { "kind": "step", "stepId": "text-case" }
  }
]
```

- **`name`** is `verb_noun` in lowercase. The agent sees it as
  `plugin_<your-plugin-id>_<name>`.
- **`description`** is one sentence, 200 characters or fewer. An agent
  reads every description before choosing, so keep it about what the
  tool does.
- **`inputSchema`** is your own JSON Schema, passed to the agent
  untouched.
- **`effect`** is `read` (answers straight away) or `write` (needs the
  Settings toggle and parks for the person's approval).
- **`run`** says what it runs:
  - `{"kind": "step", "stepId": "..."}` runs one of your declared
    steps. Its `text` argument is the step's input; every other
    argument names one of that step's config fields.
  - `{"kind": "command", "commandId": "..."}` runs one of your
    registered commands. A command takes no arguments, so a
    command-kind tool declares none.
  - `{"kind": "query"}` lists the board's contents, filtered by the
    optional `kind` and `parentId` arguments.

A command a tool names must also be declared, its id namespaced
`<your plugin id>.<verb>`:

```json
"commands": [{ "id": "board-index.refresh", "label": "Refresh the board index" }]
```

`api.registerCommand` still works for a command you never declared —
declaring is what makes it reachable by an agent, and Mill logs one
warning for an undeclared id.

A tool contributes nothing while your plugin is turned off, and a
write-effect tool never skips the person's approval. See
[What plugins expose to agents](../agents/plugins.md) for the agent's
side of this.

## Scheduled and background work

There is no timer or alarm API. Work that should happen on a schedule,
on an event, or while no window is open is a workflow: ship a
workflow that uses your step or object, and it runs, pauses for
approval, and shows in Activity like anything a user builds. Your
plugin can open or reference it, and a user can edit it.

The full contract — every field, every capability, and what stays
stable between versions — is in [Extending the canvas](extending-the-canvas.md),
and every type is listed in the [plugin API reference](plugin-api/index.md).

---


# Workflows and steps

Copy something from a web page and the seeded Clipboard → Markdown
workflow reads it, converts the HTML, writes the Markdown back to your
clipboard, and notifies you — one trigger, three steps, connected in
order on a canvas. That's every workflow: capture, transform, and act
steps chained after a trigger.

Runs execute the chain durably — a crash or restart resumes where it
left off, and every run is recorded step by step.

## Triggers

Every workflow starts with exactly one trigger — the event that starts
a run: Manual run, Hotkey pressed, On a schedule, Clipboard changed,
File changed, System event, Atlas card changed, or Called by another
workflow (which makes the workflow a callable child with typed inputs).

## Copy and paste steps

Select steps on the canvas and press ⌘C, then ⌘V to paste copies
where your cursor is — configuration included, connections kept when
both ends were copied. The copy is plain text on your clipboard, so
it pastes into another workflow, or another Mill. Trigger steps
don't copy; a workflow has exactly one.

## The step contract

Every step declares what it consumes and what it produces — a coarse
payload kind: text, HTML, Markdown, JSON, anything, or nothing. The
card shows it (`HTML → Markdown`), the inspector spells it out
("Takes: HTML — Produces: Markdown"), and connecting a step to one
that can't accept its output is refused at draw time with a plain
explanation. Steps that forward their input unchanged (like Notify me
or Validate with rules) say so.

Data flows two ways through a run:

- **The payload** — one running artifact the chain transforms (the
  clipboard HTML that becomes Markdown).
- **Attributes** — named, typed fields a workflow declares. Steps
  read and write them by name (an AI classification lands in an
  attribute; Branch routes on one). They're the structured half. A
  run uses your explicit value first, then what a trigger or capture
  set, then the attribute's default.

## Configuring a step

Click a step to open its inspector. It opens on **Parameters** — the
step's own setup and nothing else. **Settings** is one click away and
holds how the step behaves: whether it runs, asks or is denied, the
rules that apply to it, and its breakpoint. **Test** runs the step
alone on an input you supply, and shows the selected run's data for
it. Settings carries a count when rules apply and a mark when a
breakpoint is set; Test carries a count when a run recorded data.
Under every tab, one line states what the step takes and produces,
beside a link to the step reference.

Drag the inspector's edge to make it wider, double-click the edge to
reset it, or use the Expand button to give a long value the whole side
of the canvas; the width you choose is remembered on this device.
Fields are typed — pickers for
Configure entities, code editors with highlighting for scripts and
JSON, plain inputs for plain values. A field that names *which
external thing* to talk to (an API, a model, a list) always points at
a Configure entity rather than holding the value inline — see
[Configure entities](configure.md).

## Versions

Saving edits a draft. Publishing snapshots a version — callers and
triggers run the published version, so edits never leak into
production mid-composition.

## Seeing what a step points at

A step that references something you configured — an integration, a
list, an MCP server, an AI provider — shows two links under the
picker. **Details** opens a short summary right there: an
integration's address, method, and auth, and whether its secret is
stored; a list's columns and row count. **Open** takes you to that
entity's own editor: an integration opens as a tab beside your
workflow, everything else opens on its Configure page with the form
ready. If the entity cannot work as it stands — an integration whose
auth has no secret yet — the step says so on the field, with the same
Edit link.

---


# Guardrails and effect classes

Add a step that calls an API, and Mill won't send anything until you
say yes: the run parks, and an approval prompt shows exactly what it's
about to do. That's the guardrail model — it applies to every step
that reaches outside your machine, not just API calls.

Every step type declares an effect class — what kind of touch it has
on the world:

- **None** — pure computation, nothing outside the run.
- **Read** — reads local state (a file, a list, the clipboard).
- **Local** — changes something on this machine (writes the
  clipboard, moves a file, shows a notification).
- **External** — leaves the machine (an API call, an MCP tool).

**External is guarded by default.** A run reaching an external step
parks and asks for your approval — in the app, and with an actionable
notification when you're away. Nothing you didn't approve leaves the
machine.

## Deciding once, or by rule

Approving every run gets old for a step you trust. Configure →
Guardrails holds rules: allow or deny, scoped as narrowly as you want —
a specific workflow, a specific step, a matching condition. Rules can
be dry-run against past asks before you rely on them.

A rule that cannot evaluate counts as failed — ambiguity never
silently allows.

A condition can single out steps that use a stored secret, so approval
by hand stays reserved for the sensitive ones instead of every external
call.

## Where asks live

Parked runs appear in the workflow's Runs tab and in **Review**, the
queue for everything waiting on a person: guardrail asks, human-review
steps, and agent writes. Each ask shows what will happen and how old
the request is. Approve resumes the run; deny stops it, recorded.

Local-effect steps run without a gate — the notification saying your
markdown is ready shouldn't itself need approval. AI steps pointed at
a localhost model run ungated too; the same step pointed at a remote
endpoint is external and asks.

---


# Configure entities

Point two workflows at one Integration entry instead of pasting the
base URL and auth into each step, and changing the endpoint later is a
single edit that both workflows pick up.

Configure holds the things workflows and boards *point at* — the
values two independently-authored workflows should share, where
drifting apart would be a bug. Deleting any of them takes effect at
once and offers Undo for ten seconds; an entry a workflow still uses
refuses to delete and names the workflow. An undone Integration comes
back without its secret — enter it again.

Configure lists its kinds in a rail on the left, grouped by what
they are for: **Connections** (Integrations, MCP Servers, AI
Providers, Certificates), **Runtime** (Environments, Execution
Environments), **Data** (Lists, Attributes, Conversion profiles) and
**Workflow logic** (Decisions, Step types). Pick a kind to see its
page; type in *Filter kinds* to narrow the rail across every group.
Each kind has its own address (`#/configure/lists`, for example) and
its own command in the palette (*Configure › Lists*), so a link or a
search lands straight on it.

The entities:

- **Integration** — an HTTP API: base URL, auth (the secret lives in
  your OS keychain, never in config), operations with typed inputs
  and outputs. Choosing an auth type shows only that type's fields;
  encrypting the request body (JWE) and a fixed fallback body sit
  under Advanced. Test a saved integration from its own Testing tab.
- **Lists** — typed tabular data steps can look up, search, and
  write; Atlas can project them too. A list edits as one spreadsheet-
  style grid: click a cell to select it, click again (or press
  Enter, or just type) to edit it; Tab commits and moves right, Enter
  commits and moves down, Escape cancels. Select a range and copy or
  paste it, drag the fill handle to repeat a value, press Delete to
  clear. Drag a header's edge to resize a column, drag the header to
  reorder. The header's menu (or a right-click on it) renames the
  column, inserts one to either side, and opens its type, choices,
  deprecation, and removal; a right-click on a row inserts a row
  below it, marks it expired or active, or deletes it. A column's type
  can change until it holds data. Commits happen as you go, and ⌘Z
  takes the last one back — an edited cell returns to what it held, a
  deleted row comes back whole, and ⇧⌘Z re-applies it. Bring data in from a CSV or JSON file: "Import rows…" on a
  list maps file columns to its fields, and "New from file…" on the
  Lists page proposes a whole typed schema from your sample — edit
  the proposed names and types, then create the list with every row
  in one step. A list can also mirror an outside source, one way: the
  "Sync rows into a list" step turns a request's JSON result into rows
  matched by a key column, on whatever schedule its workflow runs —
  "Example: Jira issues → List" shows the shape, and Mill never writes
  back to the source. Deleting a list is immediate and permanent, so
  export first if you might want it back.
- **MCP Servers** — other MCP servers Mill can call as workflow
  steps. (Connecting an agent *to* Mill is the other direction — see
  Settings → MCP access.)
- **AI Providers** — a local or remote model endpoint the AI steps
  resolve by reference. Availability evidence is machine-local and
  specific to the saved endpoint, model, secret source, and adapter
  version. A guarded check may wait for approval and can be cancelled;
  it reads provider model metadata without sending a completion. A
  reachable metadata endpoint or listed model does not prove that text,
  structured output, or classification works, so those operations stay
  Unknown until an exact operation test supplies evidence. Cached
  evidence becomes stale when the provider or its secret source changes.
  Mill refuses changes to a connection's protocol, address, model, or
  secret reference, and its deletion or recreation, while an unfinished
  run still depends on it. The same check applies to imports, resets,
  restores, undo, and redo. Finish or stop the named runs first; a stopped
  run's body may still be finishing before edits unlock. A label-only edit
  remains available, as does repairing the value behind the same secret
  reference. When Mill
  cannot read the unfinished-run history or confirm exclusive access to its
  execution database, it refuses the connection change instead of guessing.
  Authored workflow references remain separate from the unfinished-run
  evidence because they answer different questions.
- **Execution environments** — a pinned shell, directory, and
  environment for Run a command. This is reproducibility, not a
  sandbox: the script runs with your full user account. Clean profile
  mode sources no shell startup files, so a step sees only the
  variables you declare; Login mode sources your shell's login files
  as well. A step's own Working directory field overrides the
  environment's directory for that run, with a value from a captured
  Attribute.
- **Conversion profiles** — which source-specific rules an HTML to
  Markdown conversion applies (Confluence, Office and Word). The
  converter step picks one; leave it empty and every rule applies.
  The page's sample preview shows what each profile makes of a paste.
- **Attributes** — a workflow's declared typed fields.
- **Decisions** — named outcome sets a workflow records against,
  with published versions.
- **Step types** — your own palette entries: a named, curated binding
  over an existing engine (an API operation, an MCP tool, a callable
  workflow) with the sharp edges pinned away.

The dividing rule: a value that names **which external thing** to
talk to is a Configure entity, picked by reference in the step. A
value that is one workflow's **own decision** — a threshold, a
category list, literal text — stays in the step where you see it.

Everything here rides Settings → Backups' snapshot, export, and
import — except secrets, which stay in the keychain and are
deliberately never exported.

---


# Selecting and bulk actions

Every list in Mill — Configure's entities, Workflows, Secrets — shares
one way to work on several rows at once, the same shape any email
inbox or file browser's own multi-select already uses.

## Turning a row into a selection

Hover a row and a checkbox appears at its start; click it to select
that row without opening it. Shift-click a row to select every row
between it and the last one you touched. ⌘/Ctrl-click a row toggles it
in or out without opening it either. A plain click, with no modifier,
still opens the row — selecting never gets in the way of browsing.

On a phone or a narrow window, press and hold a row instead: holding it
still for about half a second selects it, the same way a touch list
elsewhere on your phone works.

## The selection bar

Selecting anything replaces the toolbar with a bar naming how many rows
are selected, the actions available for them, and a way to cancel.
Selecting more rows updates the count live; when only some of the rows
on screen are selected, the bar offers to select every row currently
matching your search and filters, not just the visible page.

Press Escape, or the bar's close button, to clear the selection and
bring the toolbar back.

## Keyboard

With a list in view, ⌘A (Ctrl+A on Windows/Linux) selects every row
currently shown; Escape clears the selection; Delete removes it.

## Deleting several at once

Delete acts on every selected row, even when one of them turns out to
be in use elsewhere and can't be removed — Mill deletes everything it
can and names whichever rows it kept, rather than stopping at the
first one it can't touch. Where the delete can be undone, one Undo (the
toast's button, or ⌘Z) brings every deleted row back at once, not one
at a time.

---


# Atlas

Drop a folder of markdown files onto the board and every file becomes a
card — edit one outside Mill and the card updates itself, no re-import.
That's Atlas: one zoomable map of cards, typed by kinds you define,
connected by links, grouped into areas you can drill into.

## The board's building blocks

- **Cards** carry a kind (Topic, Contact, Document — or your own,
  authored with typed fields), a title, notes, and typed field
  values. Mark a field "Show on card" in the kind editor and its
  value appears right on the card's face — choice fields as their
  colored pills, the way the seeded Topic shows its status. A field
  can also reference another card of a kind you pick: the page
  offers only matching cards, and a set reference draws a dashed,
  labeled line between the two on the board. Drop a markdown file on
  the board and it lands as a card mirroring the file; mirrored
  markdown renders in the card — including ` ```mermaid ` fences as
  live diagrams. Drop a `.drawio` or `.mmd` file and it renders the
  same way, updating automatically whenever you edit that file outside
  Mill — a missing file shows a clear notice with a button to choose
  another one. A `.xml` file that holds a draw.io diagram (an
  exported one, say) lands as a diagram too — Mill reads the file to
  tell it apart from ordinary XML. Click anywhere on a diagram to select it and get its
  resize handles. A multi-page draw.io file shows page arrows when you
  hover it, so you can flip through its pages right on the board.
  General and flowchart shapes render with their real
  outlines; less common shape libraries still show as plain boxes.
  Drop an `.xlsx` or `.csv` file and it shows a preview of its first
  sheet, updating automatically whenever the file changes. For a CSV,
  double-click a cell to change it right on the board — Enter saves to
  the file, Escape cancels. Excel files stay read-only here — open
  them in your spreadsheet app to edit.
- **Links** connect cards through link kinds you define. Drag from a
  card's link handle and release anywhere on a highlighted card; one
  relationship per pair and kind — repeats never duplicate. Hover a
  link for its actions.
- **Areas** group cards; drill in to work at a level, breadcrumb back
  out. Drag a card onto an area to file it; drag it back out — from
  the area's preview or from inside — and it moves up a level, or
  into whichever area you drop it on. Perspectives save named views
  of the map.
- **Notes are markdown.** Write headings, lists, bold, tables — in
  a card's note or a board sticky note (press N and click). While
  you type, formatting appears in place and the markdown syntax
  fades on every line except the one you're editing; select some
  text and a small toolbar floats beside it with bold, italic,
  strikethrough, and code; type `[]` or `[x]` at the start of a line
  for a to-do, and Enter continues the list unchecked; at rest the
  note shows the rendered result, and clicking it brings the source
  back. With Rich code blocks turned on (Extensions →
  Note), press Shift-Option-F inside a code block to format it —
  JSON, JavaScript, TypeScript, CSS, HTML, YAML, and Markdown. A long sticky note scrolls in place, and grows while you
  edit it — and any note opens big: ⌘-click it (or right-click →
  Open note) for a full-size editor, like opening a note in its own
  window. Notes save when you click away; with Settings → General →
  Save changes set to "When I choose", they wait for ⌘S instead and
  show a dot until then.

## Contents

Everything on the board, listed by kind: cards, notes by their first
line, and every other object. Open it from the toolbar's list button
or the command palette ("Contents"), type to filter by name, and
activate a row to jump there — a card opens, a note or object is
brought into view.

## Drawing and images

- **Images and ink live on the board, not inside a card.** Take a
  screenshot to the clipboard and press ⌘V on the board — the image
  lands at your pointer, at its own size. Drag the little screenshot
  preview straight onto the board and it lands the same way, and so
  does an image dragged out of a browser page. Copy a file in Finder
  and paste it, and it lands exactly the way dropping it would: an
  image as an image, a diagram as a diagram, a document as a card.
  The Image button in the toolbar offers a file picker and a paste
  zone too, and pasting an image file's path as text also works.
  Dropping an image file onto the board does the same. Pick Pencil and drag to draw; lift and draw
  again for the next stroke, no interruption. Either one is a thing in
  space you can move, select, and delete, and ink stays visually on
  top of an image so you can mark one up. Nothing becomes a card until
  you ask: right-click either one and choose "Promote to card…" to
  give it a title and a kind. The pointer always shows which tool is
  armed, over anything on the board. Hold Space to pan the board
  without drawing, and press Escape to put a drawing tool away.
- **Shapes have their own style options.** Pick Shape in the toolbar
  and drag to draw a rectangle, ellipse, or arrow; while it's armed, a
  small panel above the button offers the shape type, stroke colour,
  stroke width, and fill. Fill starts off (an outline only) — pick a
  colour to fill the interior instead, and a filled shape is still
  fully clickable anywhere inside it, not just along its outline. Each
  drawing tool's panel offers only the style controls that make sense
  for it, drawn from the same small set: a colour, a colour that can
  also be switched off, a numeric width shown as a line or a dot, or a
  named shape choice. Pencil's panel, for example, offers only colour
  and stroke size, since a stroke has no separate fill. A diagram's own
  colours and styling stay whatever its own file sets, not this panel.
- **Turn a rectangle or ellipse with the rotate handle.** Select one
  and a small circular grip appears above it — drag to turn the shape
  live, holding Shift to snap every 15 degrees. Press Escape mid-drag
  to cancel back to where it was. The handle only shows on a single
  selected shape, and an arrow doesn't get one since its own shape
  already comes from the direction it points.

## Copy, paste, and create

- **Copy and paste to duplicate.** Select cards or notes and press
  ⌘C, then ⌘V — copies appear where your cursor is, filed into
  whatever area it's over. A copy is just the card itself; when the
  original has items inside, the paste offers to copy those too.
  Links come along only when both ends were copied. The copy is
  ordinary text on your clipboard, so it pastes across spaces.
- **Paste anything from outside Mill — it lands as the right kind of
  thing.** A table copied from a spreadsheet or a document app becomes
  a table on the board, ready to browse and edit like any other list.
  A pasted file path lands what dropping the file would: a document
  becomes a card, a diagram, spreadsheet, PDF, JSON or YAML file its own
  board object (a PDF shows its pages right on the board — click it once
  to select it, then search, zoom, and page through it in place; while
  you scroll inside it the board holds still. A JSON or YAML file opens
  as a collapsible tree: each key on its own row, a collapsed section
  showing how many entries it holds, a box to filter keys and values,
  and a right-click on any row to copy that row's value, its path, or
  its key. Edit the file in your own editor and the tree follows),
  a folder path opens the folder import. Everything else lands as a
  sticky note at your pointer, already selected — nothing else to
  fill in. Multiple tables in one paste each land as their own table,
  offset so you can tell them apart.
- **Create by pointing.** Press C (or pick Card in the toolbar) and
  click — the card appears right there and you name it in place;
  Enter keeps the name, Escape keeps it as Untitled. Web addresses
  on a card are real links — click one to open it in your browser.

## Finding what you need

- **Jump anywhere with ⌘K.** Type a few letters and pick from every
  card and every placed object — a diagram, an image, a table —
  matched by its name. Enter flies the board to it and pulses it so
  your eye lands in the right place, even when it lives levels away.
- **Tidy the whole board with Auto-arrange.** One click packs
  everything on the current level — cards and objects alike — into
  clean rows, then leaves you in control: anything you drag
  afterwards stays where you put it.
- **Group anything into an area.** Select any two or more things —
  cards, notes, diagrams, images — and press G (or draw an area
  around them) to file them into a named area together. The area
  shows a small preview of everything inside, objects included, and
  its count includes every member.
- **Filter without losing the map.** The search control on the board
  (top right) dims everything that doesn't match your text, chosen
  kinds, or field values (the Fields menu lists every choice-type
  field on the board — pick "Status: Open" to light up just those
  cards) — matches stay crisp in place, so you keep the spatial
  context instead of watching cards vanish. Filters are a question,
  not a setting: they clear with one click and are never saved.
- **Export takes the shape you need.** The toolbar's Export control
  offers a choice: the whole map as portable JSON, ready to import
  into another Mill, or just the board you're viewing as a `.drawio`
  file. Cards become boxes, links become labelled connections, and
  areas nest exactly the way they do on the board — open the download
  in draw.io or hand it to a tool that expects that format. A shape's
  own colour, stroke, and rotation come along; a freeform arrow, a
  sketch, or an image doesn't have a faithful box to become yet, so
  it's named rather than silently left out.
- **Take a picture of what you're looking at.** Right-click a
  selection, or open the Atlas menu, for "Copy as image" and "Export as
  image…". Copy puts a sharp PNG straight on your clipboard, ready to
  paste into a document, a deck, or a chat. Export opens a small dialog
  where you pick the scale and whether the background comes along, then
  saves the file. Both picture whatever is selected; with nothing
  selected, both widen to the whole board. Selection outlines, drag
  handles and resize frames never appear in the picture. Working in a
  browser against a Mill running elsewhere, the copy lands on that
  machine's clipboard, and the confirmation says so.

## Keeping Atlas in sync

- **Sync a docs folder**: the seeded "Mirror a docs folder into
  Atlas" workflow regenerates a space from a folder of markdown,
  idempotently — a maintained docs set becomes a maintained map.
- **Track delivered work with evidence.** Point the seeded "Example:
  Delivery ledger" workflow's folder path at a folder of goal files
  with a frontmatter header (`id`, `status`, `date`, `prs`, `proof`,
  `spec_refs`) and run it — each file becomes a Delivered feature
  card carrying its goal, shipped date, PRs, and proof. Set Sign-off
  to Verified or Verified with notes once you've checked the
  evidence; Mill stamps the verified date for you. Re-running the
  workflow after a file changes updates only the goal/date/PRs/proof
  — your sign-off, verified date, and notes always stay put. The area
  you file them under shows how many are signed off, right on its own
  face — "12 of 40 done," alongside the card count.

## Cards that act, and undo

- **Cards can act.** A card can carry attached action workflows —
  run them from the card. Workflows can also read and write cards as
  steps (create, update, find, link), and a trigger can fire on card
  changes — the board and the automation layer are one system.
- **Undo almost anything.** ⌘Z undoes your last change on the
  board — drawing a stroke, moving or resizing a card, deleting
  something, pasting a table, typing in a table's cell — and ⇧⌘Z
  brings it back. Board edits and table edits share one history, in
  the order you made them: ⌘Z right after typing in a cell restores
  the cell and leaves the table where it is. Deleting also shows a
  brief Undo button; either one restores it. A change someone else
  makes at the same time is never something your own ⌘Z can undo.

## Tables and the AI companion

- **Tables are projections.** Start one from nothing with "New
  table" (the toolbar at the bottom of the board, next to Card,
  Note, and Area): sweep the size grid to the shape you want, then
  move the pointer over the board — a dashed outline carrying the
  new table's name follows it, so you see where the table lands
  before you commit. Click to place it there (inside an area if
  that's where you point), backing List and name together. Escape
  cancels an armed size. Or pick "Table from a List" to project a
  List you already have. Either way the List stays the single source
  of truth, so the table is never stale and every board showing it
  agrees.
- **Name a table on the board.** Every table carries its name in a
  row above its grid. Double-click the name to rename it in place,
  or right-click the table and pick Rename. Enter keeps the new
  name, Escape leaves the old one, and a blank name changes nothing.
  The name belongs to the backing List, so it changes everywhere
  that List is shown.
- **Click once to pick a table up, again to edit it.** The first
  click on a table selects the whole object: drag its band to move
  it, drag a corner while it is selected to give it more room — the
  size sticks. Once it is selected, clicks reach the cells: click a
  cell to change it, click a column header to rename it, and hover a
  header or row for the ⊕ that inserts a column or row exactly
  there. Escape leaves the grid with the table still selected, so
  Delete removes the whole table. Workflows write the same List
  through their own guarded steps.
- **Ask the AI companion.** Click the sparkle icon in the toolbar to
  open a chat panel beside the board. Pick a configured AI provider,
  then ask about or describe what you want organized — the reply
  streams in, and when it proposes cards or a scratchpad note you
  review and accept before anything is created; collisions with
  existing cards start unchecked so nothing gets overwritten. Keep
  talking to refine a proposal instead of accepting it. Closing the
  panel clears the conversation.
- **Recognized sources.** A card whose Source URL matches one of your
  configured integrations shows that integration's name beside the
  link, and any workflow declaring "Offer on cards from" that
  integration appears on the card as a ready action — running it
  attaches it. Every action run receives the card's Source URL and
  field values, so a "refresh this page" workflow knows its target.

## Other views

Matrix and Coverage views project the same data as grids when a board
is the wrong shape for the question. Roadmap lays a space's cards out
as swimlanes — one row per card type, one column per Now/Next/Then
tag plus an Unscheduled column for anything untagged — so you can see
at a glance what's planned and what still needs a tag. An empty
roadmap still shows the full column layout, and each Now/Next/Then
column has its own "Place cards" button that opens a picker of the
space's other cards — pick one and it lands in that column, and if
its type doesn't have a Horizon field yet, Mill adds one automatically
and says so with a quiet toast. Drag a card's chip between columns to
retag it, or onto Unscheduled to clear the tag.

---


# Runs, review, and debugging

Mill crashes or restarts mid-workflow, and the run picks up where it
left off instead of vanishing. Open the workflow's **Runs** tab
afterward and every run is there step by step — inputs, outputs,
timing, and exactly where it stopped.

When a step fails after producing output, Mill keeps that output beside
the error. **Run from clipboard** shows the failed result and lets you
copy it manually with **Copy result**.

## When a run needs a person

**Review** is the one queue for everything waiting on you:

- **Guardrail asks** — an external-effect step parked for approval.
- **Human review steps** — a workflow deliberately pauses for your
  verdict, optionally collecting typed input that flows back into
  the run.
- **Agent writes** — changes an AI agent proposed over MCP, held
  until you approve.
- **Vault waits** — a step needed a stored secret while the vault was
  locked; the run waits here until you unlock the vault, then continues
  from that step.

Each entry shows exactly what will happen and how old the ask is.
When you're away, Mill escalates: an actionable notification, a dock
badge, and a floating approval prompt.

## When Mill relaunches during a paused run

A run paused for your approval survives an ordinary restart: Mill picks
it back up and it waits for you again, with its 24-hour window
restarting from the relaunch. Right after a restart there is a brief
moment where the run is still being picked back up — answering then
tells you to try again in a moment. An update that changes how workflows
run can't resume a paused run safely, so Mill stops it instead and the
run reads **Interrupted**: nobody answered, and nothing was applied. Run
it again when you're ready.

## Debugging a workflow

- **Test runs** from the editor execute the draft without counting
  as production activity.
- **Breakpoints** pause a run before a step; edit values, then
  resume — or run in step mode and walk the chain one step at a
  time.
- **New note…** in the Quick Panel opens a small note window away from
  the canvas; pick where it lands (the Scratchpad, a space, or the top
  level) and ⌘↩ saves it there. Plugins can add their own captures the
  same way.
- **Try this step** on any step runs just that step on an input you
  type, paste from the clipboard, or take from its last run — and
  shows the output right there, without running the workflow. A step
  that would need your approval tells you so instead of running.
- **Activity** shows trigger fires and run outcomes across all
  workflows, so a scheduled or watching workflow is never invisibly
  running. Its MCP calls section logs every call to or from Mill's
  agent connection — who called what, when, and what happened.

## The menu bar shows what Mill is doing

Mill's menu-bar icon is a live status surface, not just a launcher.
The icon itself means Mill is running; a count beside it means
something is waiting on you. Clicking it opens a small panel:

- **Needs you** — approvals, agent writes, and plugin asks waiting
  for a decision. Click a row to land on Review.
- **Running now** — what's executing, each with a Stop button.
- **Recent** — the last few runs that finished, with Done, Failed or
  Stopped and how long ago. Click one to see which steps ran, in the
  same small floating window.
- **Quit Mill…** — quitting tells you first what stops: your
  schedules, hotkeys and watchers pause until Mill runs again.

Right-clicking the icon keeps the plain menu: Open Mill, Quit.

## Run from the Quick Panel

Summon the Quick Panel with your hotkey, type a workflow's name, and
the footer shows what the highlighted row can do. Drag the panel by
its top edge; it stays where you leave it. To put it back in the
middle of the screen, search "Reset Quick Panel position" in the
command palette (⌘K).

- **↩** runs it. The panel stays open and the footer tells you the
  outcome: done and how long it took, failed and why, or waiting for
  your approval. Press Escape when you're done reading.
- **⌘↩** opens the workflow in Mill, read-only, with its Run and
  step-by-step controls. Right after a run, this opens that run, so
  you see which steps ran and where it stopped.
- **⌘⇧↩** runs the workflow and opens a small floating window with
  its canvas, so you watch the steps light up without leaving what
  you were doing. Close it when you're done, or press Open in Mill to
  continue in the full app on that run.
- **⌘⇧P** pins or unpins it.
- **⌘K** lists all of these with their shortcuts.

Anything you type that isn't a workflow's name can be kept: **Save as
note** files it into the Scratchpad card on the board, **Save as
task** adds it as a row to the Engagement tasks list, scheduled
for today. Both close the panel the moment they land.

The same panel is the fastest way to update Mill: type "update" and
press ↩ to check. The footer tells you whether you're up to date, or
names the next step — download and install, then restart to finish —
and typing "update" or "relaunch" again finds that step. A restart, or
quitting from the menu bar, first saves whatever you were still
typing: a note mid-edit, a cell being edited, a workflow draft.

---


# The coding loop

Copy a shell command, hit the hotkey, and Mill shows you exactly what
it parsed before anything runs.

## Copy a command, hit the hotkey

Say a tool hands you a command to debug something — a `curl` to test
a connection, a couple of setup lines to try. Copy it, then run
**Run from clipboard…** from the command palette (⌘K) or Quick
Panel — the same panel a global hotkey opens from any app, so this
works even when Mill isn't in front.

## Confirm before anything runs

Mill parses the copied block into its real structure first:

- A **piped command** (`a | b`) stays one step.
- Commands on separate **lines** each show as their own step, and
  run regardless of what came before.
- Commands joined by **`&&`** also show as separate steps, but a
  later one is skipped if an earlier one fails — matching what `&&`
  already means.

The confirm screen shows every step, the shell and folder it'll run
in, and whether a step needs your approval. A step that looks like
it has a placeholder for a secret — `<YOUR_TOKEN>` and similar — is
flagged so you know it'll run exactly as copied, nothing filled in
for you. Nothing runs until you click **Run**.

Some shell commands are common enough that Mill recognizes them: a
read-only check like `curl -I` or `ls` shows **Allowed by** its rule
and never needs a click. A command that looks destructive — `rm -rf`,
piping a download straight into a shell — shows **Blocked by** its
rule instead, so you still have to approve it before it runs. You can
add your own patterns to either list in Review's Rules tab.

## Watch it run

Once you confirm, each step shows live: waiting, running, done,
failed, or skipped, with the running step's own output as it
happens. If a step goes quiet, Mill tells you it's stuck instead of
leaving you guessing. Cancel stops it from there.

Closing the window doesn't stop the run — it keeps going, and you
can find it again in **Activity**.

## Copy the result back

When it finishes, every step's output is right there, with **Copy
result** ready for pasting back into wherever the command came from.
The run itself is saved too, so you can find it again later.

---


# Secrets are references

Every field in Mill that needs a password, a token, or a key takes a
pick from Secrets, never a typed value — a workflow carries the name
of a secret, and Mill fills in the value only at the moment a step
runs.

## The vault

Secrets you add by hand live in one encrypted vault file on this
device. Its key sits in your login keychain, tied to that specific
file: a second vault, or one restored from a backup, gets its own key.
Turn on the unlock requirement and Mill asks for Touch ID or your
password before the vault opens.

## References, not values

An Integration's auth, an MCP server's token, an AI provider's API key,
an Environment's secret variables, a client certificate's passphrase —
each field is a picker over Secrets. Pick an entry and the field
stores its name. Exporting a workflow exports the reference, so the
value never travels with it; importing on another machine asks you to
point the reference at that machine's own entry.

Guardrails decide which steps may read each secret, and every read
lands in the entry's access history — who used it, in which run, when.

## Sources

A secret you already keep elsewhere needs no copy. Under
**Secrets › Sources**, Mill reads entries from your shell environment,
a `.env` file, or a password manager's CLI, and lists their keys in
every secret picker beside the vault's own entries. Mill reads the
value when something uses it and never stores it. An extension can add
a source of its own the same way, and never sees another source's
values.

A `.env` or Bruno source is watched: edit the file and every open
picker and the Sources list update on their own, no reload. If a
picked key disappears from the file, the field says so — the pick
still names it, so putting the key back resolves it again — and
starting a run that needs it refuses before anything runs, naming the
missing key.

## Trash

Deleting a vault entry moves it to Trash instead of destroying it. A
trashed entry can only be restored or deleted forever — it can't be
revealed, copied, edited, or picked by a field. A reference that still
names a trashed entry says so, distinctly from one that names an entry
that no longer exists at all, and a run that would need it refuses
before anything runs. Trash empties itself automatically 30 days after
each entry lands there.

## The lock policy

**Settings › Security** decides when the vault closes itself: after a
chosen idle time, when this Mac sleeps or the screen locks, when you
switch users, or when Mill's window is minimized. The Secrets page
states the whole policy in one line — what it takes to unlock, and how
long it stays open. Lock and unlock from the command palette too;
search "vault".

## Backups carry the vault

Every automatic backup and every full export includes the vault file.
Restoring one on the same device reopens it with the key already in
your keychain; on another device, Mill offers to start a new vault and
keeps the restored file beside it, so nothing is lost.

To put a value in the vault and use it from a step, follow
[Store and reference a secret](../how-to/store-and-reference-a-secret.md).

---


# Extensions and trust

An extension adds what Mill can do — a board object, a workflow step, a
command, a secret source, a view — as a folder of two files you
install, and it runs only with your say-so.

## The store

**Extensions** is its own page (⇧⌘X). **Installed** lists what you
have and what each one adds. **Browse** lists what your marketplaces
offer that you have not installed. It distinguishes loading, source
failure, installed matches, filters with no results, and a genuinely
empty catalog. **Updates** shows what has a newer version.

Installing shows what the extension can do — the hosts it reaches,
whether it writes to your boards, what it adds — and installs only
after you confirm. A newly installed extension waits under Installed
until you allow it to run.

## Needs review

An extension that just arrived, that changed on disk, or that now asks
for more than you allowed carries a **Needs review** label on its row,
grouped together at the top of Installed. A banner names how many are
waiting and takes you straight to the one, or the group, that needs
you. Its Allow (or Allow again) button is right there in its detail;
Remove is beside it when the extension already ran before. Nothing
clears until you decide.

## Sources

A marketplace is any repository or folder with a `.mill/marketplace.json`
file, listing the extensions it offers. Add one under **Sources**;
Mill reads it only when you add it, refresh it, install from it, or
check for updates — never on its own. You can also install straight
from a repository, a `.zip` address, or a folder on this Mac.

Each source keeps its own identity, canonical location, cached catalog,
and refresh status. A failed refresh leaves the last successful catalog
available and shows the failure. Removing a source leaves extensions
already installed from it in place.

## Tiers

Every installed extension wears one badge, and it says exactly what was
checked:

- **Verified** — its files match the hash the marketplace published,
  and a key this Mill trusts signed them.
- **Hash-pinned** — its files match the hash the marketplace published.
- **Unverified** — nothing checked these files; Mill asks you to
  acknowledge that before installing.
- **Dev** — you installed it from a folder on this Mac.

An extension that changes on disk after you allowed it loses its badge
until you allow it again.

## What an extension can reach

An extension declares up front what it contributes and what it may ask
for; the list you confirm at install is the list it gets. It renders
in its own frame with the theme Mill hands it, reaches the network
only through hosts it declared, writes to boards only through the same
guarded path an agent's write takes, and never receives another
extension's secrets. It cannot register hotkeys, update itself, or
phone home.

To install one, follow [Install a plugin](../reference/install-a-plugin.md).
To write one, start at [Extending the canvas](../reference/extending-the-canvas.md).

---


# Trust, data, and safety

Turn off your network and Mill keeps working — nothing here calls home
on its own.

**No phone-home, ever.** Mill makes no network call you didn't
configure: no telemetry, no analytics, no AI API calls of its own.
The updater talks to the releases page only when you check or a
channel you chose is enabled; workflow steps talk only to endpoints
you configured.

**Your data is local files.** Settings and entities live in a JSON
store; run history and the extension source catalog use separate SQLite
files on your machine. Settings →
Backups snapshots them on a schedule, exports everything to one
file, and imports it on another machine. Passwords and keys live in
their own encrypted vault file on your device. Every local backup
carries a copy of that file too — never its key, which stays in your
OS keychain — but "Export everything" leaves it out, since that
archive is the one meant to move to another machine or another
person. A portable export includes a validated extension source snapshot,
but Import everything reports it without applying it over the live source
catalog. The vault's key sits in your OS keychain, stored against that
specific vault file — a second vault, or a vault restored from a
backup, gets its own key rather than replacing the first one's.

Mill gives one running process ownership of each local settings file
and run-history database before it opens either file. Opening the app a
second time restores and focuses the copy that is already running. If
you start a server or source-built copy against files another Mill
process owns, it stops before changing them and tells you to close that
instance or choose different data paths. The small lock files beside
the data stay on disk after Mill exits; their presence is harmless, and
Mill never treats an old file by itself as a running process.

Changing an AI connection while a workflow is unfinished also depends on
exclusive access to the run-history database. Mill uses that database to prove
which saved connection revision an active run can still call. If ownership
cannot be established, Mill keeps the connection locked instead of assuming it
is unused. Close the other Mill process that owns the same data and retry after
its runs have stopped. This database check says nothing about where an AI model
runs: a configured provider address may forward a request to another machine,
so Mill reports the execution location as unknown.

Turn on the unlock requirement and Mill asks before the vault opens.
The checkbox names what this Mac can actually offer — Touch ID, an
Apple Watch, your Mac password — rather than promising hardware you
may not have. Be clear about what that buys you: it proves who is at
the keyboard, and it does not stop another program running as you from
reading the key out of the keychain. Lock and unlock the vault from the
command palette (⌘K) too — search "vault" and only the action that
currently applies shows up.

Settings > Security decides when the vault closes itself. Choose how long
it may sit idle, from one minute to eight hours, a custom number of
minutes, or never; the count is time since you last used this Mac, so
working in another app keeps the vault open. Three checkboxes close it
regardless of idle time: when this Mac sleeps or the screen locks, when
you switch users, and when Mill's window is minimized. The first two
start on. The Secrets page states both halves in one line: what it
takes to unlock, and how long it stays open. A workflow run that needs
a secret while the vault is locked doesn't fail: it waits in Review
until you unlock the vault, then continues from the step that stopped.

If Mill can't open your vault file — the key for it isn't on this
device, or the key it has doesn't fit — the locked screen says which,
and offers to start a new vault. That keeps the current file beside it
as a dated backup and creates an empty one; the backup's entries stay
unreadable until the key that opens them turns up.

Secrets you already keep elsewhere need no copy. Secrets > Sources
points Mill at a dotenv file — a project's `.env` — or at a
Bruno collection, whose root `.env` supplies values and whose
environments name the secrets it expects — or at 1Password or
Bitwarden through their own command-line tools, which Mill asks for one
value at the moment a step runs, the way those tools' scripting docs
recommend. An extension can add stores of its own the same way: it
declares the store it reads, you point a source at the file or folder,
and Mill applies whatever comes back through the same gate — the
extension never receives another source's value, and never runs in the
window. The keys appear in every secret picker beside the vault's
entries, by name only, and every read is in your access history. The
value is read from the file at the moment a step runs, recorded in the
same access history as a vault read, and never stored by Mill.

**Anything that needs a secret names one; it never holds one.** An
integration's token, an HMAC signing key, an OAuth 1.0a consumer and
token secret, a JOSE key pair, an AI endpoint's API key — each field
picks an entry from Secrets rather than taking a typed value. That is
what puts one set of controls in front of every credential: the unlock
requirement, the access history, and the guardrails that can see which
secrets a step is about to use. Exporting one of these carries the
name of the entry, never the credential.

Each entry says what it holds — text, a key, a certificate or a file —
so a field only offers the entries it can actually use. An entry can
also point at a key in one of your sources instead of holding a value,
in which case Mill reads that key when something uses it.

An entry is the whole record, not five fixed boxes. Add fields of your
own — a serial number, a recovery code, an account id — and hide any
field whose value should stay masked until you ask for it. Add tags and
the list finds an entry by them; clicking a tag on a row narrows the
list to everything carrying it. A search matches titles, tags and field
names, never a value. Every entry says where it came from: added by
hand, from a source, or imported from a file.

Two ways to bring in what you already have. **Secrets > Sources > Find
.env files** scans one folder you choose — never your whole home
directory, never more than a few levels down, and never inside
dependency or build folders — lists the dotenv files it found with how
many keys each holds, and adds the ones you tick as sources or imports
their keys as entries. **Secrets > Import** reads a password export you
made yourself from the tool you already use, shows how many entries it
holds before anything is stored, and offers to delete the file straight
after, because an export holds every password in plain text. Mill never
reads another application's own credential store — you export, Mill
reads what you exported.

The first time you unlock after updating, any credential an earlier
version had saved outside the vault moves in: Mill creates an entry,
reads it back to check it arrived intact, points the integration at it,
and only then removes the old copy. If the check fails, the old copy
stays exactly where it was and Mill tries again next time. After that
the only Mill item left in your operating system's keychain is the
vault's own key.

**Clipboard history is opt-in and screened.** Turning on the
Clipboard history workflow is the only way it starts watching, and
turning it off stops watching immediately. Anything a password manager
or similar app marks confidential is never recorded, and any known
secret value is scrubbed before an entry is ever saved. Every entry you
copy back leaves a line in your own access history.

**External effects ask first.** The guardrail model
([Guardrails](../concepts/guardrails.md)) parks any step that leaves
the machine until you approve it or a rule you wrote allows it.
Agents get no shortcut around this.

**Plugins run only with your say-so.** A plugin you install waits in
Settings > Extensions, showing what it can request and reach, until
you allow it; Mill fingerprints its files at that moment and stops it
if they change; an administrator can pin the allowed set and require
signatures in the settings file; and Export plugin audit files what
every plugin asked for and read
([Install a plugin](../reference/install-a-plugin.md)).

**Execution environments are not a sandbox.** Run a command executes
with your full user account — the pinned shell, directory, and
environment give reproducibility, not confinement. Anything the
script can do, you can do; treat scripts accordingly.

**Updates verify before touching anything.** A downloaded update is
checked against its published digest and refused on mismatch, and a
backup snapshot is taken before any install. A copy built from
source never self-updates.

**Beta builds carry one stable signing identity.** Every beta is
signed with the same self-signed certificate, so permissions like
Accessibility stay granted across updates instead of asking again.
First launch still needs the Gatekeeper override
([Install](../start-here/install.md)) — the certificate isn't from
Apple. A paid Developer ID certificate and notarization, which would
remove that step entirely, are a future path. To allow-list by
certificate instead: SHA-1 fingerprint
`65:9A:26:7D:8A:23:52:36:90:39:E9:C2:45:64:F3:C7:46:A8:C6:22`,
designated requirement `identifier "com.alicoding.mill" and
certificate leaf = H"659a267d8a2352369039e9c24564f3c746a8c622"`.

**When something breaks, you get the truth.** Every failure you can
see — a crash, a failed run, a bad connector save, an update that
didn't install — shows a Copy details button that copies the exact
error plus enough context to root-cause it, instead of leaving you to
retype it from a screenshot. Every run also records what actually
happened.

---


# Step reference

Generated from the live step registry — every step's contract exactly as the canvas enforces it. Do not edit by hand; `go generate ./internal/docsgen` regenerates.

## Triggers

### Atlas card changed

Fires when a card of the chosen kind is created or updated in Atlas. A run started by this trigger never re-fires itself from a write it makes to its own source card, so a workflow that both reacts to and updates a card can't loop.

- Takes: nothing — Produces: text
- Effect: none — pure computation
- Settings:
  - **Kind** — Which Atlas card kind to watch. Fires only for cards of this kind.

### Called by another workflow

Fires only when another workflow invokes this one with its Child Workflow step, never by an outside event. A workflow starting here declares itself callable: it appears in the Child Workflow step's picker and nowhere else.

- Takes: nothing — Produces: anything
- Effect: none — pure computation

### Clipboard captured

Fires when you copy something new. It skips content marked confidential by the app you copied it from, and skips text Mill itself just wrote back to the clipboard.

- Takes: nothing — Produces: text
- Effect: none — pure computation

### Clipboard changed

Fires whenever the clipboard's content changes.

- Takes: nothing — Produces: text
- Effect: none — pure computation

### File changed

Fires when a file or folder under the configured path is added, changed, or deleted.

- Takes: nothing — Produces: text
- Effect: none — pure computation
- Settings:
  - **Path to watch** — Absolute path to a file or directory.
  - **Filename pattern (optional)** — A glob like *.md or report-*.csv. Only files whose name matches fire the trigger. Leave empty to fire on any change.

### Hotkey pressed

Fires on a global keyboard shortcut, even when Mill isn't focused. Bound via TriggerService, not a config field here.

- Takes: nothing — Produces: an empty start
- Effect: none — pure computation

### Manual run

Fires on-demand when a user clicks Run/Test. No listener process.

- Takes: nothing — Produces: an empty start
- Effect: none — pure computation

### On a schedule

Fires on a cron schedule.

- Takes: nothing — Produces: an empty start
- Effect: none — pure computation
- Settings:
  - **Cron expression** — Standard 5-field cron expression (minute hour day month weekday).

### System event

Fires when Mill's own engine emits an internal event: a run finishing, failing, or parking for approval, or a Configure entity or board object being created, referenced, dereferenced, or deleted. React to the platform itself, like forwarding a pending approval to another device or flagging a list nobody references anymore.

- Takes: nothing — Produces: JSON
- Effect: none — pure computation
- Settings:
  - **Event** — Which internal event fires this trigger. "Decision parked" fires when a guardrail ask or human-review checkpoint parks awaiting approval; the run events fire once a run reaches a terminal state; "update-available" fires when an update check finds a newer release on this install's channel. The entity/object events fire on a Configure entity or board object's own lifecycle. Pair "entity dereferenced" with a Branch step checking remaining == 0 to catch only the case where nothing references it anymore.
  - **Workflow scope** — Fire for every workflow's matching event, or scope to one specific workflow. Empty means all workflows. Has no effect on an entity/object event, which carries no source workflow.

### Webhook fired

Runs when a request arrives at Mill's webhook address.

- Takes: nothing — Produces: JSON
- Effect: none — pure computation
- Settings:
  - **Source (optional)** — Fire only for events whose source field matches this exactly, in lowercase. Leave empty to fire on any source, or on an event with no source at all.
  - **Reply within (seconds)** — How long the caller waits for a reply before Mill answers for it. Only matters when this workflow has an Answer the webhook step.

## Capture

### Inspect clipboard

Reads the clipboard's own format report, listing which flavors (HTML, plain text, images) are present and their sizes, and summarizes whether HTML and plain text are available, followed by the raw report. A diagnostic for pastes that look right but convert wrong: see directly whether HTML was actually on the clipboard.

- Takes: nothing — Produces: text
- Effect: reads local state

### Read attribute

Replaces the payload with the value of one of this workflow's declared Attributes, e.g. a callable workflow's typed input, or a value a Decision already routed on.

- Takes: nothing — Produces: text
- Effect: none — pure computation
- Settings:
  - **Attribute** — The declared Attribute key to read (Configure > Attributes, or the typed input a calling workflow bound).

### Read clipboard

Reads the clipboard's HTML. If there's no HTML flavor (many apps only put plain text), falls back to the plain-text flavor rather than failing.

- Takes: nothing — Produces: HTML
- Effect: reads local state

### Read clipboard text

Reads the clipboard's plain text only, never its HTML. Use it for ids, tokens, and anything copied as-is.

- Takes: nothing — Produces: text
- Effect: reads local state

### Read file

Reads a local file into the payload. "payload" source treats the current payload as the file path, which a File changed trigger supplies; "literal" reads a fixed path instead.

- Takes: text (optional) — Produces: anything
- Effect: reads local state
- Settings:
  - **Path source** — "payload" reads the path from the upstream payload (a filesystem-watch trigger's changed path); "literal" reads the fixed path below instead.
  - **Path** — The file path to read when "Path source" is literal. Ignored when source is payload.

## Process

### Add text

Prepends or appends configured static text to the payload, e.g. a fixed hint or instruction pasted alongside a workflow's real output.

- Takes: text (optional) — Produces: text
- Effect: none — pure computation
- Settings:
  - **Text to inject** — The literal text this step adds to the payload. Left empty, this step is a no-op.
  - **Placement** — Where the text goes relative to the existing payload.

### Ask for review

Pauses the run for a person: the item lands in the Review queue (and this workflow's Runs tab), where a reviewer can approve, deny, and fill in values for this workflow's declared Attributes. Their input flows into the resumed run. Denying (or 24 hours of silence) stops the run. A deliberate, visible checkpoint you drew into the flow: the ambient guardrail rules (Configure > Guardrails) never skip it.

- Takes: anything — Produces: its input, unchanged
- Effect: none — pure computation
- Settings:
  - **Message to the approver** — Shown alongside the approval request, so future-you knows what this checkpoint is guarding.
  - **Ask for these attributes** — Comma-separated Attribute keys the reviewer should fill in. Leave empty to ask for all of the workflow's Attributes.

### Call an API

Calls a Configure-authored integration's API and replaces the payload with the response body. The step only picks WHICH integration and binds data. Method, endpoint path, and body all live on the integration itself (Configure > Integration). Legacy steps saved with their own path/method/bodyTemplate config keep working (those keys still win when present); they're just no longer authorable here.

- Takes: anything — Produces: anything
- Effect: external — parks for approval by default
- Settings:
  - **Integration** — Which Configure-authored integration this step calls. (references an Integration)

### Call an MCP tool

Calls one tool on a configured MCP server and replaces the payload with its text result. The tool is picked from the server's live tool list, with typed-name fallback when the server can't be reached.

- Takes: anything — Produces: anything
- Effect: external — parks for approval by default
- Settings:
  - **MCP Server ID** — The ID of an MCP server configured on the Configure page. (references an MCP Server)
  - **Tool name** — The exact tool name, from that server's own tool list.
  - **Arguments (JSON)** — Optional JSON object of arguments to pass to the tool. Top-level string values of the form "attr:<name>" resolve to the named Attribute's typed value at run time (a number/boolean Attribute stays a JSON number/boolean, not stringified); every other value is sent as-is.

### Classify with AI

Sends the payload, plus an optional instruction, to a configured AI provider and asks it to pick exactly one of this step's declared categories, writing the choice into a named Attribute. Pairs with Branch to route on the result.

- Takes: text — Produces: its input, unchanged
- Effect: external — parks for approval by default
- Settings:
  - **AI provider** — Which Configure-authored AI provider (local Ollama or a BYO endpoint) this step calls.
  - **Instruction** — Optional guidance sent as the user message, followed by the current payload. Leave empty to classify the payload with no extra instruction.
  - **Categories** — One category per line. The AI picks exactly one of these.
  - **Write category to** — The declared Attribute key the chosen category is written into.

### Convert HTML to Markdown

Converts HTML into Markdown, preserving structure (headings, bold, lists).

- Takes: HTML — Produces: Markdown
- Effect: none — pure computation
- Settings:
  - **Conversion profile** — Which source-specific rules apply (Confluence, Office). Empty applies every rule set. (references a Conversion profile)

### Create run receipt

Renders this run's own recorded evidence (its steps so far, their guardrail verdicts, and which Mill build ran them) as a JSON receipt, replacing the payload. Only covers steps that ran BEFORE this one, since the run is still in flight when this step executes. Compose it with an Apply step (clipboard/file write) to hand the receipt to an external agent; there is no separate send path.

- Takes: nothing — Produces: JSON
- Effect: reads local state

### Extract HTML section

Extracts one element (by CSS selector) out of the payload's HTML, dropping everything else, e.g. a saved page's main-content region, stripping nav/header/footer chrome before converting to Markdown. Fails the step if nothing matches (fail-safe: never silently passes the whole, unfiltered document through).

- Takes: HTML — Produces: HTML
- Effect: none — pure computation
- Settings:
  - **CSS selector** — A CSS selector, optionally comma-separated (e.g. "#main-content, main, article"). The first matching element (in document order) is kept.

### Extract fields with AI

Sends a prompt plus the payload to a configured AI provider, requests a structured response, and writes each declared output field into this workflow's Attributes by the same key. Every declared field is required; a response that does not match the requested schema fails the step.

- Takes: text — Produces: its input, unchanged
- Effect: external — parks for approval by default
- Settings:
  - **AI provider** — Which Configure-authored AI provider (local Ollama or a BYO endpoint) this step calls.
  - **Prompt** — The extraction instruction sent as the user message, followed by the current payload (if any).
  - **Output fields** — The typed fields this step extracts. Each becomes an Attribute of the same key, authored via the field editor below (not raw JSON).

### Find Atlas cards

Searches a Kind's cards in Atlas against one or more match parameters (exact or fuzzy, AND'd together) and writes the result into Attributes. Match against "title" or any of the Kind's own field keys.

- Takes: nothing — Produces: its input, unchanged
- Effect: reads local state
- Settings:
  - **Kind** — Which Atlas card kind to search.
  - **Match parameters** — JSON array of match criteria, ALL must match (AND): [{"column":"title","value":"attr:leadName","matchType":"exact"},{"column":"status","value":"New","matchType":"exact"}]. value is a literal or "attr:<name>".
  - **Stop at first match** — Stops scanning after the first match.
  - **Output attribute** — Which Attributes field receives the typed search-result object.

### Generate with AI

Sends a prompt plus the payload to a configured AI provider (local Ollama, or your own OpenAI-compatible or Anthropic endpoint) and replaces the payload with the completion. The system prompt is this step's System prompt field; the user message is the Prompt followed by the payload when one exists. One call per run, never a loop.

- Takes: text (optional) — Produces: text
- Effect: external — parks for approval by default
- Settings:
  - **AI provider** — Which Configure-authored AI provider (local Ollama or a BYO endpoint) this step calls.
  - **System prompt** — Optional system-level instruction sent ahead of the user message. Leave empty for none.
  - **Prompt** — The instruction sent as the user message, followed by the current payload (if any).

### Look up list row

Looks up an Attributes value in a configured List and writes the matched entry back into Attributes.

- Takes: nothing — Produces: its input, unchanged
- Effect: reads local state
- Settings:
  - **List ID** — The ID of a list configured on the Configure page. (references a List)
  - **Input attribute** — Which Attributes field's value to look up.
  - **Output attribute** — Which Attributes field the matched value gets written to.
  - **If no match** — What to do when the input value isn't in the list.
  - **Default value** — Written to the output attribute when there's no match and "If no match" is "default".
  - **Pin to version (optional)** — Leave empty to always resolve this List's current rows. Enter a version number to pin this step to that exact published snapshot, unaffected by later row edits.

### Replay in the browser

Runs a recorded browser flow in your paired browser, signed in as you are.

- Takes: anything — Produces: JSON
- Effect: external — parks for approval by default
- Settings:
  - **Recording** — The flow exported as JSON from the browser's own recorder. Import it rather than typing it.
  - **Parameters** — Values to replace in the recording before it runs, each naming one step and one of its fields.
  - **Extract** — Text to read back out of the page, each naming a step that waits for the element to read.
  - **Timeout (seconds)** — How long the whole flow may take before the run fails.
  - **Browser** — Which paired browser runs the flow.

### Run a captured command

Runs the captured payload exactly as written, in your real login shell by default, or inside a Configure-authored execution environment (its shell, directory, and variables) when one is chosen. A piped command stays one step; commands separated by a new line or && show as separate steps. External effect: the run asks for your approval by default.

- Takes: text — Produces: text
- Effect: external — parks for approval by default
- Settings:
  - **Execution environment** — Runs the block inside a Configure-authored environment. Empty runs your real login shell. (references an Execution environment)
  - **Run with admin rights** — Runs each command with administrator rights. macOS asks you to approve every run — Touch ID when it's set up for sudo, your password otherwise.
  - **Working directory** — Overrides the environment's directory. Use {param} for a value from this run.

### Run a command

Runs one command locally, inside a configured execution environment (pinned shell, directory, and environment). External effect: the run asks for approval by default. Source "payload" runs the upstream payload as the command; "literal" runs a fixed script instead. A running command can be stopped from this workflow's Runs tab.

- Takes: text (optional) — Produces: text
- Effect: external — parks for approval by default
- Settings:
  - **Execution environment** — Which Configure-authored environment (shell, working directory, env vars) this command runs inside. (references an Execution environment)
  - **Command source** — "payload" runs the captured/upstream payload as the command; "literal" runs the script below instead.
  - **Script** — The command to run when "Command source" is literal. Ignored when source is payload.
  - **Pass input** — How a literal script receives the upstream payload: piped to stdin, or one argument per line ($1, $2, …).
  - **Timeout (seconds)** — Kills the command if it hasn't finished within this many seconds.
  - **Working directory** — Overrides the environment's directory. Use {param} for a value from this run.

### Run another workflow

Runs another of your workflows as a step and uses its result as this workflow's payload. The other workflow must start with the "Called by another workflow" trigger.

- Takes: anything — Produces: anything
- Effect: none — pure computation
- Settings:
  - **Workflow** — Which workflow to run. Only workflows whose trigger is "callable by another workflow" appear here. If the list is empty, create a workflow and drag that trigger onto its canvas first. (references a callable Workflow)
  - **Skip duplicate runs (optional)** — Leave empty to run fresh every time (the normal case). To make repeated runs with the same input reuse the first run's recorded result instead of running again, put a value here that identifies the input: a literal, or attr:<name> to use one of this workflow's attributes.
  - **Pin to version (optional)** — Leave empty to always call the child's published version. Enter a version number to pin this step to that exact snapshot, unaffected by later publishes.
  - **Store result in attribute (optional)** — Also write the child workflow's result into this workflow's named Attribute, so later steps (a Decision condition, another binding) can reference it as attr:<name>.

### Scan a folder for TODO markers

Walks a folder and lists every TODO-style marker it finds as a table: one row per hit with the file, line, marker, and the text after it. Hidden folders, node_modules, vendor and .git are skipped.

- Takes: anything — Produces: text
- Effect: changes something on this machine
- Settings:
  - **Folder** — The folder to scan. A literal path or attr:<name>.
  - **Markers** — Comma-separated words to look for, matched as whole words, case-sensitive.
  - **File types** — Comma-separated extensions to include, e.g. go,ts,md. Blank scans every text file.
  - **File limit** — Stops after this many files so a huge folder never runs away.

### Search list rows

Searches a Configure-authored List's rows against one or more match parameters (exact or fuzzy, per-column, AND'd together) and writes the result into Attributes. Supersedes list-lookup for anything beyond a single exact key match. list-lookup keeps working unchanged for existing workflows. Expired rows are excluded by default; "Include expired rows" opts in.

- Takes: nothing — Produces: its input, unchanged
- Effect: reads local state
- Settings:
  - **List** — The Configure-authored List to search. (references a List)
  - **Match parameters** — JSON array of match criteria, ALL must match (AND): [{"column":"code","value":"attr:code","matchType":"exact"},{"column":"name","value":"Untied States","matchType":"fuzzy","threshold":0.7}]. value is a literal or "attr:<name>". Authored via the Inspector's match-parameter rows; the raw JSON stays available for agent authoring.
  - **Include expired rows** — Off by default. Expired rows never match unless explicitly included.
  - **Stop at first match** — Stops scanning after the first match. The output shape stays the same typed Object either way (results just has at most one entry), so turning this on or off never changes what a downstream Decision/binding can reference.
  - **Output attribute** — Which Attributes field receives the typed search-result object.
  - **Pin to version (optional)** — Leave empty to always resolve this List's current rows. Enter a version number to pin this step to that exact published snapshot, unaffected by later row edits.

### Transform text

Hashes or encodes the payload: SHA-256, base64, URL encoding and more. Hashes are one-way; decoding applies to base64, URL, and hex.

- Takes: text or HTML — Produces: text
- Effect: none — pure computation
- Settings:
  - **Operation** — What to do with the text.

### Validate with rules

Validates the data flowing through this step against a set of named rules (business/data validation, e.g. "amount below limit", "country allowed"). Every rule must pass for the payload to continue unchanged; any failing rule fails the run, naming exactly which rules failed. A rule that cannot evaluate counts as failed (fail-safe). Distinct from Decision (which routes) and from guardrail rules (which govern whether a step may execute at all).

- Takes: anything — Produces: its input, unchanged
- Effect: none — pure computation

## Act

### Answer the webhook

Sends this run's reply to the tool that fired the webhook.

- Takes: nothing — Produces: its input, unchanged
- Effect: changes something on this machine
- Settings:
  - **Status code** — The HTTP status code the caller receives.
  - **Reply body** — Usually JSON in the calling tool's own schema.
  - **Content type** — The reply's Content-Type header.

### Back up Mill data

Takes a safe snapshot of your workflow history and settings, deleting older snapshots beyond how many you keep.

- Takes: anything — Produces: its input, unchanged
- Effect: changes something on this machine
- Settings:
  - **Snapshots to keep** — How many recent backups to keep. Older ones are deleted automatically.

### Create Atlas card

Creates a new card in Atlas of the chosen Kind. "Field values" binds the Kind's own declared fields, each a literal or attr:<name>.

- Takes: nothing — Produces: its input, unchanged
- Effect: changes something on this machine
- Settings:
  - **Kind** — Which Atlas card kind to create.
  - **Title** — The new card's title: a literal or attr:<name>.
  - **Field values** — JSON object mapping the Kind's field keys to a literal or attr:<name>, e.g. {"status":"New","owner":"attr:currentUser"}.
  - **Output attribute (optional)** — Which Attributes field receives the new card's id, so a later step can reference it (e.g. to link it or update it further).

### Create Atlas cards from reply

Creates Atlas records from an accepted clipboard reply's items: an item with a "title" becomes a card of its named kind, an item with "text" becomes a Scratchpad note. "Items" binds the attribute holding the accepted items as a JSON array.

- Takes: nothing — Produces: its input, unchanged
- Effect: changes something on this machine
- Settings:
  - **Items attribute** — Which Attributes field carries the accepted reply items (a JSON array).
  - **Landing space attribute (optional)** — Which Attributes field carries the target space's card id. New cards land there instead of the board root.
  - **Output attribute (optional)** — Which Attributes field receives a summary of what was created.

### Land downloads on the board

Turns every download a browser-replay step brought back into a file-backed board object, mirror-checksummed so a file already landed before is matched, never duplicated.

- Takes: JSON — Produces: its input, unchanged
- Effect: changes something on this machine

### Link Atlas cards

Creates a typed relation between two existing Atlas cards. "From"/"To" are each a literal card id or attr:<name>.

- Takes: nothing — Produces: its input, unchanged
- Effect: changes something on this machine
- Settings:
  - **From** — The relation's source card: a literal card id, or attr:<name>.
  - **To** — The relation's target card: a literal card id, or attr:<name>.
  - **Relation** — Which kind of relation this is.
  - **Label (optional)** — An optional note describing this specific relation.

### Mirror delivery ledger from a docs folder

Mirrors every frontmattered markdown file in a folder as a Delivered feature card: a file already mirrored keeps its card and only its mirror-owned fields (goal, shipped date, PRs, proof) refresh. Your sign-off, verified date, and notes are never touched. A new file becomes a new card, pending-verify by default.

- Takes: nothing — Produces: its input, unchanged
- Effect: changes something on this machine
- Settings:
  - **Folder path** — Folder of frontmattered markdown files to mirror as ledger cards.
  - **Parent card title** — Cards land under the card with this title, created if missing. Empty uses the space root.
  - **Output attribute (optional)** — Which Attributes field receives a summary of what changed.

### Mirror docs folder into Atlas

Mirrors every markdown file in a folder as a card under one parent: a file already mirrored keeps its card (checksum refreshed), a new file becomes a new card, so re-running stays safe. The parent card is found by title, or created when missing.

- Takes: nothing — Produces: its input, unchanged
- Effect: changes something on this machine
- Settings:
  - **Folder path** — Folder whose markdown files become mirror cards.
  - **Parent card title** — Cards land under the card with this title, created if missing. Empty uses the space root.
  - **Kind** — Kind label for created cards. Empty uses your first kind.
  - **Output attribute (optional)** — Which Attributes field receives a summary of what changed.

### Move file

Moves or renames a local file to a new location.

- Takes: text (optional) — Produces: text
- Effect: changes something on this machine
- Settings:
  - **Source file** — File to move. Leave empty to use the incoming payload, or set a path or attr: value.
  - **Destination** — Where the file goes. Tokens: {filename} {name} {ext} {date:2006-01-02} {attr:key}. End with / to keep the file's name.
  - **Create missing folders** — Creates the destination's parent folders if they don't exist yet. Off fails the step instead when a folder is missing.
  - **If the destination exists** — Fail stops the step. Suffix adds " (2)", " (3)", and so on to the file name.

### Notify me

Shows a notification when the workflow reaches this step. "Title attribute" and "Body attribute" swap a fixed line for an Attributes value.

- Takes: nothing — Produces: its input, unchanged
- Effect: changes something on this machine
- Settings:
  - **Title** — The notification's first line. Use {{attribute}} to include a value.
  - **Title attribute (optional)** — Which Attributes field replaces the fixed title, when set. Prefer {{attribute}} in the text.
  - **Message** — What the notification says. Use {{attribute}} to include a value.
  - **Body attribute (optional)** — Which Attributes field replaces the fixed message, when set. Prefer {{attribute}} in the text.
  - **Send to** — Leave empty to reach every paired device.

### Save list row

Creates or updates a row in a Configure-authored List: if an existing row's "Key column" value matches, only the fields named in "Field values" change (everything else on that row is untouched); otherwise a new row is appended. "Field values" binds the List's own declared column keys, each a literal or attr:<name>.

- Takes: nothing — Produces: its input, unchanged
- Effect: changes something on this machine
- Settings:
  - **List** — The Configure-authored List to write to. (references a List)
  - **Key column** — Which column identifies a row. An existing row whose value in this column matches gets updated; no match appends a new row.
  - **Field values** — JSON object mapping the List's column keys to a literal or attr:<name>, e.g. {"task":"attr:taskName","status":"Done"}. Must include a value for the key column.
  - **Output attribute (optional)** — Which Attributes field receives the row's id.

### Save to clipboard history

Scrubs any known secret value out of the payload, then adds what's left to Clipboard history. Confidential-marked content and Mill's own clipboard writes never reach this step.

- Takes: text — Produces: its input, unchanged
- Effect: changes something on this machine

### Sync rows into a list

Turns a JSON payload's array of items into rows of a Configure-authored List, one row per item, matched by "Key column": an existing row with the same key is updated in the mapped columns, a new key appends a row, and with "Expire missing rows" on, rows whose key is absent from this result are marked expired (never deleted). One-way: nothing is written back to the source, and a later sync overwrites the mapped columns of a row edited by hand.

- Takes: JSON or text or anything — Produces: its input, unchanged
- Effect: changes something on this machine
- Settings:
  - **List** — The Configure-authored List that mirrors the source. (references a List)
  - **Items path** — Dotted path to the array of items inside the JSON payload, e.g. issues. Blank when the payload itself is the array.
  - **Key column** — The List column that identifies an item. It must be named in the field map.
  - **Field map** — JSON object mapping List column keys to a dotted path inside each item, e.g. {"key":"key","summary":"fields.summary","status":"fields.status.name"}. A value with {{path}} placeholders is a template, e.g. "https://jira.example.com/browse/{{key}}".
  - **Expire missing rows** — Mark rows whose key is absent from this result as expired.

### Update Atlas card

Writes field values onto an existing Atlas card, resolved by "Card" (a literal card id or attr:<name>, e.g. attr:cardId from a trigger-atlas-card event, or an earlier Atlas: find cards result). Only the fields named in "Field values" change; everything else on the card is untouched.

- Takes: nothing — Produces: its input, unchanged
- Effect: changes something on this machine
- Settings:
  - **Kind** — The card's own Kind. It names which field keys "Field values" may bind.
  - **Card** — Which card to update: a literal card id, or attr:<name>.
  - **Field values** — JSON object mapping the Kind's field keys to a literal or attr:<name>, e.g. {"status":"Processed"}.
  - **Output attribute (optional)** — Which Attributes field receives the updated card's id.

### Write HTML to clipboard

Writes configured HTML to the clipboard.

- Takes: nothing — Produces: HTML
- Effect: changes something on this machine
- Settings:
  - **HTML to write** — The HTML content this step puts on the clipboard.

### Write file

Writes the payload to a local file, appending to or overwriting its existing contents.

- Takes: text — Produces: its input, unchanged
- Effect: changes something on this machine
- Settings:
  - **File path** — Where to write. Use an absolute path, or start with ~ for your home folder.
  - **Mode** — Append adds to the end of the file. Overwrite replaces its entire contents.
  - **Create missing folders** — Creates the file's parent folders if they don't exist yet. Off fails the step instead when a folder is missing.
  - **Timestamp** — Append mode only: "datetime" puts a date-and-time line before each entry. Ignored in overwrite mode.

### Write text to clipboard

Writes the workflow's current payload to the clipboard as plain text.

- Takes: text — Produces: its input, unchanged
- Effect: changes something on this machine

## Flow

### Branch

Routes to one of several next steps based on a rule evaluated against this workflow's Attributes. A pure routing point. Its conditions live on its outgoing edges, not here.

- Takes: anything — Produces: its input, unchanged
- Effect: none — pure computation

## Record

### Record decision

Ends the workflow with a typed, configured outcome: an outcome category (approve/deny/manual-review/action-needed/uncategorized) plus this Decision's own typed result fields. A manual-review outcome parks the run in Review first. Approve continues to the outcome; deny or timeout stops the run. A Decision with a configured webhook fires it on completion.

- Takes: anything — Produces: nothing
- Effect: changes something on this machine
- Settings:
  - **Decision** — Which Configure-authored Decision this terminal step reaches. (references a Decision)
  - **Pin to version (optional)** — Leave empty to always resolve this Decision's current definition. Enter a version number to pin this step to that exact published snapshot, unaffected by later edits.

---


# Commands

Press ⌘K and type any action's name — "lock vault", "check for
updates", "add a card" — and it runs from wherever you are.
Abbreviations work too: "gtw" finds "Go to Workflows". Every
user-facing action in Mill is a registered command, and the palette,
the Quick Panel, keyboard shortcuts, and Settings → Keyboard shortcuts
all read from the same list, so a command registered once shows up
everywhere it applies automatically.

Each command has a stable `id`, a label shown in the palette and
Settings, an optional default keyboard binding, an optional surface
scope (some commands only apply on one view, like Atlas), and an
optional enablement rule (some commands are only available in a
matching app state — an open workflow tab, an unlocked vault). A
command with no listed binding still works from the palette and Quick
Panel; it just has no keyboard shortcut by default. "Conditional"
enablement means the command is hidden from the palette entirely,
rather than shown disabled, whenever its state doesn't currently apply.

<!-- BEGIN GENERATED: command registry (source: frontend/src/shared/commandsDeclaration.json) -->

| ID | Label | Default binding | Surface | Enablement |
|---|---|---|---|---|
| `atlas.addFile` | Add a file to the board | — | atlas | Always available |
| `atlas.addFromFolder` | Add cards from a folder | — | atlas | Always available |
| `atlas.arrange` | Auto-arrange | — | atlas | Conditional — available only in a matching state |
| `atlas.board.addCard` | Add card here | — | atlas | Acts on the board's current selection |
| `atlas.board.addNote` | Add note here | — | atlas | Acts on the board's current selection |
| `atlas.board.home` | Back to the board | — | atlas | Always available |
| `atlas.card.addLinkedCard` | Add linked card… | — | atlas | Acts on the board's current selection |
| `atlas.card.copyContext` | Copy card as context | — | atlas | Acts on the board's current selection |
| `atlas.card.copyLink` | Copy card link | — | atlas | Acts on the board's current selection |
| `atlas.card.demote` | Turn back into object | — | atlas | Acts on the board's current selection |
| `atlas.card.dissolve` | Dissolve area | — | atlas | Acts on the board's current selection |
| `atlas.card.exportAs` | Export card as… | — | atlas | Conditional — available only in a matching state |
| `atlas.card.fitToContent` | Fit card to content | — | atlas | Acts on the board's current selection |
| `atlas.card.open` | Open card | — | atlas | Acts on the board's current selection |
| `atlas.card.openFile` | Open card file | — | atlas | Acts on the board's current selection |
| `atlas.card.refreshFromFolder` | Refresh area from folder | — | atlas | Acts on the board's current selection |
| `atlas.card.revealInFileManager` | Reveal card in file manager | — | atlas | Acts on the board's current selection |
| `atlas.card.zoomIn` | Zoom into card | — | atlas | Acts on the board's current selection |
| `atlas.companion.toggle` | Toggle companion panel | — | atlas | Always available |
| `atlas.contents.open` | Contents | — | atlas | Always available |
| `atlas.create.area` | Draw an area | — | atlas | Conditional — available only in a matching state |
| `atlas.create.card` | Add a card | — | atlas | Conditional — available only in a matching state |
| `atlas.create.image` | Add an image | — | atlas | Conditional — available only in a matching state |
| `atlas.create.note` | Add a note | — | atlas | Conditional — available only in a matching state |
| `atlas.create.table` | New table | — | atlas | Conditional — available only in a matching state |
| `atlas.delete.selection` | Delete selection | `⌫` | atlas | Acts on the board's current selection |
| `atlas.escapeLadder` | Clear selection or go up a level | `ESCAPE` | atlas | Always available |
| `atlas.export` | Export atlas | — | atlas | Always available |
| `atlas.export.drawio` | Export board as .drawio | — | atlas | Always available |
| `atlas.focusDirection` | Focus the nearest card in a direction | `⌥→` | atlas | Always available |
| `atlas.focusNext` | Focus next card | `TAB` | atlas | Always available |
| `atlas.focusPrevious` | Focus previous card | `⇧TAB` | atlas | Always available |
| `atlas.group.selection` | Group into a new area | `G` | atlas | Acts on the board's current selection |
| `atlas.import` | Import atlas | — | atlas | Always available |
| `atlas.json.copyKey` | Copy key | — | atlas | Acts on the selected tree row |
| `atlas.json.copyPath` | Copy path | — | atlas | Acts on the selected tree row |
| `atlas.json.copyValue` | Copy value | `⌘C` | atlas | Acts on the selected tree row |
| `atlas.jump` | Jump to a card or object | `⌘K` | atlas | Always available |
| `atlas.kinds.open` | Kinds | — | atlas | Always available |
| `atlas.link.editLabel` | Edit link label… | — | atlas | Acts on the board's current selection |
| `atlas.link.remove` | Remove link | — | atlas | Acts on the board's current selection |
| `atlas.link.setKind` | Change link kind | — | atlas | Acts on the board's current selection |
| `atlas.minimap.toggle` | Toggle minimap | — | atlas | Always available |
| `atlas.note.open` | Open note | — | atlas | Acts on the board's current selection |
| `atlas.note.promote` | Promote note to card… | — | atlas | Acts on the board's current selection |
| `atlas.nudgeSelection` | Move the selected card | `→` | atlas | Always available |
| `atlas.object.pluginAction` | Extension action | — | atlas | Acts on the board's current selection |
| `atlas.object.promote` | Promote object to card… | — | atlas | Acts on the board's current selection |
| `atlas.openFocused` | Open or zoom the focused card | `↩` | atlas | Always available |
| `atlas.perspective` | Open perspective switcher | — | atlas | Always available |
| `atlas.redo` | Redo | — | atlas | Always available |
| `atlas.selectAll` | Select all | `⌘A` | atlas | Always available |
| `atlas.selection.addToPerspective` | Add to perspective | — | atlas | Acts on the board's current selection |
| `atlas.selection.copyAsImage` | Copy as image | — | atlas | Conditional — available only in a matching state |
| `atlas.selection.exportAsImage` | Export as image… | — | atlas | Conditional — available only in a matching state |
| `atlas.selection.removeFromPerspective` | Remove from perspective | — | atlas | Acts on the board's current selection |
| `atlas.share.copyContext` | Copy space as context | — | atlas | Always available |
| `atlas.share.copyLinks` | Copy space links | — | atlas | Always available |
| `atlas.space.delete` | Delete space | — | atlas | Acts on the board's current selection |
| `atlas.space.new` | New space… | — | atlas | Always available |
| `atlas.space.rename` | Rename space… | — | atlas | Acts on the board's current selection |
| `atlas.undo` | Undo | — | atlas | Always available |
| `atlas.up` | Go up one level | `⌘↑` | atlas | Always available |
| `audit.export` | Export audit trail | — | Global | Always available |
| `backup.export` | Export everything | — | Global | Always available |
| `backup.now` | Back up now | — | Global | Always available |
| `browser.pair` | Pair a browser | — | Global | Always available |
| `browser.pairRequest.accept` | Accept the browser's pairing request | — | Global | Conditional — available only in a matching state |
| `browser.pairRequest.deny` | Deny the browser's pairing request | — | Global | Conditional — available only in a matching state |
| `browser.revealExtension` | Reveal the extension folder | — | Global | Always available |
| `browser.test` | Test the browser connection | — | Global | Conditional — available only in a matching state |
| `canvas.addNote` | Add note | — | Global | Acts on the item you clicked |
| `canvas.addStep` | Add step | — | Global | Acts on the item you clicked |
| `canvas.delete` | Delete selected | `⌫` | composition | Always available |
| `canvas.edge.delete` | Delete connection | — | Global | Acts on the item you clicked |
| `canvas.edge.select` | Select connection | — | Global | Acts on the item you clicked |
| `canvas.fitView` | Fit view | — | composition | Always available |
| `canvas.redo` | Redo | `⌘⇧Z` | composition | Always available |
| `canvas.step.delete` | Delete step | — | Global | Acts on the item you clicked |
| `canvas.step.openDetails` | Open step details | — | Global | Acts on the item you clicked |
| `canvas.undo` | Undo | `⌘Z` | composition | Always available |
| `canvas.zoomIn` | Zoom in | `⌘+` | composition | Always available |
| `canvas.zoomOut` | Zoom out | `⌘-` | composition | Always available |
| `capture.note` | Capture a note | — | Global | Always available |
| `clientcert.delete` | Delete certificate | — | Global | Acts on the selected entity |
| `clientcert.duplicate` | Duplicate certificate | — | Global | Acts on the selected entity |
| `clientcert.edit` | Edit certificate | — | Global | Acts on the selected entity |
| `clipboard.delete` | Delete | — | Global | Acts on the selected clipboard entry |
| `clipboard.history.open` | Clipboard history | — | Global | Always available |
| `clipboard.pin` | Pin | — | Global | Acts on the selected clipboard entry |
| `clipboard.unpin` | Unpin | — | Global | Acts on the selected clipboard entry |
| `codingLoop.run` | Run from clipboard… | — | Global | Always available |
| `configure.aiprovider.cancelCheck` | Cancel check | — | Global | Acts on the selected entity |
| `configure.aiprovider.check` | Check connection | — | Global | Acts on the selected entity |
| `configure.aiprovider.copyAddress` | Copy address | — | Global | Acts on the selected entity |
| `configure.aiprovider.dataHelp` | Data access help | — | Global | Acts on the selected entity |
| `configure.aiprovider.delete` | Delete | — | Global | Acts on the selected entity |
| `configure.aiprovider.export` | Export | — | Global | Acts on the selected entity |
| `configure.aiprovider.reset` | Reset to shipped example | — | Global | Acts on the selected entity |
| `configure.aiprovider.restore.classification` | Restore sample | — | Global | Acts on the selected entity |
| `configure.aiprovider.restore.structured` | Restore sample | — | Global | Acts on the selected entity |
| `configure.aiprovider.restore.text` | Restore sample | — | Global | Acts on the selected entity |
| `configure.aiprovider.test.classification` | Test feature | — | Global | Acts on the selected entity |
| `configure.aiprovider.test.structured` | Test feature | — | Global | Acts on the selected entity |
| `configure.aiprovider.test.text` | Test feature | — | Global | Acts on the selected entity |
| `configure.conversionprofile.delete` | Delete | — | Global | Acts on the selected entity |
| `configure.decision.delete` | Delete | — | Global | Acts on the selected entity |
| `configure.decision.duplicate` | Duplicate | — | Global | Acts on the selected entity |
| `configure.decision.export` | Export | — | Global | Acts on the selected entity |
| `configure.decision.reset` | Reset to shipped example | — | Global | Acts on the selected entity |
| `configure.environment.delete` | Delete | — | Global | Acts on the selected entity |
| `configure.environment.duplicate` | Duplicate | — | Global | Acts on the selected entity |
| `configure.environment.export` | Export | — | Global | Acts on the selected entity |
| `configure.environment.reset` | Reset to shipped example | — | Global | Acts on the selected entity |
| `configure.execenv.delete` | Delete | — | Global | Acts on the selected entity |
| `configure.execenv.export` | Export | — | Global | Acts on the selected entity |
| `configure.execenv.reset` | Reset to shipped example | — | Global | Acts on the selected entity |
| `configure.integration.testDraft` | Test | — | Global | Conditional — available only in a matching state |
| `configure.list.delete` | Delete | — | Global | Acts on the selected entity |
| `configure.list.export` | Export | — | Global | Acts on the selected entity |
| `configure.list.reset` | Reset to shipped example | — | Global | Acts on the selected entity |
| `configure.mcpserver.delete` | Delete | — | Global | Acts on the selected entity |
| `configure.mcpserver.export` | Export | — | Global | Acts on the selected entity |
| `configure.mcpserver.listTools` | List tools | — | Global | Acts on the selected entity |
| `configure.mcpserver.reset` | Reset to shipped example | — | Global | Acts on the selected entity |
| `configure.new.aiproviders` | New AI provider | — | Global | Always available |
| `configure.new.certificates` | New client certificate | — | Global | Always available |
| `configure.new.conversionprofiles` | New conversion profile | — | Global | Always available |
| `configure.new.decisions` | New decision | — | Global | Always available |
| `configure.new.environments` | New environment | — | Global | Always available |
| `configure.new.execenvs` | New execution environment | — | Global | Always available |
| `configure.new.integration` | New integration | — | Global | Always available |
| `configure.new.lists` | New list | — | Global | Always available |
| `configure.new.mcpservers` | New MCP server | — | Global | Always available |
| `configure.new.steptypes` | New step type | — | Global | Always available |
| `configure.open.aiproviders` | Configure › AI Providers | — | Global | Always available |
| `configure.open.attributes` | Configure › Attributes | — | Global | Always available |
| `configure.open.certificates` | Configure › Certificates | — | Global | Always available |
| `configure.open.conversionprofiles` | Configure › Conversion profiles | — | Global | Always available |
| `configure.open.decisions` | Configure › Decisions | — | Global | Always available |
| `configure.open.environments` | Configure › Environments | — | Global | Always available |
| `configure.open.execenvs` | Configure › Execution Environments | — | Global | Always available |
| `configure.open.integration` | Configure › Integrations | — | Global | Always available |
| `configure.open.lists` | Configure › Lists | — | Global | Always available |
| `configure.open.mcpservers` | Configure › MCP Servers | — | Global | Always available |
| `configure.open.steptypes` | Configure › Step types | — | Global | Always available |
| `configure.request.delete` | Delete | — | Global | Acts on the selected entity |
| `configure.request.edit` | Edit | — | Global | Acts on the selected entity |
| `configure.request.export` | Export | — | Global | Acts on the selected entity |
| `configure.request.reset` | Reset to shipped example | — | Global | Acts on the selected entity |
| `configure.secretsource.delete` | Delete | — | Global | Acts on the selected entity |
| `configure.steptype.delete` | Delete | — | Global | Acts on the selected entity |
| `configure.steptype.export` | Export | — | Global | Acts on the selected entity |
| `diagram.fit` | Fit diagram | — | atlas | Acts on the board's current selection |
| `diagram.nextPage` | Next page | — | atlas | Conditional — available only in a matching state |
| `diagram.previousPage` | Previous page | — | atlas | Conditional — available only in a matching state |
| `docs.search` | Search docs | — | Global | Always available |
| `edit.save` | Save | `⌘S` | Global | Conditional — available only in a matching state |
| `edit.saveAll` | Save all changes | — | Global | Conditional — available only in a matching state |
| `extension.addMcpServer` | Add to Configure | — | Global | Acts on the selected entity |
| `extension.addSource` | Add extension source | — | Global | Acts on the selected marketplaceSourceInput |
| `extension.browse.clearFilters` | Clear extension filters | — | Global | Conditional — available only in a matching state |
| `extension.browse.retry` | Retry loading extensions | — | Global | Conditional — available only in a matching state |
| `extension.checkUpdates` | Check for updates | — | Global | Acts on the selected entity |
| `extension.disable` | Turn off | — | Global | Acts on the selected entity |
| `extension.enable` | Turn on | — | Global | Acts on the selected entity |
| `extension.refreshSources` | Extensions: refresh sources | — | Global | Conditional — available only in a matching state |
| `extension.remove` | Remove | — | Global | Acts on the selected entity |
| `extension.reveal` | Reveal folder | — | Global | Acts on the selected entity |
| `extension.source.remove` | Remove source | — | Global | Acts on the selected entity |
| `extension.sources.retry` | Retry sources | — | Global | Conditional — available only in a matching state |
| `extension.update` | Update | — | Global | Acts on the selected entity |
| `extensions.cancelRemainingUpdates` | Extensions: cancel remaining updates | — | Global | Conditional — available only in a matching state |
| `extensions.checkUpdates` | Extensions: check for updates | — | Global | Conditional — available only in a matching state |
| `extensions.exportAudit` | Export plugin audit | — | Global | Always available |
| `extensions.importTheme` | Extensions: Import theme | — | Global | Conditional — available only in a matching state |
| `extensions.install.confirm` | Confirm extension installation | — | Global | Acts on the selected extensionInstallAttempt |
| `extensions.install.dismiss` | Close extension installation | — | Global | Acts on the selected extensionInstallAttempt |
| `extensions.install.prepare` | Prepare extension installation | — | Global | Acts on the selected extensionInstallCandidate |
| `extensions.install.retry` | Retry extension preparation | — | Global | Acts on the selected extensionInstallAttempt |
| `extensions.open` | Extensions | `⌘⇧X` | Global | Always available |
| `extensions.retryRecovery` | Extensions: retry recovery | — | Global | Conditional — available only in a matching state |
| `extensions.retryUpdates` | Extensions: retry loading updates | — | Global | Conditional — available only in a matching state |
| `extensions.review` | Review extensions waiting for you | — | Global | Conditional — available only in a matching state |
| `extensions.sources` | Extensions: marketplace sources | — | Global | Always available |
| `extensions.updateAll` | Extensions: update all | — | Global | Conditional — available only in a matching state |
| `extensions.viewInstalled` | Extensions: view installed | — | Global | Always available |
| `guardrail.rule.delete` | Delete rule | — | Global | Acts on the selected entity |
| `guardrail.rule.edit` | Edit rule | — | Global | Acts on the selected entity |
| `help.openDataFolder` | Open data folder | — | Global | Always available |
| `help.reportIssue` | Report an issue… | — | Global | Always available |
| `help.shortcuts` | Keyboard shortcuts help | — | Global | Always available |
| `list.clearSelection` | Clear selection | `ESCAPE` | configure, composition, secrets | Conditional — available only in a matching state |
| `list.deleteSelection` | Delete | `⌫` | configure, composition, secrets | Conditional — available only in a matching state |
| `list.destroySelection` | Delete forever | — | configure, composition, secrets | Conditional — available only in a matching state |
| `list.extendSelection` | Extend selection | `⇧SPACE` | configure, composition, secrets | Conditional — available only in a matching state |
| `list.restoreSelection` | Restore | — | configure, composition, secrets | Conditional — available only in a matching state |
| `list.selectAll` | Select all | `⌘A` | configure, composition, secrets | Conditional — available only in a matching state |
| `list.toggleSelection` | Toggle selection | `SPACE` | configure, composition, secrets | Conditional — available only in a matching state |
| `listGrid.addColumn` | Add a column | — | Global | Acts on the selected table rows |
| `listGrid.addRow` | Add a row | — | Global | Acts on the selected table rows |
| `listGrid.copyRows` | Copy selected rows | — | Global | Acts on the selected table rows |
| `listGrid.deleteColumn` | Delete selected column | — | Global | Acts on the selected table rows |
| `listGrid.deleteRows` | Delete selected rows | — | Global | Acts on the selected table rows |
| `listGrid.search` | Find in this list | `⌘F` | Global | Acts on the selected table rows |
| `object.editDiagram` | Edit diagram… | — | atlas | Acts on the board's current selection |
| `object.openInDefaultApp` | Open in default app | — | atlas | Acts on the board's current selection |
| `object.rename` | Rename | — | atlas | Acts on the board's current selection |
| `output.copy` | Copy output | — | Global | Conditional — available only in a matching state |
| `output.find` | Find in output | `⌘F` | Global | Conditional — available only in a matching state |
| `output.openFull` | Open output in full | — | Global | Conditional — available only in a matching state |
| `output.save` | Save output | — | Global | Conditional — available only in a matching state |
| `output.toggleWrap` | Wrap output lines | — | Global | Conditional — available only in a matching state |
| `palette.open` | Open command palette | `⌘K` | Global | Always available |
| `panel.applyClipboard` | Apply from clipboard | — | Global | Always available |
| `panel.open` | Open Quick Panel | — | Global | Conditional — available only in a matching state |
| `panel.openMill` | Open Mill | — | Global | Always available |
| `panel.resetPosition` | Reset Quick Panel position | — | Global | Conditional — available only in a matching state |
| `perspective.row.delete` | Delete perspective | — | Global | Acts on the selected entity |
| `perspective.row.rename` | Rename perspective | — | Global | Acts on the selected entity |
| `review.rules` | Guardrail rules | — | review | Always available |
| `run.continue` | Continue | — | Global | Acts on the selected run |
| `run.monitor` | Show run steps | — | Global | Acts on the selected run |
| `run.open` | Open run | — | Global | Acts on the selected run |
| `run.step` | Step | — | Global | Acts on the selected run |
| `run.stop` | Stop run | — | Global | Acts on the selected run |
| `runMonitor.open` | Run monitor | — | Global | Conditional — available only in a matching state |
| `secret.copyReference` | Copy reference | — | Global | Acts on the selected entity |
| `secret.destroy` | Delete forever | — | Global | Acts on the selected entity |
| `secret.restore` | Restore | — | Global | Acts on the selected entity |
| `secret.row.delete` | Delete | — | Global | Acts on the selected entity |
| `secret.row.edit` | Edit | — | Global | Acts on the selected entity |
| `secret.row.history` | History | — | Global | Acts on the selected entity |
| `secret.row.openSource` | Open source | — | Global | Acts on the selected entity |
| `secret.showTrash` | Show Trash | — | Global | Always available |
| `secrets.findDotenvFiles` | Find .env files… | — | Global | Always available |
| `secrets.lockVault` | Lock vault | — | Global | Conditional — available only in a matching state |
| `secrets.resetVault` | Start a new vault | — | Global | Conditional — available only in a matching state |
| `secrets.restoreVaultFromBackup` | Restore the last backup | — | Global | Conditional — available only in a matching state |
| `secrets.unlockVault` | Unlock vault | — | Global | Conditional — available only in a matching state |
| `settings.open` | Open Settings | `⌘,` | Global | Always available |
| `settings.open.appearance` | Settings › Appearance | — | Global | Always available |
| `settings.open.backups` | Settings › Backups | — | Global | Always available |
| `settings.open.connections` | Settings › Connections | — | Global | Always available |
| `settings.open.general` | Settings › General | — | Global | Always available |
| `settings.open.notifications` | Settings › Notifications | — | Global | Always available |
| `settings.open.security` | Settings › Security | — | Global | Always available |
| `settings.open.shortcuts` | Settings › Shortcuts | — | Global | Always available |
| `settings.open.updates` | Settings › Updates | — | Global | Always available |
| `settings.search` | Search settings | `⌘F` | settings | Always available |
| `settings.show.appearance.colorMode` | Setting: Theme mode | — | Global | Always available |
| `settings.show.appearance.darkTheme` | Setting: Dark theme | — | Global | Always available |
| `settings.show.appearance.density` | Setting: Density | — | Global | Always available |
| `settings.show.appearance.lightTheme` | Setting: Light theme | — | Global | Always available |
| `settings.show.appearance.theme` | Setting: Theme | — | Global | Always available |
| `settings.show.connections.mcpAddress` | Setting: Address | — | Global | Always available |
| `settings.show.connections.mcpAllowImport` | Setting: Allow MCP clients to change content | — | Global | Always available |
| `settings.show.connections.mcpAskBeforeImport` | Setting: Ask me before each MCP import | — | Global | Always available |
| `settings.show.general.canvasNavigation` | Setting: Canvas navigation | — | Global | Always available |
| `settings.show.general.launchAtLogin` | Setting: Launch Mill at login | — | Global | Always available |
| `settings.show.general.saveMode` | Setting: Save changes | — | Global | Always available |
| `settings.show.notifications.alertPermission` | Setting: Alerts that stay on screen | — | Global | Always available |
| `settings.show.notifications.awayAfter` | Setting: Away after (seconds) | — | Global | Always available |
| `settings.show.security.extensionAllowedSources` | Setting: Allowed sources | — | Global | Always available |
| `settings.show.security.extensionBlockedCapabilities` | Setting: Blocked capabilities | — | Global | Always available |
| `settings.show.security.extensionLists` | Setting: Allow and block lists | — | Global | Always available |
| `settings.show.security.extensionManagedBy` | Setting: Managed by | — | Global | Always available |
| `settings.show.security.extensionPolicyFile` | Setting: Policy file | — | Global | Always available |
| `settings.show.security.extensionRequiredTier` | Setting: Required trust tier | — | Global | Always available |
| `settings.show.shortcuts.globalHotkey` | Setting: Global hotkey | — | Global | Always available |
| `tab.close` | Close tab | `⌘W` | Global | Conditional — available only in a matching state |
| `tab.closeAll` | Close all tabs | `⌘⇧W` | Global | Always available |
| `tab.closeOthers` | Close other tabs | `⌘⌥W` | Global | Conditional — available only in a matching state |
| `tab.next` | Next tab | `⌃TAB` | Global | Always available |
| `tab.prev` | Previous tab | `⌃⇧TAB` | Global | Always available |
| `update.check` | Check for updates | — | Global | Conditional — available only in a matching state |
| `update.downloadAndInstall` | Download the update and install | — | Global | Conditional — available only in a matching state |
| `update.relaunch` | Restart to finish updating | — | Global | Conditional — available only in a matching state |
| `update.trustSigning` | Trust Mill's signing | — | Global | Always available |
| `update.whatsNew` | What's new | — | Global | Always available |
| `view.activity` | Go to Activity | `⌘4` | Global | Always available |
| `view.atlas` | Go to Atlas | `⌘3` | Global | Always available |
| `view.composition` | Go to Workflows | `⌘1` | Global | Always available |
| `view.configure` | Go to Configure | `⌘2` | Global | Always available |
| `view.docs` | Open docs | — | Global | Always available |
| `view.home` | Go to Home | `⌘0` | Global | Always available |
| `view.review` | Go to Review | `⌘5` | Global | Always available |
| `view.secrets` | Go to Secrets | `⌘6` | Global | Always available |
| `webhook.mint` | Add webhook token | — | Global | Always available |
| `workflow.edit` | Edit workflow | — | Global | Conditional — available only in a matching state |
| `workflow.new` | New workflow | `⌘N` | Global | Conditional — available only in a matching state |
| `workflow.open` | Open workflow | — | Global | Acts on the selected workflow |
| `workflow.pin` | Pin | — | Global | Acts on the selected workflow |
| `workflow.publish` | Publish current draft | — | composition | Conditional — available only in a matching state |
| `workflow.row.delete` | Delete | — | Global | Acts on the selected entity |
| `workflow.row.edit` | Edit | — | Global | Acts on the selected entity |
| `workflow.row.export` | Export | — | Global | Acts on the selected entity |
| `workflow.row.reset` | Reset to shipped example | — | Global | Acts on the selected entity |
| `workflow.run` | Run workflow | `⌘↩` | Global | Conditional — available only in a matching state |
| `workflow.runAndWatch` | Run and watch | — | Global | Acts on the selected workflow |
| `workflow.runStepped` | Run step by step | — | Global | Conditional — available only in a matching state |
| `workflow.save` | Save workflow | `⌘S` | Global | Conditional — available only in a matching state |
| `workflow.unpin` | Unpin | — | Global | Acts on the selected workflow |
| `workflow.view` | View workflow | — | Global | Conditional — available only in a matching state |

<!-- END GENERATED -->

See "Register a command" for how to add a new one, and Settings →
Keyboard shortcuts to rebind any command's default combo.

---


# Menu bar

Every menu in Mill's menu bar is built from the same list of actions
the command palette reads. An action registered once shows up in the
palette, in Settings → Keyboard shortcuts, and — if it has a menu seat
— in the menu bar, all describing the same thing.

Items that appear dimmed are actions that do not apply right now: Save
needs an open workflow, the Atlas menu needs the Atlas view. A dimmed
item's keyboard shortcut does nothing either, so a shortcut that only
makes sense on one view stays quiet everywhere else.

Standard items — About, Services, Hide, Quit, the whole Edit menu,
zoom, Minimize — are macOS's own. They behave the way they do in every
other Mac app, and macOS shows them in your language.

Reload, Force Reload and Open Developer Tools live in **Help →
Developer**, out of the way of everyday use.

Shortcuts listed here are the shipped defaults. Rebind any of them in
Settings → Keyboard shortcuts, and the menu updates to match.

<!-- BEGIN GENERATED: menu bar (source: frontend/src/shared/menuDeclaration.json) -->

### Mill

| Item | Shortcut | Command |
|---|---|---|
| About Mill | — | Provided by macOS |
| Check for updates… | — | `update.check` |
| Settings… | ⌘, | `settings.open` |
| Back up now | — | `backup.now` |
| Services | — | Provided by macOS |
| Hide Mill | — | Provided by macOS |
| Hide Others | — | Provided by macOS |
| Show All | — | Provided by macOS |
| Quit Mill | — | Provided by macOS |

### File

| Item | Shortcut | Command |
|---|---|---|
| New workflow | ⌘N | `workflow.new` |
| New note | — | `capture.note` |

### File > New…

| Item | Shortcut | Command |
|---|---|---|
| New integration | — | `configure.new.integration` |
| New list | — | `configure.new.lists` |
| New MCP server | — | `configure.new.mcpservers` |
| New decision | — | `configure.new.decisions` |
| New execution environment | — | `configure.new.execenvs` |
| New environment | — | `configure.new.environments` |
| New AI provider | — | `configure.new.aiproviders` |
| New client certificate | — | `configure.new.certificates` |
| New conversion profile | — | `configure.new.conversionprofiles` |
| New step type | — | `configure.new.steptypes` |

### File

| Item | Shortcut | Command |
|---|---|---|
| Close tab | ⌘W | `tab.close` |
| Close other tabs | ⌘⌥W | `tab.closeOthers` |
| Close all tabs | ⌘⇧W | `tab.closeAll` |
| Save | ⌘S | `workflow.save` |
| Export everything | — | `backup.export` |
| Export plugin audit | — | `extensions.exportAudit` |
| Export audit trail | — | `audit.export` |
| Lock vault | — | `secrets.lockVault` |

### Edit

| Item | Shortcut | Command |
|---|---|---|
| Undo, Redo, Cut, Copy, Paste, Select All, Speech | — | Provided by macOS |

### View

| Item | Shortcut | Command |
|---|---|---|
| Home | ⌘0 | `view.home` |
| Workflows | ⌘1 | `view.composition` |
| Configure | ⌘2 | `view.configure` |
| Atlas | ⌘3 | `view.atlas` |
| Activity | ⌘4 | `view.activity` |
| Review | ⌘5 | `view.review` |
| Review rules | — | `review.rules` |
| Secrets | ⌘6 | `view.secrets` |
| Extensions | ⌘⇧X | `extensions.open` |
| Docs | — | `view.docs` |
| Command palette | ⌘K | `palette.open` |
| Next tab | ⌃TAB | `tab.next` |
| Previous tab | ⌃⇧TAB | `tab.prev` |
| Clipboard history | — | `clipboard.history.open` |
| Actual Size | — | Provided by macOS |
| Zoom In | — | Provided by macOS |
| Zoom Out | — | Provided by macOS |
| Enter Full Screen | — | Provided by macOS |

### Workflow

| Item | Shortcut | Command |
|---|---|---|
| Run workflow | ⌘↩ | `workflow.run` |
| Run step by step | — | `workflow.runStepped` |
| Run from clipboard… | — | `codingLoop.run` |
| View workflow | — | `workflow.view` |
| Edit workflow | — | `workflow.edit` |
| Save | ⌘S | `edit.save` |
| Save all changes | — | `edit.saveAll` |
| Undo | ⌘Z | `canvas.undo` |
| Redo | ⌘⇧Z | `canvas.redo` |
| Delete selected | ⌫ | `canvas.delete` |
| Zoom in | ⌘+ | `canvas.zoomIn` |
| Zoom out | ⌘- | `canvas.zoomOut` |
| Fit view | — | `canvas.fitView` |
| Publish current draft | — | `workflow.publish` |

### Atlas

| Item | Shortcut | Command |
|---|---|---|
| Go up one level | ⌘↑ | `atlas.up` |
| Jump to a card or object | ⌘K | `atlas.jump` |
| Undo | — | `atlas.undo` |
| Redo | — | `atlas.redo` |
| Auto-arrange | — | `atlas.arrange` |
| Contents | — | `atlas.contents.open` |
| Import atlas | — | `atlas.import` |
| Export atlas | — | `atlas.export` |
| Export board as .drawio | — | `atlas.export.drawio` |
| Kinds | — | `atlas.kinds.open` |
| Add a file to the board | — | `atlas.addFile` |
| Copy as image | — | `atlas.selection.copyAsImage` |
| Export as image… | — | `atlas.selection.exportAsImage` |
| Add cards from a folder | — | `atlas.addFromFolder` |
| Copy space as context | — | `atlas.share.copyContext` |
| Copy space links | — | `atlas.share.copyLinks` |
| Open perspective switcher | — | `atlas.perspective` |
| Select all | ⌘A | `atlas.selectAll` |
| Delete selection | ⌫ | `atlas.delete.selection` |
| Group into a new area | G | `atlas.group.selection` |
| Previous page | — | `diagram.previousPage` |
| Next page | — | `diagram.nextPage` |
| Fit diagram | — | `diagram.fit` |
| Open in default app | — | `object.openInDefaultApp` |
| Rename | — | `object.rename` |
| Toggle companion panel | — | `atlas.companion.toggle` |
| Toggle minimap | — | `atlas.minimap.toggle` |
| Export card as… | — | `atlas.card.exportAs` |
| Add a card | — | `atlas.create.card` |
| Add a note | — | `atlas.create.note` |
| Draw an area | — | `atlas.create.area` |
| New table | — | `atlas.create.table` |
| Add an image | — | `atlas.create.image` |

### Window

| Item | Shortcut | Command |
|---|---|---|
| Minimize | — | Provided by macOS |
| Zoom | — | Provided by macOS |
| Quick panel | — | `panel.open` |
| Run monitor | — | `runMonitor.open` |
| Bring All to Front | — | Provided by macOS |

### Help

| Item | Shortcut | Command |
|---|---|---|
| Mill help | — | `view.docs` |
| Search docs | — | `docs.search` |
| Keyboard shortcuts | — | `help.shortcuts` |
| What's new | — | `update.whatsNew` |
| Report an issue… | — | `help.reportIssue` |
| Open data folder | — | `help.openDataFolder` |

### Help > Developer

| Item | Shortcut | Command |
|---|---|---|
| Reload | — | Provided by macOS |
| Force Reload | — | Provided by macOS |
| Open Developer Tools | — | Provided by macOS |

<!-- END GENERATED -->

---


# Environments

An **environment** is a named set of variables a run selects. One
workflow, one integration, two stages: the only thing that changes
between them is which environment the run picked.

Environments live in **Configure › Environments**.

## Write a variable into a request

In an integration's URL, a header value, or its body, write a variable
name in double braces:

```
{{API_BASE}}/v1/updates
```

When a run starts, Mill replaces every reference with the selected
environment's value and sends the result. Nothing is stored resolved,
and nothing is guessed: a name the environment does not define stops the
run before it starts, naming the variable.

To send the braces themselves, escape the opening pair with a backslash:
`\{{API_BASE}}` is sent as `{{API_BASE}}`. Text that is not a variable
name — a JSON body's own braces, for instance — is left exactly as
written.

## Add an environment

1. Open **Configure › Environments** and choose **New environment**.
2. Give it a **Label**: the name a run picks it by, such as Sandbox or
   Production.
3. Add a variable: a **name** and a **value**.

A variable name starts with a letter or underscore, then letters, digits
or underscores. No two variables in one environment may share a name.

## Plain and secret variables

Tick **Secret** beside a variable and its value becomes a pick from your
secret store instead of typed text. The environment holds the pointer,
never the secret itself, so an exported environment carries no
credentials.

A secret variable with nothing picked yet shows **Needs a value** on the
row. It resolves to an empty string until you pick one.

## Choosing an environment for a run

- A workflow's **Environment** on the canvas is the stage its runs use.
  Scheduled runs, triggered runs, and runs started by another workflow
  all use it, because there is nobody to ask.
- Running from the canvas or the workflow list opens the run dialog with
  that environment already chosen. Pick another for this one run, or
  pick **None**.

The run records the environment it actually started in. Activity shows
it per run, and a redrive replays that same stage even if the workflow's
default has moved on since.

## Shell environments

An execution environment (**Configure › Execution Environments**) can
borrow a shared environment's variables through **Variables from
environment**. Those variables are added under the shell's own, so a
name the shell sets itself wins.

## What you cannot delete

An environment a workflow targets, or a shell borrows, cannot be
deleted. Mill names what still uses it, so you can clear the reference
first.

## For agents

`mill://environments` lists every environment, its label, and its
variable names, each marked plain or secret. Values never cross that
boundary — an agent can see that `{{API_BASE}}` will resolve without
being told what it resolves to.

---


# Client certificates

Some servers ask the caller to prove who it is with a certificate, not
just a token. Mill holds those certificates in **Configure ›
Certificates**, one per host, and presents the right one automatically
on every request it makes to that host.

Nothing about a client certificate lives on an individual request. You
name a host once; every integration, every workflow step and every
agent-driven call to that host uses it.

## Add one

1. Open **Configure › Certificates** and choose **New certificate**.
2. Give it a **Label** you will recognise in a list.
3. Set the **Host**. Either an exact host, `api.example.com`, or a
   wildcard covering one level of subdomain, `*.example.com`. Add a
   port when the server uses a non-standard one: `api.example.com:8443`.
4. Pick the **Certificate**, and the **Private key** beside it.

Both fields are pickers over your secret store, not file paths. If the
certificate is not stored yet, **Add** beside the picker opens the
store's own entry form without leaving the page.

Under **Advanced** there are two more fields, both optional: a
**Passphrase** for an encrypted key or bundle, and a **CA certificate**
for a server that presents a private root. The CA you add here is used
*alongside* the authorities your computer already trusts, never instead
of them.

## Which certificate a request uses

Mill picks the most specific host that matches:

- An exact host beats a wildcard.
- A longer wildcard suffix beats a shorter one, so `*.pay.example.com`
  wins over `*.example.com`.
- An entry naming a port only matches that port.

If nothing matches, the request goes out with no client certificate. A
server that wanted one then refuses the connection, and Mill says which
host had no match.

## Formats Mill reads

- A **PEM** certificate (or a chain, leaf first) beside a **PEM key**.
  The key may be PKCS#8, PKCS#1 or SEC 1, encrypted or not.
- A **PKCS#12 / PFX bundle**, which carries its own key. Pick it in the
  Certificate field and the Private key field disappears: the bundle
  already has one. Put the bundle's passphrase in the Passphrase field.

One format is refused on purpose: a PEM key encrypted the old way, with
a `Proc-Type: 4,ENCRYPTED` header. Its protection is not sound, and Mill
tells you to export the key as PKCS#8 or PKCS#12 instead. From OpenSSL,
`openssl pkcs8 -topk8 -in old.key -out new.key` does it.

## Reading the list

Each row shows its host and a status:

- **Ready** — the certificate works and is not near its end.
- **Expires in N days** — inside the last 30 days of validity.
- **Expired** — past its end date, or not yet started. Requests to that
  host fail until you replace it.
- **Needs a certificate and key** — you have not finished setting it up.
- **Can't read the certificate** — the material is named but cannot be
  opened. Unlock your secret store first; if it stays, the passphrase
  or the entry is wrong.

Only the certificate's subject, issuer and dates are read to build this.
The certificate and key themselves are never shown, copied or logged,
and each time Mill reads one it records the read in the secret audit
trail.

## Test a host

Open a certificate and choose **Test**. Mill opens a TLS connection to
the host, completes the handshake, and closes it without sending a
request. It reports either that the handshake succeeded or what stopped
it. A wildcard host names a family rather than one machine, so Test is
unavailable there.

## On a request

An integration's **Auth** section says which certificate its base URL's
host already has, or that the host has none and offers **Add one**,
which starts a new certificate with the host filled in. It is a
statement about the host, not a setting on the request.

---


# The browser extension

Some work only exists behind a sign-in. Mill can replay a recorded set
of steps in **your** browser, in the session you are already signed into,
so nothing has to be re-authenticated and no password ever reaches Mill.

The browser half of that is a small extension, which ships inside Mill
itself.

## Install it

1. Open **Settings › Connections › Browsers** and press **Reveal the
   extension folder**. Mill writes the extension out and shows you where
   it landed.
2. Open your browser's extensions page (`chrome://extensions` in Chrome)
   and turn on **Developer mode**.
3. Choose **Load unpacked** and pick the folder Mill just showed you.
4. Pin the Mill icon so its popup is one click away.

Chrome, Edge and Opera all load the folder as-is. Mill rewrites the
folder every time you reveal it, so after an upgrade, reveal it again
and reload the extension.

## Pair it

A browser has to be paired before Mill will send it anything, and that
holds even when both are on the same computer. Anything running locally
could otherwise drive your tabs. Pairing works the way Bluetooth does:
the same code shows on both sides, and you confirm the match once.

1. Open the extension's popup and press **Pair with Mill**. A 6-digit
   code appears in the popup.
2. Open **Settings › Connections › Browsers** in Mill. The browser's
   request shows there with the same code.
3. Check the two codes match, then press **Accept**.
4. The popup shows **Connected to Mill**, and the browser appears in the
   Browsers list.

If the popup can't reach Mill this way — a remote Mill, or Mill still
starting up — press **Enter a code instead** in the popup:

1. Open **Settings › Connections › Browsers**. Note the **Mill address**
   shown there.
2. Press **Pair a browser**. An eight-character code appears, good for
   five minutes and usable once.
3. In the popup's **Enter a code instead** form, check the address
   matches, type the code, and press **Pair**.
4. The popup shows **Connected to Mill**, and the browser appears in the
   Browsers list.

Once paired, the popup shows one of three states:

- **Connected to Mill** — the address is kept behind a **Show details**
  disclosure, and **Disconnect** ends the pairing: the browser drops
  off Mill's Browsers list, and the popup's own credential clears
  right away even if Mill can't be reached.
- **Paired, reconnecting…** — the browser goes idle enough that Chrome
  shuts down the extension's own background process; opening the
  popup reconnects it. Open the Mill extension in your browser to
  reconnect if a step ever reports nothing is listening.
- **Not paired** — the credential was revoked, or nothing has been
  paired here yet; press **Pair with Mill** again.

## Test it

Press **Test the connection**. Mill opens a page it serves itself,
presses a button on it, and waits for what the press reveals — the three
things every recorded flow depends on. It then reports how many steps
ran and how long they took.

If the browser went idle since it last paired, Mill waits briefly for
it to reconnect on its own before reporting: *No browser is connected.
Open the Mill extension in your browser and run again.*

## Record the steps

Mill replays Chrome DevTools Recorder flows exactly as exported:

1. Open DevTools and go to the **Recorder** panel.
2. Record what you do on the page.
3. Export the recording as JSON.

Every selector the Recorder writes is honoured, including its fallback
chains — a CSS selector, an accessible name, the visible text, an XPath,
and a path through a component's shadow root. That redundancy is what
lets a recording survive a page whose styling changed underneath it.

## Replay a recording as a workflow step

**Replay in the browser** is a step like any other. Add it to a
workflow, and it runs your recording in the paired browser as part of a
run.

- **Recording** — press **Import a recording** and pick the JSON you
  exported. The file is stored exactly as exported; nothing rewrites it.
- **Parameters** — a run rarely wants the same text every time. Each
  parameter names one step of the recording, one of its fields (the
  address it opens, the text it types, the key it presses), and where
  the value comes from: one of the workflow's Attributes, or a fixed
  value. The values are laid over a copy of the recording at run time.
- **Extract** — name a step that waits for an element, and the text of
  that element comes back under the name you gave it.
- **Timeout** — how long the whole flow may take before the run fails.

The step leaves a result you can read in the run's receipt: every step
with its outcome, the text you extracted, and any file the browser
saved while the flow ran.

Add a **Land downloads on the board** step after it to turn what the
browser downloaded into an Atlas object — a PDF, image, CSV/Excel
sheet, JSON export or diagram file each land as their matching object
type; anything else stays on disk with a note saying Mill can't show
that file type yet. A file already on the board is matched by its
content rather than landed a second time, and the note says when it
first landed. A download over 10 MB stays on disk too, with a note
saying where.

Driving a live site is an external effect, so a run parks for your
approval before the browser is touched, the same as an outgoing HTTP
call.

### When it stops

- *No browser is connected. Open the Mill extension in your browser and
  run again.* — the browser didn't reconnect in time; open its popup
  and try again.
- *Couldn't find the element for step 3 (#email).* — the page changed
  under the recording. Re-record that step.
- *The browser didn't finish the flow in 60 seconds.* — the flow is
  longer than its budget, or the page is waiting on something. Raise the
  timeout, or shorten the flow.
- *Parameter email points at step 4, which has no value.* — the
  parameter names a step that types nothing. Point it at the step that
  does.

## What it can and cannot do

It can open a tab or reuse one already on the right site, click,
double-click, hover, type into fields, press keys, scroll, wait for an
element or for an expression to become true, and report a file the page
downloaded while the steps ran.

It cannot resize your window, shape your network, or close tabs. Those
steps are reported as skipped rather than performed.

## Revoke it

A paired browser stays paired until you revoke it. Press **Revoke** on
its row in **Settings › Connections › Browsers**. The connection ends
immediately, including in the middle of a run.

## What leaves your machine

Nothing. The extension talks only to the Mill address you entered, which
is your own computer by default. The pairing token is held by the
browser for this extension alone, and Mill keeps only a hash of it.

---


# Settings

Preferences about Mill itself, not any one workflow — most apply the
moment you change them and persist across restarts; the few that need a
restart say so.

Settings is a list of groups on the left and one group's page on the
right: General, Appearance, Security, Shortcuts, Extensions,
Connections, Notifications, Backups, Updates. Only the group you pick
is on screen, and Mill reopens Settings on the group you read last.
Each setting is one row: its name and a short line about it on the
left, its control on the right, with **Learn more** wherever the full
story lives in these pages. Every group is also one command away —
search "Settings" in the command palette (⌘K) to jump straight to a
group.

## General

- **Launch at login** — start Mill when you sign in.
- **Save changes** — Automatically (the default): edits save as you
  make them, and quitting or restarting saves anything still open.
  When I choose: edits wait until you press ⌘S; a note or sheet with
  unsaved edits shows a dot, and Mill asks Save all / Discard / Cancel
  before it quits, restarts, or closes its window.
- **Canvas navigation** — how scrolling moves the Atlas board and the
  workflow canvas. Trackpad: scrolling pans, pinch or ⌘-scroll zooms.
  Mouse: scrolling zooms, drag pans. Stored per device, so each
  computer keeps its own choice.

## Appearance

- **Theme mode** — Single theme keeps the selected light or dark theme
  active. Follow system uses separate Light theme and Dark theme
  preferences as your system appearance changes. Every open Mill
  window follows a selection at once, including the Quick Panel, the
  menu-bar panel, and the run monitor.
- **Theme** — point at a theme or move through the list with the
  keyboard to preview it across the current window. Select it to keep
  it; press Escape or leave the list to restore the saved appearance.
  Single theme lists both families, so selecting any theme applies it
  immediately.
- **Light theme** and **Dark theme** — under Follow system, each choice
  saves the theme used for that system appearance and keeps following
  the system. Finishing a preview restores the system's current
  appearance. Use Single theme to keep a specific theme active.
  Available themes are Default, High contrast, Colorblind, Colorblind
  high contrast, Tritanopia, and Tritanopia high contrast, plus Dimmed
  for the dark family. When the system asks for more contrast, Mill
  switches to the high-contrast version of the saved theme.
- **Accent** — Mill uses your system accent color when the platform
  reports one, and its own teal when it doesn't. There is no accent
  picker.
- **Density** — Comfortable or Compact. Compact tightens rows and
  spacing across the app: list and palette rows, the Quick Panel,
  tables, canvas card faces, and Settings itself. On a phone-sized
  window, touch targets keep their full size either way.

## Security

- **Lock the vault after** — how long the vault may sit idle before it
  locks itself: one minute to eight hours, a custom number of minutes,
  or never. Counts time since you last used this Mac, so working in
  another app keeps the vault open.
- **Lock when this Mac sleeps or the screen locks**, **when switching
  users**, and **when Mill's window is minimized** — three checkboxes
  that close the vault regardless of idle time. The first two start on.
- **Ask for Touch ID, an Apple Watch, or your password before
  unlocking** — turn this on and Mill asks before the vault opens,
  naming only what this Mac can actually offer. Changeable only while
  the vault is unlocked.

The Secrets page's own status line states both halves in one
sentence — what it takes to unlock, and how long it stays open — and
links back here to change either one.

## Shortcuts

- **Global hotkey** — a shortcut that opens the Quick Panel from any
  app. Recording a combo captures it even if a menu shortcut already
  uses it; Escape cancels recording. The panel's own actions — Open
  Mill, Open Settings, Review, Apply from clipboard, and any update
  action currently available — match the command palette exactly,
  since both read from the same list.
- **Keyboard shortcuts** — every command Mill dispatches in its own
  window, grouped by where it applies (Everywhere, Atlas, Workflows,
  Review), bound commands first with the unbound rest tucked behind a
  closed "Unbound" row per group — search or the Unbound filter opens
  it. Filter by All, Bound, Unbound or Customised, or press the find-
  by-shortcut key icon and the combo itself to jump straight to
  whatever it's bound to. Click a combo to record a new one; Reset
  returns the default.

## Extensions

One list of everything that can put an object on the canvas: **Built
in** (grouped into Knowledge, Files and Drawing) and **Installed** —
the plugins in your plugins folder. Each row is the extension's icon,
its name, one line about it, and the switch that turns it on. Turning
one off hides its tray button (or, for file-backed types like Diagram
and Sheet, stops new ones landing on drop); objects already on the
board keep working. **Turn all off** flips every built-in at once.

Click a row to open its page beside the list. That page carries
everything else: the full description, what it adds (its commands,
canvas objects, workflow steps, views and captures), what it can reach
outside Mill, where it came from, and any settings it declares.

The Note offers **Rich code blocks**: turn it on and code fences in
notes get syntax coloring and the full code editor, starting the next
time a note opens for editing. The Sheet offers **Preview rows** and
**Preview columns**: how much of a spreadsheet shows on the board
before the "showing the first…" note. The Table offers **New grid
(experimental)**: tables render with the adopted spreadsheet grid
(keyboard navigation, range selection, copy and paste); column
editing stays in the current grid for now. Text and number settings
save when you press Enter or leave the field.

An installed plugin's page adds **Reload** — pick up an edit without
restarting Mill — and a **…** menu holding **Remove…**. Removing asks
first, then moves the plugin's folder to the Trash; objects it created
stay on the board as unknown kinds until it is installed again. Nothing
is deleted, so putting the folder back restores the plugin — it asks
to be allowed again, the way any newly installed plugin does. Plugins
that ship inside Mill have no Remove.

Above the list: **Open plugins folder** and **Reload all**. Copy a
plugin folder there, then reload it.

## Connections

Everything that reaches Mill from outside: paired devices and
browsers, MCP and webhook credentials, and the offline contract
export, in that order.

### Devices

Pair a device to reach Mill from your phone or another computer. This
Mac always has access. Other devices pair once, then stay connected
until you revoke them.

Select "Pair a device." A code appears with a Copy button. Enter this
code on the other device within 5 minutes — a live "Expires in m:ss"
countdown shows how long you have left. Once it runs out, select "Pair
a device" again for a fresh code.

If you already have a background Mill instance reachable from another
device, it asks for pairing the first time you reach it. A background
instance has no window to show "Pair a device" in, so it writes the
code to its own log instead, and keeps writing a fresh one on every
startup until a device pairs. Enter that code on any device the same
way, within 5 minutes. Once a device is paired, the instance stops
writing codes to its log.

Locked out of a background instance with no paired device left? Stop
it, delete its saved device list, and start it again — it writes a
fresh code to its log.

Each paired row shows when it was paired and last seen, a pencil to
rename it, and Revoke to disconnect it immediately.

On a phone or another computer's browser tab, turn on "Notify me on
this device" to get a notification when a decision needs your action.
Your browser asks for permission the first time. Notifications only
appear while that tab isn't in view, and clicking one takes you to the
item waiting for you. If notifications are blocked, turn them back on
in your browser's site settings.

### Browsers

Mill can replay a recorded flow in your own browser, signed in as you
already are. Load Mill's extension into your browser as an unpacked
extension.

Select "Reveal the extension folder" to find it on disk. Mill address
shows the address the extension connects to, with a Copy button.
Opening it in a browser shows Mill's connection page.

Select "Pair a browser." A code appears with a Copy button. Enter this
code in the browser extension's popup within 5 minutes.

Each paired browser shows Connected or Waiting to connect, when it was
paired and last seen, and Revoke to disconnect it. "Test the
connection" replays a short flow in a paired browser to confirm the
whole path works.

### Credentials

Credentials other tools use to reach Mill.

MCP access sets the address MCP clients connect to — changes take
effect after you restart Mill. "Allow MCP clients to import data" lets
a connected client create workflows, integrations, lists and servers;
reading never includes secrets. With imports allowed, "Ask me before
each MCP import" makes each import wait for your approval, then time
out after 2 minutes.

Webhooks let a tool on this Mac fire a workflow by posting to Mill's
hook endpoint with a token. Select "Add webhook token," name the tool
that will send it, then select "Mint token." The token shows once —
copy it now, Mill never shows it again. Each row shows when it was
minted and Revoke to disconnect it.

### Contract

Export the full step catalog, every data schema, and this app's
version as one file, or the skill doc that explains how to work with
Mill — for an agent that can't reach Mill over MCP.

## Notifications

- **Away after (seconds)** — how long this Mac sits idle before a
  parked decision follows you with a floating approval prompt. Losing
  focus entirely always counts as away.
- To get alerts that stay on screen rather than banners that
  auto-dismiss, allow them in System Settings > Notifications > Mill.

## Backups

Mill snapshots your workflow history, settings, extension source catalog,
and your secrets
vault automatically — on clean shutdown, on version change, and daily
via the built-in "Backup Mill data" workflow, keeping the most recent
ten. "Back up now" adds one on demand. "Export everything" bundles
your data into one file for moving machines, excluding the vault;
"Import everything" merges it back. The export covers Mill's own
data — files mirrored from folders on disk are referenced by path,
not copied in, so back those folders up separately. Source state is stored
as `plugin-state/catalog.sqlite`, or as `plugin-state/legacy-marketplaces.json`
before its first migration. Export files report when this snapshot is
included. Import validates and retains it in the archive but does not apply
it over the source catalog currently open in Mill. Restoring brings back a
local backup's vault file; it opens if that vault's key is still on this device.

## Updates

One button drives the whole update flow, and its label always says what
it does next: "Check for updates" while idle, "Download vX and
install" once a newer version is found, and "Restart to update" once
it's ready to go — Mill never restarts on its own, so nothing happens
until you click it. The status line above it shows your current
version, release channel, and when Mill last checked. Running "Check
for updates" from anywhere — the command palette, the Quick Panel, or
here — always answers in the bottom-right corner: a brief "Checking
for updates…", then "You're up to date." when there's nothing new, or
a notice you can click through to this page if the check fails. The same
"Update available" notice in the bottom-right corner acts the same
way: click it to download directly, or click "Restart to update" once
it's ready — it never just opens Settings.

Click "What's new", next to the status line or on the notice's own
secondary link, to read the release notes for the version Mill most
recently found — grouped by version, with headings and lists rendered
normally instead of raw markdown. When you skip versions, What's new
lists each one. Before any check has found a new version, it explains
that and offers "Check for updates" right there.

Pick a release channel from the dropdown. Turning on "Check for and
download updates automatically" downloads a newer version in the
background as soon as it's found; the interval below it controls how
often Mill checks on its own — Hourly (the default), Daily, Weekly, or
Only when I check, which turns off the background check entirely and
leaves it to you. If a newer version shows up while an older one is
already downloaded and ready, Mill re-targets the newer one
automatically — restarting always applies the newest version Mill
knows about, never an older one left over from an earlier check.

Every update action is also available from the command palette (⌘K)
and the Quick Panel: search "update" to check, download and install,
or restart, whichever is currently possible.

Mill re-signs itself after each update with a signing identity unique
to your Mac, so permissions like Accessibility and Input Monitoring
stay granted across updates instead of asking again every time. **This
needs a one-time setup step before it takes effect** — open "How
updates stay trusted" below the update button and click "Trust Mill's
signing," then confirm with your Mac password or Touch ID when
prompted. Until you do this, updates still install normally, but Mill
can't re-sign itself yet — permissions behave the same as before (you
may need to re-grant them after an update, same as always) and Mill
tells you so after an update if that happened.

The first update after this landed still needs one fresh grant per
permission either way — macOS treats it as a new app once. If a
permission you already granted stops working (the summon hotkey goes
unresponsive, for example), open **System Settings → Privacy &
Security**, remove Mill from the affected permission, then add it
back. On a Mac where that doesn't help, clear the stale entry from
Terminal and re-grant from scratch:

```
tccutil reset Accessibility com.alicoding.mill
```

---


# Extending the canvas

Add one file under `frontend/src/atlas/tools/` and rebuild, and Atlas
has a new placeable tool — card, note, area, table, and image all work
exactly this way today, five self-registered files. (The drawing
tools — pencil, eraser, laser, shape — used to as well; they now ship
as the bundled Drawing runtime plugin, registered through the same
plugin door you can use.) This page is the contract that file has to
satisfy: how it gets discovered, what its declaration requires, and
which platform services its runtime code may call — and may not.

Two doors exist now. A **runtime plugin** — a folder with a manifest
and a `main.js`, copied into the app's plugins folder, no rebuild —
is the out-of-tree door, and [Install a plugin](install-a-plugin.md)
covers it end to end, including the `activate(api)` contract, drag
tools with style pickers and live previews, and the
guarded-capability model; [Plugin theming](plugin-theming.md) is how
whatever you draw follows the reader's color scheme. This page is the OTHER door: a compiled-in
tool built by editing Mill's own tree, the same way adding a workflow
step type does — fuller reach (custom React rendering, and any
platform hook the plugin surface doesn't carry yet) at the price of a
rebuild. The "Stability" section
below says exactly what is and isn't safe to build against either
way.

## How it loads

A canvas tool is one file, `frontend/src/atlas/tools/<id>Tool.ts`,
that:

1. Builds an object matching the `AtlasToolShape` type
   (`frontend/src/atlas/atlasNounRegistry.ts`).
2. Calls `registerNoun(thatObject)` at module scope — not inside a
   function, not conditionally. The call has to run the moment the
   module loads.

`frontend/src/atlas/atlasTools.ts` discovers every file matching
`tools/*.ts` via `import.meta.glob(..., { eager: true })` — a glob
over the filesystem, not a hand-maintained list a new tool has to be
appended to. `registerNoun()` throws immediately if two files claim
the same `id`, and a startup check
(`assertRegistryAgreesWithIdentity()`) fails hard if a tool has an
identity (`frontend/src/shared/atlasToolIdentity.ts`) but no
registered descriptor, or a descriptor but no identity — a half-wired
tool cannot exist silently.

A tool's own translated strings follow the same shape, one level down:
`frontend/src/locales/en/atlas/<id>.json` is merged into the single
`atlas` i18next namespace by `frontend/src/app/atlasLocaleMerge.ts`,
discovered the same glob-and-merge way. The merge refuses two files
declaring the same top-level key, so a new tool's own locale file can
never silently clobber another tool's strings.

**The declaration-vs-code split.** `AtlasToolShape` splits into inert
data, read before any of the tool's own code runs, and one runtime
function. Naming the split explicitly is what lets the registry get
checked and documented as data, table below included:

- **Inert declaration** — read before any of this tool's own code
  runs, and enumerable purely as data: `id`, `icon`, `label`,
  `shortcutKey`, `tray`, `interaction`, `styleDefaults`,
  `styleFields`, `lockable`, `resizable`, `boardNodeType`,
  `dragBand`. The whole "What is required" table below is this list.
- **Runtime code** — the one member that's a function, not data:
  `commit`, which shapes this tool's own placement input into the
  artifact the board persists.

## Where your tool appears

The board's creation dock shows seven buttons and never grows: Card,
Note, Area, Table, Media, Annotate, and More. A tool declaring `media`
or `annotate` joins that flyout; anything else is found by name in the
More panel, which searches every registered tool and lists the plugin
each one came from. Nothing is hidden by this — a tool in the panel
still arms exactly the way a dock button does, and still answers to its
own shortcut key.

## What is required

Every field on `AtlasToolShape` other than `commit` (documented above
as the one runtime-code member) is REQUIRED — never optional, never
inferred — so a tool that omits one fails to compile rather than
half-existing. `false`, `null`, and an empty array are legitimate,
honest answers for a field that doesn't apply to a given tool; they
are never omissions.

<!-- BEGIN GENERATED: noun declaration fields (source: frontend/src/atlas/atlasNounDeclarationFields.json) -->

| Field | Legal values | Meaning |
|---|---|---|
| `id` | one of the ids declared in shared/atlasToolIdentity.ts's ATLAS_TOOL_IDENTITIES array | the noun's stable identity. registerNoun() throws at module-eval time on a duplicate; assertRegistryAgreesWithIdentity() fails the build if an identity has no matching descriptor, or a descriptor has no matching identity. |
| `icon` | any icon component from @primer/octicons-react, typed as Icon | the glyph rendered on this noun's tray/palette button. |
| `label` | a string | the button/command text, as a LOCALE KEY (shared/copy.ts resolves it wherever a surface renders it). By convention every in-tree noun sources this from identityOf(id).commandLabel rather than restating it; a third-party noun may pass plain English, which copy() returns unchanged. NEVER read as this noun's row title in Settings > Extensions — see nounName below. |
| `nounName` | a string | the locale key for the bare noun a user would call this thing ("Card", "Pencil"), read only by Settings > Extensions' row title. Kept separate from label because label is a command verb phrase ("Add a card") and a row title needs the noun, not the verb. |
| `description` | a string, or omitted entirely | the locale key for a one-sentence, user-vocabulary summary of what this noun does. Read by Settings > Extensions' per-row disclosure; a noun that omits it falls back to its own label there. |
| `shortcutKey` | a single-character string, or null | the bare keypress that arms this tool from the board. null for a tool with no bare-key shortcut. |
| `tray` | 'quick' or 'palette' | which tray surface renders this tool's button. |
| `group` | 'objects', 'media', 'annotate', or 'embed' — REQUIRED, never optional | which creation-dock cluster this noun belongs to. The dock's visible buttons are fixed at seven and never grow: atlasToolPlacement.ts gives the four named object tools their own slots, collects 'media' and 'annotate' tools into their two flyouts, and leaves everything else — 'embed', and any further 'objects' tool — to be found by name in the dock's More panel. |
| `settings` | an array of {type, key, label, description, defaultValue} setting declarations — type is boolean | string (optional placeholder) | number (optional min/max/step) | enum (options: [{value, label}]) — or omitted entirely | the noun's own user settings (goal 0258): declared here, rendered generically inside its Settings > Extensions row, persisted centrally per extension id + key. defaultValue applies whenever the user has never touched the control; a stored value of the wrong type, or an enum value no longer among the options, falls back to it. Omitted means the row shows no settings block. A runtime plugin declares the same shape as manifest contributes.settings and reads it back through api.settings. |
| `interaction` | 'arm-then-click', 'pick-then-place', 'drag-to-draw', 'drag-to-erase', 'ephemeral-drag', or 'paste-or-drop' | the authoring gesture that places this noun. Must equal the same field on this id's own shared/atlasToolIdentity.ts entry — the registry's own agreement check cross-validates the two so they can never silently drift apart. |
| `styleDefaults` | a record of style-field key to value, or omitted entirely | session-only seed values for a freshly placed instance's style state (colour, size, ...). Never persisted document data — omit this field for a noun with no style surface rather than declaring an empty object. |
| `styleFields` | a readonly array of AtlasStyleField entries (atlasStyleVocabulary.ts's closed union: color, color-or-none, stroke-width, or shape-kind) — REQUIRED, never optional | this noun's own declared styleable properties. An empty array is the honest answer for a noun with no style surface at all. A non-empty array makes AtlasStylePanel.tsx render this tool's style picker automatically — no other file needs to name this noun's id. |
| `lockable` | boolean — REQUIRED, never optional | does re-clicking this tool's own already-armed tray button lock it for repeated placement, instead of disarming on the second click? Only meaningful for an arm-then-click tool; every other tool still declares it, always false. |
| `resizable` | boolean — REQUIRED, never optional | can a placed instance be dragged to a new size via the shared NodeResizer? A container that auto-fits its own children, or a tool that never persists a placed instance, both legitimately declare false. The conformance suite checks that a true answer is backed by a real `<NodeResizer>` in the renderer boardNodeType names. |
| `boardNodeType` | 'atlas-note', 'atlas-sticky', 'atlas-group', 'atlas-object', or null | which shared React Flow node component renders this noun's placed instance. null for a tool whose gesture never persists a renderable instance (eraser, laser). |
| `dragBand` | boolean — REQUIRED, never optional | only load-bearing when boardNodeType is 'atlas-object': does this noun's own content capture pointer events (a grid, a vendored pan/zoom viewer), so the shared renderer needs to add its own chrome band as the drag surface? A noun whose whole body already drags declares false, not omitted. |
| `boardObjectKind` | 'shape', 'image', 'ink', 'table', 'diagram', 'sheet', or null | the persisted BoardObject.Kind this noun's own placed instance carries, or null for a tool that never routes through the shared 'atlas-object' renderer. Not always equal to id — pencil's own placed instance is Kind 'ink' — so content resolution below keys off this field, read from object.Kind, never off id. |
| `content` | an object with Component (a React component accepting { object, mirrorVersion }), ariaLabelKey (a string), role ('img' or undefined) and input ('static' or 'interactive'), plus the optional members source, editRoute, shieldHintKey, overflowChip and extension — or null | this noun's own placed-instance content contribution. registerNoun() feeds it into the board-object content registry (atlasNounRegistry.ts's registerBoardObjectContent) whenever boardObjectKind is non-null, killing AtlasBoardObjectNode.tsx's former per-Kind hand branch. A tool-less noun (diagram, sheet, pdf) calls registerBoardObjectContent directly instead of declaring this field at all, since it has no AtlasToolShape to satisfy — it instead sets this same content shape's own optional `extension` member (icon, label, description, disableScopeNote, group) so Settings > Extensions can still render an honest, correctly-sectioned row for it. mirrorVersion bumps on a live disk change to a fileBacked Kind's own mirrored file (see fileBacked below) — a non-file-backed Component simply ignores it. The nested `input` member is the ONE input fact a noun declares about its face: 'static' means the canvas owns every gesture over it (a shape, an image), 'interactive' means the face scrolls, selects text or edits in place (a grid, an embedded viewer). Every canvas opt-out — the click shield, the wheel and drag opt-outs, the keyboard boundary — is derived from it plus the object's own state, across three states: idle (the canvas owns the wheel, the drag and the keys, and a shield takes the first click), selected (the face receives pointer events and keys, and a wheel over anything in it that really scrolls stays inside it), and editing (the face reports an open editor through onEditingChange and the board's shortcuts stand down). shieldHintKey is the locale key for what the chrome band's tooltip says while the face is idle (what that first click buys differs per noun: a diagram starts panning, a PDF starts scrolling, a table starts cell editing); overflowChip lets the shared chrome band carry a “Fit” chip whenever the face reports its content is larger than the object's box. |
| `capabilities` | a readonly array of strings, or omitted entirely | the external reach this noun's own manifest declares. No current noun sets it. Settings > Extensions' reach line reads this field directly, so a future noun's declared capabilities show up there with no other code change. |
| `fileBacked` | boolean — REQUIRED, never optional | does this noun's own placed instance read Payload.mirrorPath as a real external file (goal 0232's file-backed preview/open/watch contract)? true gets the shared live-watch subscription (its own content Component sees mirrorVersion bump) and the object.openInDefaultApp context-menu command uniformly, with no extra wiring of either. A noun with no boardObjectKind at all still declares it, always false. |
| `sticky` | boolean — REQUIRED, never optional | does this tool stay armed after a completed gesture (pencil/eraser/laser — repeated strokes/passes are the point), or disarm after one? useAtlasToolGesture.ts reads this to decide whether a gesture's own onEnd may call ctx.disarm/disarmUnlessLocked at all — a sticky tool gets no-ops for both. A non-drag tool still declares it, always false. |
| `gesture` | an object with onEnd (a function), plus optional onPoint/preview/fadeMs — or null | a drag-shaped tool's own pure behavior contribution to the ONE platform gesture engine (useAtlasToolGesture.ts): onEnd commits (or no-ops); onPoint accumulates live per-point state (eraser's own hit-testing); preview is a component rendered generically in one overlay slot; fadeMs makes an ephemeral tool's own points age out on a timer instead of clearing at pointerup. null for every tool whose interaction never drags. |

<!-- END GENERATED -->

**Your declaration must pass the conformance suite — it IS the
contract test**, the same way a compiler is the contract test for a
type. A new or changed tool has to keep these files passing:

- `frontend/src/atlas/atlasNounDeclarationFields.test.ts` — this
  page's own table stays exhaustive against the real `AtlasToolShape`
  type.
- `frontend/src/atlas/atlasArmConformance.test.ts` — a tool's
  `lockable` answer matches its actual arm/disarm behavior.
- `frontend/src/atlas/atlasBoardSurfaceConformance.test.ts` — a
  `resizable: true` answer is backed by a real `NodeResizer` in the
  renderer its `boardNodeType` names; a `boardNodeType: 'atlas-object'`
  tool keeps the shared drag frame band wired; the drag band is gated
  on `dragBand`, never rendered unconditionally; and two specific
  shared files (`AtlasCreationTray.tsx`, `AtlasStylePanel.tsx`) contain
  no tool-id branch.
- `frontend/src/atlas/atlasEditorBoundsConformance.test.ts` and
  `atlasSelectionRingConformance.test.ts` — the shared editor-bounds
  and selection-ring surfaces reach every registered tool, not a
  hand-picked subset.

None of these render anything — they read source files and the live
registry as data and assert against it (this repo's own
"static source-audit" pattern, documented in each test file's header).
A tool that satisfies the type checker and these tests is, by
definition, correctly wired.

## What platform APIs exist — and what you may not reach

A tool's own `commit` function, and any board-rendering code it needs
(shared node renderers, not the tool file itself — see "inherits for
free" below), may call:

- **Board object CRUD** — `AtlasService.CreateBoardObject`,
  `SetBoardObjectSize`, `SetBoardObjectPosition`, `MoveBoardObject`,
  `DeleteBoardObject`, `Objects` (generated bindings at
  `frontend/bindings/.../internal/services/atlassvc/atlasservice.ts`).
- **Mirroring and captures** — `AtlasService.SaveImageBytes` writes
  pasted or drawn bytes to a Mill-owned file and returns its path (used
  by `imageTool.ts` for a pasted clipboard image, and by the Drawing
  plugin's pencil for a baked stroke SVG); `ObjectMirrorContent` reads
  a mirrored file's bytes back for rendering; `RepickObjectMirror`
  re-points an existing object at a different local file.
- **The mirror-changed subscription** —
  `useAtlasMirrorChanged(id, onChange)`
  (`frontend/src/atlas/useAtlasMirrorChanged.ts`) fires whenever a
  given object's own mirrored file changes on disk, so a renderer can
  refetch instead of polling.
- **The style value store** — `useAtlasNounStyle(nounId)` /
  `useAtlasSetStyleValue()` (`frontend/src/atlas/atlasStyleValueStore.ts`)
  is the one generic, noun-agnostic store every `styleFields`-declaring
  tool reads and writes its session-only style defaults through — never
  a bespoke per-tool store.
- **Configure entities** — when a tool's own artifact needs a
  reusable "which external thing" reference rather than a one-off
  value (`.claude/rules/architecture.md`'s business-vs-integration
  test), it goes through `ConfigureService` the way `tableTool.ts`
  mints its backing List via `ConfigureService.CreateList` /
  `AddListRow`.
- **Input over the face — declared, not written.** A noun's `input`
  declaration (`'static'` or `'interactive'`, inside `content`) is the whole contract for
  what a wheel, a drag and a keystroke over the object do. Three states
  follow from it, and the shared renderer applies every one of them:

  | State | What owns input | What the object looks like |
  |---|---|---|
  | idle | the canvas: the wheel pans or zooms the board, a drag moves the object, keys reach the board | the face is inert behind a transparent shield; hovering draws a ring and the chrome band says what the first click buys |
  | selected | the face: it receives pointer events and keys, and every wheel over it stays inside it whether or not anything in it scrolls; the chrome band still drags the object and still pans the board | the selection ring and resize handles |
  | editing | the face's own editor: the board's shortcuts stand down until it closes | unchanged from selected |

  A `'static'` face is always idle — the canvas owns every gesture over
  a picture. A `'interactive'` one starts idle, and the first click on
  it selects the object rather than landing inside the face. While it
  is idle the chrome band's tooltip reads "Click to select, then scroll
  or edit."; a noun with a more specific answer sets `shieldHintKey` to
  its own locale key instead. A face with an editor of its own reports
  it through the `onEditingChange` prop the renderer passes down — one
  call when the editor opens, one when it closes — which is what puts
  the object into its editing state.

- **Selection and resize — inherited, not written.** Declaring
  `resizable: true` plus a `boardNodeType` is the entire cost: the
  shared renderer that `boardNodeType` names already carries a
  `NodeResizer` and selection highlighting for every tool routed
  through it. A tool's own `commit` function never calls
  `SetBoardObjectSize` or any resize RPC itself — that call lives
  entirely in the shared renderer, fired once, for every tool that
  opted in by declaring the field.

**What you may not reach, and what actually stops you.** Compiled-in
TypeScript has no process boundary and no sandbox — nothing here is
enforced the way a browser extension's content-script isolation is.
Each line below names the real mechanism, honestly, rather than
implying a barrier that doesn't exist:

- **The workflow/composition domain** (`frontend/src/composition/`,
  and its Go counterpart `internal/domain/composition`) — enforced by
  `frontend/.dependency-cruiser.cjs`'s `atlas-must-not-depend-on-composition`
  rule, run by both Lefthook and CI's `boundaries` job. A real import
  across that line fails the build.
- **The `configure/`, `views/`, and `app/` bounded-context folders**
  your tool file has no reason to import from directly — same
  dependency-cruiser config, the `domain-folders-must-not-depend-on-views-or-app`
  and related rules.
- **The Go kernel itself** — durable execution, the guardrail engine,
  the composition graph engine. There is no TypeScript enforcement here
  because none is needed: a tool's runtime code can only call whatever
  a generated `*service.ts` bindings file happens to export, and Wails
  only generates a binding for a service's own exported Go method.
  The kernel is unreachable by construction — no RPC exists to call —
  not because a rule blocks it.
- **Other tools' own private implementation modules** — files inside
  `frontend/src/atlas/` that are not one of the APIs named above (for
  example `atlasBuildBoardObjectNodes.ts`'s internal z-order table, or
  the per-Kind branches inside the shared `AtlasBoardObjectNode.tsx`
  renderer). **This is enforced by review only.** TypeScript has no
  module-private keyword, and nothing stops a `tools/<id>Tool.ts` file
  from importing any other file under `frontend/src/atlas/` at all —
  the conformance suite above only checks two specific shared files for
  one specific bad pattern (a hardcoded tool-id branch), not every
  possible reach into implementation detail. Staying inside the surface
  documented above, once you're inside the same folder, is a norm this
  repo's reviewers hold, not something the compiler holds for you.
- **A duplicate `id`** — enforced by `registerNoun()`'s own runtime
  throw and `assertRegistryAgreesWithIdentity()`'s startup check (see
  "How it loads").

## Stability

**Stable today:** the declaration fields on `AtlasToolShape` (the
table above) and the conformance suite that checks them. Both have
already absorbed nine tools' worth of real additions without
structural change, and any drift is caught immediately — the suite
breaks the moment a field's meaning or a renderer's contract changes
underneath an existing tool.

**Not promised for compiled-in tools:** no semver, no deprecation
window, and no compatibility guarantee on the *runtime* API —
`commit`'s own signature shape, the exact set of `AtlasService` RPCs a
tool may call, or any shared renderer's internal behavior. Every
compiled-in adopter is one of Mill's own files, changed in the same
pull request as any platform change that affects it, so nothing here
needs to stay backward compatible.

**Promised for runtime plugins:** the plugin surface — the manifest
schema, the `activate(api)` shape and its argument's methods, the
`renderFace` contract, and the payload keys each `source` implies — is
versioned against **Mill's own version**, the way desktop app-plugin
ecosystems converge on versioning against the app rather than a
separate API number. A plugin pins the Mill it needs with
`minMillVersion` in its manifest; a Mill older than that refuses to
load it, saying so on its Extensions row, instead of half-running it
against a surface it predates. Within versions that satisfy the
minimum, an existing manifest field or `api` method keeps its meaning
— growth is additive.

---


# Register a canvas tool

A walkthrough of adding a new placeable tool to Atlas, using Mill's own
simplest real one — `card` — as the worked example. Read "Extending the
canvas" first for the full field-by-field contract this guide only
walks through in order; that page's table is the reference, this one is
the tutorial.

## 1. Pick a stable id

Every tool needs an id already declared in
`frontend/src/shared/atlasToolIdentity.ts`'s `ATLAS_TOOL_IDENTITIES`
array — this is where the id, its command label key, its bare-key shortcut
(if any), and its authoring gesture (`interaction`) live, shared by both
the tool file and the identity-agreement check that fails the build if
the two ever disagree.

## 2. Write one file

`frontend/src/atlas/tools/<id>Tool.ts` builds an object matching
`AtlasToolShape` and calls `registerNoun(...)` on it at module scope.
Below is Mill's own card tool, quoted whole — every declaration field
`AtlasToolShape` requires, answered honestly (`false`/`null`/`[]` where
a field doesn't apply, never omitted):

<!-- BEGIN GENERATED: frontend/src/atlas/tools/cardTool.ts -->

```ts
import { FileIcon } from '@primer/octicons-react'
import type { Kind } from '../../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
import { identityOf, registerNoun, type AtlasToolShape } from '../atlasNounRegistry'
import { lastUsedKindID } from '../atlasCreateHelpers'

const cardIdentity = identityOf('card')

export interface AtlasCardArtifact { kind: 'card'; kindID: string; title: string; note: string }

// Card's instant-placement default (goal 0144: the click IS the
// creation, no form) resolves the last-used kind itself; a form-driven
// create (right-click "Add card", paste, slot-link) instead supplies
// kindID/title explicitly and this just shapes them into the same
// artifact -- one function backs both placement doors.
export const cardTool = {
  id: cardIdentity.id,
  icon: FileIcon,
  label: cardIdentity.commandLabel,
  nounName: 'atlas:cardNoun.name',
  description: 'atlas:cardNoun.description',
  shortcutKey: cardIdentity.shortcutKey,
  tray: 'quick',
  // The atom a board is made of -- typed, linked, filed, searchable --
  // and so one of the four fixed object slots on the creation dock
  // (goal 0355).
  group: 'objects',
  interaction: cardIdentity.interaction,
  // Instant placement (goal 0144) always disarms after the one click --
  // never reads a lock flag at all, so this stays false rather than N/A.
  lockable: false,
  // Rendered by AtlasNoteCardNode ('atlas-note'), whose own NodeResizer
  // is the general card resize (goal 0193).
  resizable: true,
  boardNodeType: 'atlas-note',
  // Not routed through the shared 'atlas-object' renderer -- always
  // false, not N/A (atlasNounRegistry.ts's own header comment).
  dragBand: false,
  // No boardObjectKind means no content registration reads this at
  // all -- always false, not N/A.
  fileBacked: false,
  // Rendered by AtlasNoteCardNode, not the shared 'atlas-object'
  // content contract -- always null, not N/A.
  boardObjectKind: null,
  content: null,
  // No style surface of its own (goal 0209) -- always empty, not
  // omitted.
  styleFields: [],
  // Never drags -- placed by a single click (useAtlasCreation.ts's
  // placeAt), so this is never read at all; false, not N/A.
  sticky: false,
  gesture: null,
  commit: (input: { kinds: Kind[]; kindID?: string; title?: string; note?: string }): AtlasCardArtifact => ({
    kind: 'card',
    kindID: input.kindID ?? lastUsedKindID(input.kinds),
    title: input.title ?? 'Untitled',
    note: input.note ?? '',
  }),
} as const satisfies AtlasToolShape

registerNoun(cardTool)

```

<!-- END GENERATED -->

`commit` is the one field on this shape that's runtime code, not inert
data — it shapes the placement input into the artifact the board
persists. Everything else here is read before any of this tool's own
code runs.

## 3. Nothing else to wire

`frontend/src/atlas/atlasTools.ts` discovers `tools/*.ts` by glob, so
there is no registry array to append to by hand. Add a translated
string file at `frontend/src/locales/en/atlas/<id>.json` if the tool
needs its own copy.

## 4. Satisfy the conformance suite

Run the frontend tests. A new tool has to keep
`atlasNounDeclarationFields.test.ts`, `atlasArmConformance.test.ts`,
`atlasBoardSurfaceConformance.test.ts`,
`atlasEditorBoundsConformance.test.ts`, and
`atlasSelectionRingConformance.test.ts` passing — see "Extending the
canvas" for what each one actually checks. None of them render
anything; they read your declaration and the live registry as data.

---


# Register a command

A walkthrough of adding a new command to Mill's registry, using the
vault lock/unlock commands as the worked example — small, self-contained,
and demonstrating every field a command typically needs. Read
"Commands" first for the full generated list of what's registered
today; this page is the tutorial for adding to it.

## 1. Decide where it lives

`frontend/src/shared/commands.ts` holds the `COMMANDS` array directly
for commands with no natural grouping; anything with 2+ related
commands splits into its own satellite file under `shared/` (the
500-line file convention) and spreads into `COMMANDS`. Below is one
such satellite file, quoted whole:

<!-- BEGIN GENERATED: frontend/src/shared/secretsCommands.ts -->

```ts
import type { Command } from './commands'
import { entityContext } from './commandContext'
import { SecretService } from './bindings'
import { useAppStore } from './store'
import { useUISignalStore } from './uiSignalStore'
import { refreshVaultBackupTime, refreshVaultStatus, useVaultStatusStore } from './vaultStatusStore'
import type { UserError } from './userError'
import { userErrorFrom } from './userError'
import { writeClipboardText } from './clipboardWrite'
import { toReference } from './secretReference'

// The vault lock/unlock/reset actions (goal 0222 S1's own state door,
// vaultStatusStore.ts) -- split out of shared/commands.ts (CLAUDE.md's
// 500-line convention), spread into its COMMANDS array. The view's own
// buttons call these (findCommand(id)?.run()) instead of their own
// SecretService calls, so a palette or keyboard invocation performs the
// exact same action. None needs input: any passphrase-equivalent lives
// behind the system authentication sheet, not a typed field.
//
// Every run() records its outcome in the store (goal 0330). A rejected
// unlock used to end in console.error, which left the Unlock button
// looking like it did nothing at all on a device whose stored key does
// not open the vault file.
function record(promise: Promise<unknown>): void {
  const { setVaultError } = useVaultStatusStore.getState()
  setVaultError(null)
  promise
    .then(refreshVaultStatus)
    .catch((err) => { setVaultError(userErrorFrom(err)) })
}

export const SECRETS_COMMANDS: Command[] = [
  {
    // Copy reference (goal 0408 S2, the 1Password "copy secret
    // reference" precedent): every row -- a vault entry or a source's
    // own key -- copies the portable string ("vault:<id>" or
    // "env:<source>/<KEY>") a Configure field or a plugin's secretRef
    // consumes, never the value itself, so this needs no gate the
    // value-copy actions already carry. Named `secret.copyReference`
    // (not the family's `secret.row.*` namespace) since it acts on
    // every row identically, vault or source.
    id: 'secret.copyReference',
    label: 'commands.secret.copyReference',
    defaultBinding: null,
    needs: 'entity',
    enabled: (ctx) => entityContext(ctx, 'secret') !== null,
    run: async (ctx) => {
      const target = entityContext(ctx, 'secret')
      if (!target) return
      await writeClipboardText(toReference(target.id))
    },
  },
  {
    // Restore (goal 0406 S2): the Trash section's own row/bulk action --
    // moves a trashed vault entry back to its original group.
    id: 'secret.restore',
    label: 'commands.secret.restore',
    defaultBinding: null,
    needs: 'entity',
    enabled: (ctx) => entityContext(ctx, 'secret') !== null,
    run: async (ctx) => {
      const target = entityContext(ctx, 'secret')
      if (!target) return
      await SecretService.RestoreSecret(target.id)
    },
  },
  {
    // Delete forever (goal 0406 S2): permanently removes a TRASHED
    // entry -- reachable only from the Trash section's own row menu
    // (the confirm asking about it lives on that row's own menuAction,
    // shared/secretsTrashRowItems.ts) and the selection bar's
    // list.destroySelection.
    id: 'secret.destroy',
    label: 'commands.secret.destroy',
    defaultBinding: null,
    needs: 'entity',
    enabled: (ctx) => entityContext(ctx, 'secret') !== null,
    run: async (ctx) => {
      const target = entityContext(ctx, 'secret')
      if (!target) return
      await SecretService.DestroySecret(target.id)
    },
  },
  {
    // Show Trash (goal 0406 S2): the delete toast's own action --
    // navigates to the Secrets view's Trash section.
    id: 'secret.showTrash',
    label: 'commands.secret.showTrash',
    defaultBinding: null,
    run: () => { useAppStore.getState().setView({ kind: 'secrets', tab: 'trash' }) },
  },
  {
    // "Find .env files…" (goal 0367): opens the Sources section's scan
    // dialog from anywhere. Navigation first, the set-then-consume
    // signal second -- the section may mount fresh on that navigation,
    // and the signal is what the freshly-mounted view consumes. Quick
    // Panel's own row overrides run() (panel window ≠ main window).
    id: 'secrets.findDotenvFiles',
    label: 'commands.secrets.findDotenvFiles',
    defaultBinding: null,
    quickPanel: true,
    run: () => {
      useAppStore.getState().setView({ kind: 'secrets', tab: 'sources' })
      useUISignalStore.getState().requestSecretsDotenvScan()
    },
  },
  {
    // The vault seat's anchor (goal 0335): its own `menu` fixes the
    // seat's File-menu position, but shared/menuSpec.ts's seatOverrides
    // (shared/vaultSeat.ts) decide which of lockVault/unlockVault
    // actually shows there and with what label/enablement.
    id: 'secrets.lockVault',
    menu: { path: 'file', group: 4, order: 0 },
    label: 'commands.secrets.lockVault',
    defaultBinding: null,
    enabled: () => useVaultStatusStore.getState().vaultStatus?.Unlocked === true,
    run: () => { record(SecretService.LockVault()) },
  },
  {
    id: 'secrets.unlockVault',
    label: 'commands.secrets.unlockVault',
    defaultBinding: null,
    enabled: () => {
      const status = useVaultStatusStore.getState().vaultStatus
      return status !== null && status.Exists && !status.Unlocked
    },
    run: () => { record(SecretService.UnlockVault()) },
  },
  {
    id: 'secrets.resetVault',
    label: 'commands.secrets.resetVault',
    defaultBinding: null,
    // Only offered where the current file cannot be opened at all --
    // the stored key does not fit it, or there is no key for it here.
    // Anywhere else this would discard a readable vault.
    enabled: () => {
      const { vaultStatus, vaultError } = useVaultStatusStore.getState()
      if (vaultStatus === null || !vaultStatus.Exists || vaultStatus.Unlocked) return false
      return vaultErrorKind(vaultError) === 'keyMismatch' || vaultErrorKind(vaultError) === 'noKey'
    },
    // Destructive, and only meaningful with the locked view's own
    // failure on screen to explain what it replaces.
    paletteHidden: true,
    run: () => { record(SecretService.ResetVault()) },
  },
  {
    id: 'secrets.restoreVaultFromBackup',
    label: 'commands.secrets.restoreVaultFromBackup',
    defaultBinding: null,
    // Only offered where the key-mismatch state itself shows: a stored
    // key that doesn't fit the current file, AND a local backup that
    // still carries one to try instead.
    enabled: () => {
      const { vaultStatus, vaultError, vaultBackupTime } = useVaultStatusStore.getState()
      if (vaultStatus === null || !vaultStatus.Exists || vaultStatus.Unlocked) return false
      return vaultErrorKind(vaultError) === 'keyMismatch' && vaultBackupTime?.present === true
    },
    run: () => {
      useVaultStatusStore.getState().setVaultError(null)
      SecretService.RestoreVaultFromLatestBackup()
        .then(() => Promise.all([refreshVaultStatus(), refreshVaultBackupTime()]))
        .catch((err) => { useVaultStatusStore.getState().setVaultError(userErrorFrom(err)) })
    },
  },
]

// vaultErrorKind classifies a lock/unlock failure by the stable code
// the Go error declares (secretsvc's ErrKeyMismatch and friends). The
// code is what never changes; the wording stays on this side, where it
// can be translated, instead of being pinned to a Go sentence.
export type VaultErrorKind = 'keyMismatch' | 'noKey' | 'cancelled' | 'authUnavailable' | 'other' | 'none'

const KIND_FOR_CODE: Record<string, VaultErrorKind> = {
  'key-mismatch': 'keyMismatch',
  'no-vault-key': 'noKey',
  'unlock-cancelled': 'cancelled',
  'auth-unavailable': 'authUnavailable',
}

export function vaultErrorKind(error: UserError | null): VaultErrorKind {
  if (!error) return 'none'
  return KIND_FOR_CODE[error.code] ?? 'other'
}

```

<!-- END GENERATED -->

## 2. Fill in the `Command` shape

- `id` — a stable, namespaced string (`secrets.lockVault`, not
  `lockVault`) — namespacing by the area it belongs to is the
  convention every existing command follows.
- `label` — the locale key for what the palette and Settings show, not
  the sentence itself. Add the English under `commands.` in
  `frontend/src/locales/en/common.json`; `commandLabel()` resolves it
  wherever a surface renders the command.
- `defaultBinding` — a `KeyCombo`, or `null` for a command with no
  keyboard shortcut by default.
- `enabled` — omit for a command that's always valid. Provide a
  function when the command only makes sense in a specific state (here,
  only when the vault exists and is/isn't already unlocked) — never
  guard inline inside `run()` and return silently; an unavailable
  command is omitted from the palette entirely, not shown disabled.
- `run` — the actual action. Calls a generated service binding
  directly when the command needs no input, the same shape both
  commands above use.

## 3. Add it to `COMMANDS`

Spread your new array into `frontend/src/shared/commands.ts`'s
`COMMANDS` export (`...SECRETS_COMMANDS` is how the file above joins
in) — this is the one array every surface (palette, keyboard dispatch,
Settings' rebinding list, and the Quick Panel for anything opting into
`quickPanel: true`) reads from.

## 4. Verify

`frontend/src/shared/commands.test.ts` covers the dispatch contract
generically; add a case there if your command's `enabled` predicate has
real branches worth pinning. From the repository root, regenerate the
TypeScript registry declaration first, then the user docs that consume it:

```sh
npm --prefix frontend run docs:commands
go generate ./internal/docsgen
```

Inspect and commit both generated outputs so "Commands" picks up the new row.

---


# The plugin standard

Every plugin that ships with Mill follows these rules, and the
conformance check enforces the ones a machine can. Follow them and
your plugin feels like part of Mill. Bringing a plugin over from
another platform? Start with [Port an extension from another
platform](port-a-vscode-extension.md).

Rule numbers are stable diagnostic identities. Add a new rule with a new
number; do not renumber existing rules, because conformance and install
messages use these numbers to lead an author back here. Retired numbers stay
unused.

## Configuration

1. Declare every setting in the manifest's `configuration` key, with a
   type, a default and a one-sentence description. (checked) `settings`
   still loads as a deprecated alias; renaming to `configuration` clears
   the warning.
2. Settings render in Mill's Settings; a plugin never builds its own
   settings page. (review)
3. Request only the capabilities and hosts you use. (checked: an
   unused declared capability warns)

## Interaction

4. Every action is a command declared in the manifest
   (`contributes.commands`) and registered with the same id; tools
   reference declared commands. (checked) A command may also seat
   itself in Mill's menu bar with `menu: { path, group?, order? }` --
   `path` is `"workflow"`, `"atlas"` or `"help"` only, never one of
   Mill's own menus. (checked) A ported manifest's own
   `contributes.menus` is accepted too, mapped onto whichever of
   Mill's seats it names; see [the porting
   guide](port-a-vscode-extension.md#menu-ids-and-mills-seats).
   (checked)
5. Ship no default hotkey; people bind their own in Settings ›
   Shortcuts. (checked: the SDK has no hotkey field; this rule
   documents why)
6. Use only the documented theme variables ([plugin
   theming](plugin-theming.md)); no colour literals. (checked)
7. Add the minimum persistent chrome: a face, a view or a capture only
   when the task needs one. (review)
8. No promotion, ads or upgrade prompts anywhere. (review)
9. Report a failure through `api.notify` with one actionable sentence;
   `console.error` only alongside it, never instead. (checked: a
   `console.error` with no `api.notify` in the same function warns)
10. One narrow purpose per plugin. (review)

## Contracts

11. `id` is a kebab-case slug distinct from `name`; `name` contains
    neither "Mill" nor "plugin". (checked)
12. `version` is semver; `minMillVersion` names the oldest Mill you
    support. (checked)
13. `icon.png` (128×128) is present and declared as `icon`;
    `icon@dark.png` is optional. (checked)
14. `README.md` sits beside the plugin folder in your repository,
    never inside it (a plugin folder holds only files Mill serves); it
    says what the plugin does, its settings and the capabilities it
    needs. (checked for the examples: `examples/plugins/<id>.md`)
15. No remote code, no self-update, no telemetry: `fetch` only through
    `api.fetch`, no `import()` of a URL, no `eval`. (checked)
16. Labels and messages use sentence case; no emoji in labels.
    (checked)
17. Payload keys are camelCase; command ids are `<plugin>.<verb>`;
    tool names are `verb_noun`. (checked)
18. SDK comments and your README describe behaviour for plugin
    authors: no repository vocabulary (goal ids, internal file
    names). (checked over the generated reference)
19. A theme you contribute is a CSS file of nothing but
    `--token: value;` declarations, every token drawn from the
    documented theme variables: no selector, no at-rule, no `url()`.
    Mill layers it over the built-in palette of the family you name,
    so declare only what you change. (checked)

21. A view, capture or canvas object with its own UI declares an entry
    page: `"entry": "view.html"` beside the view, capture or canvas
    object in your manifest, pointing at an `.html` file inside your
    plugin folder.
    Mill mounts it in a sandboxed frame where your page owns every
    element, and the page loads scripts, styles, fonts and images only
    from that folder, so ship what it needs beside it. Your script
    goes in a `.js` file the page loads with `<script src>`: an inline
    `<script>` or an `onclick` attribute never runs. `window.
    acquireMillApi()` is its door back to Mill. Styles may stay
    inline. A canvas object's page receives the object as its context
    and writes back through `object.updatePayload`; its face is
    always interactive. A canvas object may still draw into Mill's own
    document instead (`renderFace`, the deprecated form) — see rule 32
    for why a view or capture may not. (checked)

22. A canvas object whose face reports an open editor declares
    `content: "interactive"` on the same object. `content` says what
    happens to input over the face: `"static"` (the default) leaves
    every gesture to the canvas, `"interactive"` gives the selected
    face the wheel outright — a scroll over it never also moves the
    board — along with the drag and the keys, and `ctx.setEditing`,
    the call that stands Mill's own board shortcuts down while your
    editor is open, exists only there. The chrome band above the face
    keeps panning the board in every state. (checked: a face script
    calling `setEditing` without that declaration warns)

23. An MCP server you ship (`contributes.mcpServers`) declares a slug
    `id`, a `label`, a `command` and its `args`; every secret it needs
    is `"secretRef:<setting key>"` naming one of your own secretRef
    settings, never a literal, and never a vault entry — a literal
    under a name that looks like a credential is refused. (checked)

24. No code built at run time: no `eval`, no `new Function`, no
    `import()` of a web address, no `<script src>` loading from the
    web. This covers every `.js` and `.html` you ship, a bundled
    library included. Checked when the plugin is installed, and a hit
    refuses the install. (checked)
25. Every web address written into your own code names a host you
    declared under `contributes.network`; a `*.example.com`
    declaration covers its subdomains. An undeclared host refuses the
    install with *Reaches <host> without declaring it.* Addresses in
    comments, the XML namespace host and loopback do not count; an
    address inside a `vendor/` folder is noted to the person
    installing rather than refused. (checked)
26. Ship code a reader can read: a `.js` over 50 KB carries a
    `//# sourceMappingURL`, no base64 blob over 8 KB, no long line of
    near-random characters. A hit never refuses; the install prompt
    and the Verification tab say *Contains code Mill can't read
    easily.* and the person decides. (checked: warns)

## Publishing

27. A marketplace is a repository or folder with `.mill/marketplace.json`
    at its root: `{ "name", "owner": { "name", "url"? }, "plugins":
    [ { "id", "name", "description", "version", "kinds"?, "sha256"?,
    "source" } ] }`. `name` is a slug; `mill` is reserved for the
    extensions Mill ships. A `source` is `{ "kind": "path", "path" }`
    (a folder beside the index), `{ "kind": "github", "repo", "ref"? }`
    or `{ "kind": "archive", "url", "sha256"? }`. Two entries may not
    share an id. (checked when the marketplace is added)
28. A release is a git tag equal to the version (`v1.2.0` or `1.2.0`)
    whose assets include `<id>-<version>.zip` — the plugin folder,
    zipped, with `manifest.json` at its root or one folder down — and
    `SHA256SUMS`; sign the zip with minisign as `<zip>.minisig` when
    you can. Mill fetches the asset by that name, for an install and
    for an update.
29. Declare the archive's `sha256` in your marketplace entry. What Mill
    checked is the badge every installed extension wears: **Verified**
    when the hash matches and a key the user trusts signed it,
    **Hash-pinned** when only the hash matches, **Unverified** when
    nothing declared a hash (a branch archive always lands here, and
    the user must acknowledge it), **Dev** for a folder on their Mac.
    A hash that does not match refuses the install.

## Quality gates

30. `go run ./internal/pluginconform <folder>` passes; `npm run
    plugin:typecheck` and `npm run plugin:lint` pass. (checked)

## Board views

31. A view placed in the Atlas board's own switcher
    (`"placement": "board-switcher"`) that writes card fields through
    `api.content.setCardFields` declares the `edit-card-fields`
    capability. (checked)

## Sandboxed activation

32. `main.js` itself activates inside a sandboxed frame, the same
    isolation an entry page gets — unless your manifest declares a
    canvas object, which still activates alongside Mill's own document
    until Mill ships a framed canvas API. Because of that, a view or
    capture must declare an entry page (rule 21): Mill can no longer
    draw one in its own document, so a view or capture with no entry
    page refuses the install. `registerCommand`, `registerView` and
    `registerCapture` work the same either way — write one `main.js`
    for both. (checked: refuses the install)

## Entity references

33. An `entityRef` setting names a known `entityKind` — one of the
    Configure entity kinds the picker supports (`request`, `list`,
    `mcpserver`, `workflow`, `workflow-scope`, `decision`, `execenv`,
    `environment`, `aiprovider`, `conversionprofile`, `atlas-kind`,
    `atlas-linkkind`); an unknown or missing `entityKind` blocks the
    load. The stored value is the picked entity's id, chosen through
    the same picker a workflow node's own reference field uses.
    (checked)

## Menu when clauses

34. A `contributes.menus` item seated on `editor/context` or
    `view/title` declares a `when` clause; one that declares none
    shows everywhere, so say `when: "true"` if that is the intent.
    (checked, advisory)
35. A `when` clause that reads `plugin.<key>` names a key your own
    scripts actually write with `api.context.set(key, value)` (or the
    entry-page/framed equivalent, `call('context.set', key, value)`)
    somewhere — a key you never set stays permanently falsy. (checked,
    advisory)

## Output

36. Present output, never type it: show a result through
    `api.ui.renderOutput`, which gives the reader the same tree,
    table, log, rendered view, Find, Copy and Raw every other output
    surface in Mill has. Never a `<pre>` or a text box of your own —
    a text box says the reader can edit what they are reading.
    (review)

## Context keys

Your own plugin can contribute facts its declarative expressions read: call
`api.context.set(key, value)` — from `main.js` directly, or from a
framed entry page's `acquireMillApi().call('context.set', key,
value)` — and `plugin.<key>` reads it back. A command's optional
`enablement` expression controls whether the command can run from any
surface. Each menu item's separate `when` expression controls only
whether that item appears in its own seat; it never changes the
command's global availability. Command enablement can read the current
`selectionCount` and the calling plugin's own context keys.

`value` can be `null`, a string, a finite number, a boolean, or a flat
array containing those scalar values. Mill copies arrays when they are
stored. Context lasts for one activation and is cleared when the plugin
is disabled, removed, reloaded, or activated afresh. A key already
starting with `"plugin."` is refused, since that prefix is added
automatically wherever the fact is read back. Unknown facts and invalid
expressions fail closed.

## SDK conveniences

The SDK carries a few small helpers so a plugin never re-invents them:
`api.ui.el(tag, attrs, children)` builds one DOM element the safe way —
never `innerHTML`, so nothing you pass can inject markup — for the
rest of your face's own layout (rule 36 still governs presenting a
*result*, through `api.ui.renderOutput`). `api.fetchJSON(url, init?)`
is `api.fetch` plus a JSON parse, answering `{ ok, status, data,
errorText }` and never throwing, not even for a denied request or a
non-2xx response. `api.storage.pushList(key, item, { dedupeBy?, max?
})` and `api.storage.getList(key)` are sugar over `get`/`set` for a
request-history or cache-list. `api.convert.markdownToHtml(markdown)`
is `htmlToMarkdown`'s reverse direction, the same sanitized renderer.
`api.formatDate(iso, style)` formats a timestamp the way Mill's own
interface does (`'relative'`, `'short'` or `'long'`) instead of a
plugin's own `Date` math. Every one of these is optional — hand-rolling
the same shape yourself still works, it's just more code.

## Checking your own plugin

```sh
go run ./internal/pluginconform path/to/your-plugin
cd frontend && npm run plugin:typecheck
cd frontend && npm run plugin:lint
```

The first prints every failure and warning it finds, naming the rule
above it enforces. A failure blocks shipping; a warning is your call —
the check tells you why the rule exists, not just that you broke it.

See [Install a plugin](install-a-plugin.md) for the full authoring
guide and [the plugin API reference](plugin-api/index.md) for every
type. [Plugin API maturity](plugin-api-maturity.md) lists each
contribution family's current level and its evidence, generated fresh
from this repository on every build.

---


# Port an extension from another platform

This guide names VS Code because its manifest vocabulary is the one
Mill's own manifest deliberately reads close to. The pattern applies
just as well coming from any extension platform: some portable logic
can be reused, while calls into another platform's host need an adapter
or a Mill implementation. No other platform's extension host runs
inside Mill.

## How much of an extension actually ports

Classify each piece of what you are bringing over before you start
rewriting anything:

1. **Pure data, no runtime port** — a settings schema or color theme is
   plain JSON with nothing running behind it. Compatible declarations
   can move into a Mill manifest. A standalone color-theme file can go
   through **Extensions > Import theme**, where Mill maps the interface
   colors it understands and reports which source keys it used.
2. **A declared field with code behind it** — a command, a
   configuration entry — the field NAME maps across, but the function
   that runs when it fires does not exist on this platform. You write
   that function against Mill's own plugin API; the manifest only
   tells Mill the function exists and what it is called.
3. **Logic with no declarative shape at all** — parsing a file format
   or rendering a live-editing surface has nothing to map in the
   manifest. Portable parsing or transformation logic may be reused;
   integration with the editor, storage, network, or interface uses
   Mill's SDK and guarded host calls.
4. **No plugin at all** — sometimes an extension's entire job reduces
   to "call one API with these saved settings." That is a Configure
   entity plus a workflow step, not an extension, on either platform.

## Field by field

| Source field | Mill field | Treatment | Why |
| --- | --- | --- | --- |
| `contributes.configuration` | `contributes.configuration` | Accepted as-is | Same shape: a typed setting with a default and a description. |
| `contributes.commands` | `contributes.commands` | Accepted as-is | Same shape: an id, a label, and an optional global `enablement` expression. The function it runs is rewritten (see below). |
| `contributes.menus` | `contributes.menus` | Mapped and rendered | A foreign menu id renders on the Mill surface that plays the same role — see the seat table below. An id with no equivalent is accepted and ignored, never a load failure. |
| `commands.executeCommand('setContext', key, value)` | `api.context.set(key, value)` | Adapted | Same job — an extension contributing a fact its own declarative expressions read back — over Mill's own door: `api.context.set` from `main.js`, or `call('context.set', key, value)` from a framed entry page. Read back as `plugin.<key>` in command `enablement` or a seated item's `when`. |
| `contributes.views` | `contributes.views` | Mapped, narrower | Both declare an id, a title, and where the page's own code lives; Mill has no nested view-container tree — every view is a flat work tab. |
| `contributes.viewsContainers` | — | Not supported | Mill's own chrome (the sidebar's fixed sections) is not a plugin-extensible tree; a view still declares which existing tab it opens in. |
| `contributes.themes` | `contributes.themes` | Adapted | Mill themes are CSS token declarations. Importing a standalone JSON/JSONC color theme maps a fixed set of interface colors, keeps all other colors at Mill defaults, and does not import syntax highlighting. |
| `activationEvents` | — | Not supported, by design | Mill activates every enabled extension at boot, always. There is no lazy-activation contract to port; see below. |
| `keybindings` | — | Not supported, by design | Mill never ships a default hotkey with an extension; the person using Mill binds their own in Settings. See below. |
| `contributes.languages` / `grammars` | — | Not supported | Mill has no text-editor surface an extension can extend syntax highlighting inside. |
| `contributes.debuggers` / `taskDefinitions` | — | Not supported | No matching surface exists in Mill today. |

### Menu ids and Mill's seats

| Source menu id | Mill seat |
| --- | --- |
| `commandPalette` | Already true for every command Mill knows about; declaring it does nothing extra. |
| `editor/context` | Renders in the canvas object's own right-click menu, after a separator. Its `when` clause decides when the item shows, over facts Mill computes about the right-clicked object and the selection: `objectKind`, `objectPluginId`, `hasFile`, `hasSize`, `editRoute`, `selectionCount`, `selectionKinds`, every payload key as `payload.<key>`, and any key your own extension set with `api.context.set` as `plugin.<key>`. An item that should always show says so with `when: "true"`. |
| `view/title` | Renders as an icon-only action in the work tab's title area. Its `when` clause is evaluated against `viewId` and any `plugin.<key>` your own extension set. The command's separate `enablement` expression still decides whether the command can run from any surface. |
| any other id | Accepted and ignored — named once in the extension's status so you know it was silently dropped, never a load failure. |

## Three ported jobs, classified

- **A request-and-response extension** (send an HTTP request, apply
  saved headers and a timeout, show the response): its settings and
  its command are field 2 above — the names carry over, the send/parse/
  render logic is written fresh against Mill's own guarded fetch and
  output viewer. Mill ships exactly this rewrite as one of its own
  bundled examples, so you can read a finished one rather than
  starting from a blank file.
- **A single command with no interface of its own**, whose entire job
  is "call this one API using a saved setting": this is field 4 —
  build a Configure connector entity and a workflow step instead of an
  extension. Nothing about it needs plugin code on either platform.
- **A plugin whose whole job is reformatting text live inside another
  app's own text editor**, with full access to that editor's internal
  state: there is no equivalent surface to extend inside Mill, so
  nothing here maps or ports. If the underlying job is spreadsheet- or
  table-shaped, check whether Mill's own sheet object already covers
  it before writing anything.

## What stays deliberately absent

**Keybindings.** Mill never ships a default hotkey for anything it
runs, extensions included — every shortcut in Mill is bound by the
person using it, in Settings, and an extension's own commands bind the
same way. Bringing over a suggested keybinding would be the one thing
in the whole manifest that quietly overrides someone's own keyboard,
so it is left out on purpose.

**Lazy activation.** Some platforms only start an extension once its
declared trigger fires (opening a matching file, running its command
for the first time). Mill activates every enabled extension once, at
boot, and keeps it running — there is no partial-boot state for a
manifest to declare into, so `activationEvents` has nothing to map
onto.

## What the code side is reauthored against

Whichever surface the extension's code targeted, the replacement is
written against one of three doors, never a copy of the original
runtime:

- **`activate(api)`** — the entry point every extension's `main.js`
  exports, receiving the one object every capability arrives through
  (registering commands, views, canvas objects, reading settings,
  making a guarded request).
- **A declared script module** (`steps.js`, `secrets.js`) — for a
  workflow step or a secret source the extension contributes, run in
  Mill's own sandboxed engine rather than the extension's own process.
- **A framed entry page** — for a view, a capture, or a canvas
  object's own face, an ordinary HTML/JS page mounted in its own
  sandbox, talking back to Mill only through the same `api` handle.

---


# Plugin theming

A plugin's face, view, or capture is drawn inside Mill, so it should
look like Mill — in light and dark, and in every color scheme Settings
offers. You don't ship a palette. You read the variables Mill sets, and
your surface follows the user's choice everywhere it changes.

## The mount root tells you the theme

Mill puts two attributes on the element it hands you:

- `data-mill-theme` — `light` or `dark`. Always one of the two, never
  "auto": it is the settled answer, already resolved from the user's
  choice and the system preference.
- `data-mill-scheme` — the exact scheme, such as `light`, `dark_dimmed`,
  or `light_high_contrast`.

Both update in place when the user changes the theme, so plain CSS is
enough for a dark variant:

```css
.my-panel {
  background: var(--bgColor-default);
  color: var(--fgColor-default);
  border: 1px solid var(--borderColor-default);
}

[data-mill-theme="dark"] .my-panel {
  box-shadow: none;
}
```

## In JavaScript

Every context object carries the same pair, plus a change feed:

```js
export function activate(api) {
  api.registerView({
    id: 'my-view',
    render(el, ctx) {
      draw(el, ctx.theme)               // { mode: 'dark', scheme: 'dark_dimmed' }
      ctx.onThemeChange((theme) => draw(el, theme))
    },
  })
}
```

`onThemeChange` returns an unsubscribe function. Use it when your
surface paints pixels it can't restyle with CSS — a canvas, a chart, a
generated image. Everything else should use the variables and need no
JavaScript at all.

## The variables you may rely on

These are the names Mill promises. They are defined in every scheme.
You may define and read variables of your own on top of them; what the
conformance check refuses is reading one that is neither on this list
nor defined anywhere in your plugin.

Mill's own:

| Variable | Use |
| --- | --- |
| `--mill-accent-emphasis` | strong accent fill, with `--fgColor-onEmphasis` on top |
| `--mill-accent-fg` | accent text and links |
| `--mill-accent-muted` | subtle accent tint behind content |
| `--mill-accent-border-muted` | subtle accent border |
| `--mill-kind-trigger` | the trigger step color |
| `--mill-kind-capture` | the capture step color |
| `--mill-kind-process` | the process step color |
| `--mill-kind-apply` | the apply step color |
| `--mill-kind-decision` | the decision step color |
| `--mill-kind-terminal` | the terminal step color |
| `--mill-mono` | the monospace stack for machine-readable text |

From the design system Mill's own interface is built on:

| Variable | Use |
| --- | --- |
| `--fgColor-default` | body text |
| `--fgColor-muted` | secondary text |
| `--bgColor-default` | the surface behind your content |
| `--bgColor-muted` | a recessed or secondary surface |
| `--borderColor-default` | dividers and outlines |
| `--fgColor-accent` | accent text and links |
| `--bgColor-accent-emphasis` | a strong accent fill |
| `--fgColor-onEmphasis` | text and icons painted on any emphasis fill |
| `--borderColor-accent-emphasis` | the border of an accent fill |
| `--fgColor-danger` | an error message or a destructive action |
| `--fgColor-attention` | a warning message |
| `--fgColor-success` | a success message |

## What the check enforces

Run it over your folder before you ship:

```
go run ./internal/pluginconform path/to/my-plugin
```

- Reading a `--` variable that is neither on the list above nor defined
  by your own files **fails** — it may be undefined in some schemes, or
  vanish in a later release.
- A hardcoded color — `#1f6feb`, `rgb(31, 111, 107)` — **warns**. A
  literal is right for content the user authored (a pen color, a chart
  series). It is wrong for your surface's own chrome, which stops
  matching Mill the moment the theme changes.

A folder named `vendor/` is skipped: a bundled third-party engine
brings its own palette and its own variable names, and neither is your
plugin's chrome.

## Shipping a theme of your own

A plugin can contribute whole color themes, and a theme is data rather
than code: a CSS file holding nothing but declarations, listed in your
manifest.

```json
"contributes": {
  "themes": [
    { "id": "sepia", "label": "Sepia", "family": "light", "file": "themes/sepia.css" }
  ]
}
```

```css
/* themes/sepia.css */
--bgColor-default: #f6efe2;
--fgColor-default: #3a3026;
--fgColor-accent: #8a5a1f;
```

`family` says which appearance the theme belongs to, `light` or `dark`.
Mill layers your file over that family's built-in palette, so you
declare only the tokens you change and every other token keeps a value
that already works.

The file may contain only `--token: value;` declarations of the
variables listed above, plus comments. A selector, an at-rule, a
`url()`, or a variable outside the list is refused, and the check names
the line. Your theme then appears in Settings > Appearance under its
family, with your plugin's name beneath its label.

Turning your plugin off or removing it takes its themes with it, and
the appearance falls back to Mill's own.

## Importing a standalone JSON theme

If you have an unchanged JSON or JSONC color-theme file, open
**Extensions**, choose **Import theme**, then choose the original file.
Mill shows its proposed name and appearance before it writes anything.
Files without a recognized `type` need an explicit Light or Dark choice.

The compatibility report names the source interface colors Mill can use,
the unsupported colors it leaves unmapped, and supported keys whose values
are not valid hex colors. Importing creates a local data-only extension: it
does not run source code or load another file named by the theme. You still
review and Allow the extension in Extensions before the theme appears in
Appearance.

The import is a snapshot. Mill preserves the original file bytes and records
their SHA-256 hash with the mapper version. It maps only interface colors to
the variables listed above. Syntax highlighting, token colors, semantic token
colors, selectors, URLs, and executable extension code are not imported. Any
Mill token the source does not cover keeps the selected light or dark family's
built-in value.

To update the snapshot, remove the imported extension and import the new file.
Turning it off or removing it makes Appearance fall back to a built-in theme;
turning it on again makes the imported theme available without changing the
preserved source file.

---


# Managed extensions

An organisation decides which extensions Mill may install and run on a
Mac by placing one file on it. Mill reads the file every time it lists,
loads or installs an extension, so a change takes effect on the next
reload with nothing to restart, and nothing in Mill's own settings can
loosen it.

## The policy file

```
/Library/Application Support/Mill/plugin-policy.json
```

The file is system-wide, so device management can deploy it like any
other managed preference. Setting `MILL_PLUGIN_POLICY=<path>` makes
Mill read a different file instead — the way to try a policy before
rolling it out, and what tests and server mode use.

Every key:

| Key | Meaning |
| --- | --- |
| `version` | `1` for legacy name-based source rules, or `2` for canonical origin rules. |
| `managedBy` | The organisation's name, shown in the banner above Extensions. |
| `allow` | Rules naming the extensions that may install and run. Once the list has any entry, it is exclusive: anything it does not name is blocked. |
| `block` | Rules naming extensions that may never install or run. A block always wins over an allow. |
| `requiredTier` | The lowest trust tier an extension may wear: `"verified"`, `"hash-pinned"`, or `"any"` (the default). Checked when installing and every time extensions load. |
| `blockedCapabilities` | Capabilities no extension may declare: `fetch`, `write-content`, `open-url`, `open-app`, `list-files`, `read-file`, `erase-board-items`. An extension declaring one is blocked. |
| `allowedSources` | Version 1 only. Marketplace names and address prefixes installs may come from. |
| `sources` | Version 2 only. Exact rules for `bundled`, `github`, `url`, `path`, or `theme-file` origins. |

A policy cannot mix `allowedSources` and `sources`. Omitting `sources` in a
version 2 policy leaves origins unrestricted. An explicit empty `sources`
list denies every external, theme-file, and included-example origin; Mill's
kernel components remain available.

A version 2 source rule is `{ "kind", "locator", "ref", "artifactOrigins" }`.
`bundled` and `theme-file` use only `kind`. GitHub uses a lowercase
`owner/repository` locator and an optional case-sensitive ref. URL locators
match the exact canonical index address, including path and query. Path
locators are absolute and include only files below that root after symlinks
are resolved. `artifactOrigins` lists exact scheme, host, and port roots that
may serve downloads. Mill checks the source and every redirect before making
the request. An allowed source does not by itself verify its publisher or
files; tier, signature, capability, and consent checks still apply.

A rule in `allow` or `block` is `{ "id", "publisherKey", "versions" }`,
with at least one of `id` (the extension's id) or `publisherKey` (the
minisign public key that signs it). `versions` narrows the rule to a
range — `"^1.2"`, `">=1 <2"` — and a version Mill cannot read counts as
blocked, never as allowed.

## What people see

- Extensions shows **Managed by <organisation>** above every tab.
- A blocked extension stays in the Installed list, marked **Blocked by
  your organisation's policy**, and its details say why: the block
  list, the required tier, a blocked capability. It never runs.
- Installing a blocked extension stops at the prompt with the same
  reason, before anything downloads or lands.
- The **Verification** tab says whether the policy allows or blocks the
  extension.
- **Settings › Security** shows the whole policy, read-only.

## When the file cannot be read

A present file that is not valid blocks every extension that is not
built into Mill, and Extensions says so: *The extension policy file
can't be read. Ask your administrator.* The detail is in Mill's log.
This is deliberate — a broken policy closes the door rather than
opening it.

## Examples for a bank

Only two named extensions, both required to be signed and verified,
from the bank's own marketplace:

```json
{
  "version": 2,
  "managedBy": "Example Bank",
  "allow": [
    { "id": "bank-reconcile", "publisherKey": "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3" },
    { "id": "bank-tickets", "versions": "^2" }
  ],
  "requiredTier": "verified",
  "sources": [
    {
      "kind": "url",
      "locator": "https://extensions.example-bank.test/marketplace.json",
      "artifactOrigins": ["https://downloads.example-bank.test"]
    }
  ]
}
```

Anything may install, but nothing that reaches the network:

```json
{
  "version": 2,
  "managedBy": "Example Bank",
  "blockedCapabilities": ["fetch"]
}
```

One extension is blocked below a fixed version:

```json
{
  "version": 2,
  "managedBy": "Example Bank",
  "block": [{ "id": "acme-notes", "versions": "<1.4.0" }],
  "requiredTier": "hash-pinned"
}
```

## What an install checks

Every install also reads the extension's files before enabling it,
policy or not:

- Code that builds code at run time — `eval`, `new Function`, an
  `import()` of a web address, a script tag loading from the web — is
  refused.
- A web address written into the extension's own code whose host it
  never declared under `contributes.network` is refused: *Reaches
  <host> without declaring it.* Comments, the XML namespace host and
  loopback addresses do not count; an address inside a bundled library
  (a `vendor/` folder) is noted rather than refused.
- Code Mill cannot read easily — a large script with no source map, an
  embedded base64 blob, a long line of near-random characters — is
  noted, not refused: *Contains code Mill can't read easily.* The note
  shows in the install prompt and again on the Verification tab.

A refused install leaves nothing on disk.

Version 1 remains supported for existing deployments. Its
`allowedSources` entries can match marketplace names or address prefixes,
so a name alone does not prove where an installed extension came from.
Move to version 2 by replacing each name with its explicit `bundled`,
`github`, `url`, `path`, or `theme-file` origin. When a version 2 policy
restricts sources, a legacy installation without recorded origin evidence
is refused with **Source could not be verified. Reinstall this extension
from an allowed source.**

---


# Plugin API maturity

3 of 13 contribution families are stable; 0 ready to promote; 0 regressed.

| Family | Level | Conformance | Example | E2E | Docs | SDK types | MCP | Flags |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| canvasObjects | experimental | no | yes | yes | yes | yes | yes | — |
| steps | experimental | no | yes | yes | yes | no | yes | — |
| captures | experimental | no | no | yes | yes | yes | n/a | — |
| settings | stable | yes | yes | yes | yes | yes | n/a | — |
| configuration | experimental | no | yes | no | yes | no | no | — |
| menus | experimental | no | no | yes | yes | no | no | — |
| network | experimental | no | yes | yes | yes | yes | n/a | — |
| views | experimental | no | yes | yes | yes | yes | n/a | — |
| commands | stable | yes | yes | yes | yes | yes | yes | — |
| themes | stable | yes | yes | yes | yes | yes | n/a | — |
| secretSources | experimental | no | yes | yes | yes | yes | n/a | — |
| tools | experimental | no | yes | no | yes | no | yes | — |
| mcpServers | experimental | no | yes | no | yes | no | no | — |

## How a family moves

A family's level changes only by a decision recorded in an architecture record (ADR-0047, ADR-0048), never by this table alone, however complete its evidence reads. "Ready to promote" is an argument for that decision, not the decision itself. This table regenerates from the repository on every `go generate ./internal/docsgen` and is checked against the committed copy on every build. The control room dashboard renders each family's code/docs currency and a live "days behind" figure derived from it; this page never carries either, so the committed file never depends on the checkout it was generated from.

---


# Automate with agents

Point an MCP-capable agent at Mill's address, and it can compose
workflows, run them, inspect results, and read and write Atlas — the
same things you can do, under the same guardrails. Its writes still
park for your approval; nothing it does skips the gate a human action
would hit.

## Connect

Settings → MCP access shows the address your agent connects to and
whether the server is enabled. Point any MCP-capable client at it —
for example, an MCP client's own server-list entry:

```
{ "mcpServers": { "mill": { "url": "http://127.0.0.1:8090/mcp" } } }
```

Your instance's exact address is the one Settings shows.

## What an agent gets

- **Tools** to list, author, run, and step-debug workflows; read and
  write Configure entities; read and write Atlas cards.
- **Resources** describing the live registry — every step type with
  its typed input/output contract, effect class, and config fields —
  the same contract the canvas enforces, machine-readable.
- **Deny-by-default access.** Nothing works until you grant it in
  Settings → MCP access, and writes stay off until you turn them on
  separately from reads.

## See every call

Activity's MCP calls section logs every call — an agent calling Mill,
or a workflow calling a connected server — with who called what, when,
and whether it succeeded. Filter by direction or tool name to find one
fast; a failed call's error is copyable in one click.

## No MCP connection available

A tool that can't hold an MCP connection open can still start a
workflow over a plain HTTP request — see [Fire a workflow from a
webhook](../how-to/webhooks.md).

## Teach your agent

`skills/mill-use` in the repository is a ready-made agent skill:
connection steps, the tool vocabulary, the contract semantics, and
the run-inspect-fix loop. Give it to your agent and it knows the
platform; `userdocs/llms-full.txt` carries this entire documentation
set in one AI-readable file.

---


# Edit a diagram with an agent

A diagram on your board is a real file, and the file is the diagram.
An agent connected over MCP reads its shapes by id and changes exactly
the ones it names — nothing else in the file moves. Your other pages,
your layers, your styling and every id you already arranged all stay
put, because nothing is regenerated.

Every change still parks for your approval before it touches the file.

## Read the shapes first

`atlas_read_diagram` answers with the diagram's pages, the layers on
the page it read, and every shape and connector on it:

```
{
  "format": "drawio",
  "pages": [{ "id": "page1", "name": "Runtime path" }],
  "activePage": "page1",
  "layers": [{ "id": "1", "name": "", "visible": true }],
  "cells": [
    { "id": "2", "kind": "vertex", "label": "Gateway",
      "style": "rounded=0;whiteSpace=wrap;html=1;",
      "parent": "1",
      "geometry": { "x": 120, "y": 120, "width": 160, "height": 60 } }
  ]
}
```

Those ids are what every other tool takes.

## Add a box, connect it, rename it

Add a shape and the connector joining it to the one already there:

```
atlas_diagram_add_cells {
  "objectId": "<the diagram's board object id>",
  "cells": [
    { "kind": "vertex", "label": "Ledger",
      "geometry": { "x": 360, "y": 120, "width": 160, "height": 60 } },
    { "kind": "edge", "label": "writes to", "source": "2", "target": "<the new id>" }
  ]
}
```

The call answers with the ids the new cells landed under — Mill mints
one for any cell that didn't bring its own. Use the returned id to
rename it later:

```
atlas_diagram_edit_cells {
  "objectId": "<the diagram's board object id>",
  "patches": [{ "id": "<the new id>", "label": "Ledger service" }]
}
```

Only what a patch names changes. Geometry merges coordinate by
coordinate, so moving a shape leaves its size alone.

Removing it takes the connector with it, and the answer says which
connectors went:

```
atlas_diagram_delete_cells {
  "objectId": "<the diagram's board object id>",
  "ids": ["<the new id>"]
}
```

## Bring in a whole diagram

`atlas_diagram_import` takes a mode:

- **add** merges the incoming shapes into a page and re-mints any id
  that collides with one already there, reporting the map.
- **new-page** files the incoming diagram as its own page.
- **replace** overwrites the file. Everything already in it — ids,
  layers, other pages — is gone.

Prefer add or new-page. Replace is the only mode a Mermaid diagram
accepts, because Mermaid has no per-shape ids to edit against.

## Start a new diagram

`atlas_create_board_object` puts one on the board. A diagram (or a
sheet) can carry its content inline, and Mill writes the file for it:

```
atlas_create_board_object {
  "kind": "diagram",
  "payload": { "title": "Runtime path" },
  "content": "<mxfile>…</mxfile>"
}
```

Every other file-backed object points at a file that already exists,
through `payload.mirrorPath`.

## What you see while it happens

Each write shows up as one approval in your words — "Add 2 shapes to
Runtime path" — in Review and in the banner. Approve it and the board's
own picture updates on the spot. If you have the diagram open in the
editor, the change lands in it too, without losing what you were in the
middle of.

Turn the whole thing off in Settings → MCP access; writes are off until
you turn them on.

---


# Edit a sheet with an agent

A sheet on your board is a real CSV file, and the file is the sheet.
An agent connected over MCP reads its cells by range and changes
exactly the ones it names — nothing else in the file moves.

Every change still parks for your approval before it touches the file.

## Read the cells first

`atlas_sheet_read_range` answers with the values in a range (or the
whole sheet, when no range is given) as rows of cells, plus how many
rows and columns the sheet currently holds:

```
atlas_sheet_read_range { "objectId": "<the sheet's board object id>" }

{
  "range": "A1:C3",
  "values": [
    ["Item", "Qty", "Notes"],
    ["Beans", "2", ""],
    ["Rice", "5", "bulk"]
  ],
  "dimensions": { "rows": 3, "cols": 3 }
}
```

Naming a range reads just that rectangle:

```
atlas_sheet_read_range { "objectId": "<id>", "range": "A2:B3" }
```

A cell address is a column letter then a row number, the same way a
spreadsheet addresses one — `B2` is column B, row 2. A range joins two
corners with a colon: `B2:D5`.

## Change a cell or several

`atlas_sheet_edit_cells` changes one or more cells by address in a
single call:

```
atlas_sheet_edit_cells {
  "objectId": "<the sheet's board object id>",
  "edits": [
    { "address": "B2", "value": "3" },
    { "address": "C2", "value": "on sale" }
  ]
}
```

Only the named cells change. Naming a row past the sheet's current
end grows it, padding the cells in between with empty values — the
same thing typing past a row's end does in a real spreadsheet.

## Start a new sheet

`atlas_create_board_object` puts one on the board. A sheet (or a
diagram) can carry its content inline as CSV, and Mill writes the file
for it:

```
atlas_create_board_object {
  "kind": "sheet",
  "payload": { "title": "Groceries" },
  "content": "Item,Qty\nBeans,2\n"
}
```

Only a CSV-backed sheet can be read or edited this way. A sheet backed
by a binary spreadsheet file reads its bytes whole through
`atlas_read_board_object` instead — re-create it as CSV to make it
agent-editable.

## What you see while it happens

Each write shows up as one approval in your words — "Edit 2 cells in
Groceries" — in Review and in the banner. Approve it and the board's
own picture updates on the spot.

Turn the whole thing off in Settings → MCP access; writes are off until
you turn them on.

---


# Edit a spreadsheet file with an agent

A sheet on your board can be backed by a real spreadsheet file — the
file is the sheet, formulas and formatting included. An agent
connected over MCP reads its cells (and any formulas) by range and
changes exactly the cells it names — every other cell, every style,
every other sheet in the file stays exactly as it was.

Every change still parks for your approval before it touches the file.

## Read the cells first

`atlas_xlsx_read_range` answers with the values in a range (or the
whole sheet, when no range is given), plus each cell's own formula
where it has one:

```
atlas_xlsx_read_range { "objectId": "<the sheet's board object id>" }

{
  "sheet": "Sheet1",
  "range": "A1:C3",
  "values": [
    ["Item", "Qty", "Total"],
    ["Coffee beans", "2", "9"],
    ["Oat milk", "1", "3"]
  ],
  "formulas": [
    ["", "", ""],
    ["", "", "B2*4.5"],
    ["", "", "B3*3"]
  ],
  "dimensions": { "rows": 3, "cols": 3 }
}
```

A cell address is a column letter then a row number, the same way a
spreadsheet addresses one — `B2` is column B, row 2. A range joins two
corners with a colon: `B2:D5`. `sheet` picks which sheet by name;
leave it out for the workbook's first sheet.

A formula's value is never recalculated here — it reads back whatever
was last cached. Formulas recalculate when the file next opens in a
spreadsheet app.

## Change a cell or several

`atlas_xlsx_edit_cells` changes one or more cells by address in a
single call. Each edit names exactly one of `value` (literal text) or
`formula` (a computed cell):

```
atlas_xlsx_edit_cells {
  "objectId": "<the sheet's board object id>",
  "sheet": "Sheet1",
  "edits": [
    { "address": "B2", "value": "3" },
    { "address": "D2", "formula": "B2*4.5" }
  ]
}
```

Only the named cells change. Every other cell, every style, every
merged range and every other sheet in the file comes back unchanged.

## What you see while it happens

Each write shows up as one approval in your words — "Edit 2 cells in
Orders" — in Review and in the banner. Approve it and the board's own
picture updates on the spot.

Turn the whole thing off in Settings → MCP access; writes are off until
you turn them on.

---


# Add a row with an agent

A List is a reusable table other workflows and boards read from. An
agent connected over MCP can read one's full contents and append a new
row to it — never overwrite or reorder a row already there.

Every write still parks for your approval before it touches the List.

## Read the List first

`export_list` answers with a List's full definition — its label,
declared columns, and every row:

```
export_list { "id": "<the List's id>" }

{
  "label": "Groceries",
  "columns": [
    { "key": "item", "label": "Item", "type": "text" },
    { "key": "qty", "label": "Qty", "type": "text" }
  ],
  "rows": [{ "id": "row-1", "values": { "item": "Beans", "qty": "2" } }]
}
```

The column keys are what `list_append_row` takes.

## Append a row

`list_append_row` adds one new row, keyed by the List's own column
keys:

```
list_append_row {
  "listId": "<the List's id>",
  "row": { "item": "Rice", "qty": "5" }
}
```

The new row lands at the end. Every row already there — its values,
its order — stays exactly as it was.

## Start a new List

`import_list` mints a brand-new List from an exported-list JSON
definition (the same shape `export_list` above returns) — it never
overwrites an existing one. Use `list_append_row` to grow a List that
already exists.

## What you see while it happens

Each write shows up as one approval in your words — "Append a row to
Groceries" — in Review and in the banner. Approve it and every open
view of the List updates on the spot.

Turn the whole thing off in Settings → MCP access; writes are off until
you turn them on.

---


# What plugins expose to agents

See [the plugin standard](../reference/plugin-standard.md) for how a
tool-declaring plugin is expected to behave.

A plugin extends what you can do in Mill. Declaring a tool is what
extends what an **agent** can do — the same action, reached from the
other side. Nothing a plugin builds is automatically callable: the
plugin author names the tools they want reachable, and you decide
whether that plugin runs at all.

## See what is installed

`list_plugins` answers with every plugin, whether it is turned on, and
what it contributes:

```
[
  { "id": "mill-textcase", "name": "Text case", "version": "1.0.0",
    "enabled": true,
    "contributions": {
      "canvasObjects": [], "commands": [], "steps": ["text-case"],
      "tools": ["change_text_case"], "views": 0, "captures": 0 } }
]
```

A plugin you turned off is still listed, with `enabled: false`, so an
agent can tell "turned off" from "not installed". It contributes
nothing callable while it is off.

## Call a plugin's tool

Every reachable tool appears in the tool list as
`plugin_<pluginId>_<toolName>` — `plugin_mill-textcase_change_text_case`
for the example above. The arguments are the plugin author's own: they
wrote the tool's input contract, and the agent reads it directly.

Turning the plugin off in Settings › Extensions removes its tools from
the list straight away. Turning it back on, or reloading it after an
edit, puts them back. No restart either way.

## What a plugin's steps and kinds look like

`list_step_types` includes every step a turned-on plugin contributes,
marked `"source": "plugin:<pluginId>"`. Mill's own steps carry no
source. An agent authoring a workflow composes both the same way.

`atlas_list_kinds` reports `boardObjectKinds`: every canvas noun
`atlas_create_board_object` accepts, with the same `source` marking on
the ones a plugin contributes. An agent can put a plugin's own object
on your board.

## How a write parks

A tool the author declared as a write goes through exactly the gate
every other Mill write takes. It needs **Allow MCP clients to import
data** turned on in Settings, and then it parks for your approval — the
prompt shows the author's own sentence and what this call would do it
with:

```
Clips a page onto the board. -- url: https://example.com
```

Approve it and it runs; deny it and nothing happened. A read-effect
tool answers straight away and never parks.

A plugin's own guarded actions — opening a URL, reading a folder,
writing content — still take their own guardrail check when they run.
Being reachable by an agent never widens what a plugin may do.

Discussion

Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.

Posts are public.Sign in to post

No one has posted yet. Be the first.