Every cahier in one place, reachable from the desktop sidebar.
A cahier is a question the agent asks a human — a served HTML page (or a chat
question) whose answers land in an inbox and a SQLite table. Cahier is the
index: one sidebar row → /cahiers → every live and pending cahier with its
saves, deadline, PIN state and served link, grouped by profile ▸ project.
Read one here (framed in the workspace pane, browser one click away), copy
its link, filter by scope, or say which profile/project a cahier belongs to.
Reads are read-only by construction. The page renders whatever
cahier_ctl.iteration() returns — the same call the CLI's list uses — so the
page and the terminal can never disagree about what a cahier is. Grouping only
buckets that order; it never re-sorts or re-counts it. Zero API keys, zero model
tokens.
Three writes exist, and a human makes each. The ✎ on a row POSTs /filing to
record the profile and project behind a cahier; the buttons under each row's
status POST /action to spin it up, pause it or close it — all go
through the control plane (cahier_ctl.set_filing, cahier_ctl.spin),
slug-validated, atomic, and audit-lined in cahier-control.log. Nothing else on
the page writes anywhere.
One row, three moves. A row's badge is its lifecycle — serving (on the
bridge), paused (off the bridge on purpose, still active and startable) or
closed (done) — and the buttons under it are exactly the moves that state
allows: serving → Pause + Close, paused → Spin up + Close, closed →
Spin up. Pause and close are the same file move; only the registry note tells
them apart, which is why a paused row is one click from being served again while
a closed one is finished. Close asks once (it ends the row's life); Spin up
and Pause are one click, and every refusal is a 4xx with a reason and nothing
moved. The phase (live / pending / finished) reads on the left of the row,
so a badge never has to mean two things.
| Path | Role |
|---|---|
plugin.yaml |
Plugin manifest: config_schema (the Desktop settings form), version, tags |
__init__.py |
Agent-side stub — registers no tools/hooks by design |
dashboard/manifest.json |
Dashboard half: label, icon, API file, hidden tab |
dashboard/plugin_api.py |
JSON backend (reads + the two human writes), mounted at /api/plugins/cahier/ |
desktop/plugin.js |
Sidebar row + /cahiers page: grouping, in-window viewer, ✎ filing (runs inside the Electron app) |
lib/cahier_ctl.py |
Bundled control plane, so a fresh install has one before it has its own |
scripts/doctor.py |
Prints the install checklist (GET /doctor from a terminal) |
tests/ |
Backend contract suites + panel render harness |
| Route | Returns |
|---|---|
GET /health |
Control-plane load status and which copy, settings + the layer each came from, bridge port, counts |
GET /doctor |
The install checklist: ok/warn/fail per check, each fail carrying its own fix |
GET /list?scope=active|paused|finished|all |
Rows + counts + integrity warnings + the folder vocabulary (what the page fetches) |
GET /cahier?slug=<slug> |
One cahier, plus its latest saves |
POST /filing |
Records {slug, profile, project} (empty string = forget). 404 on an unknown slug, 400 on bad input, 503 when the control plane is missing |
POST /action |
{slug, action: "up"|"pause"|"close"} — spin a cahier up, pause it (off the bridge, still active), or close it (off the bridge and done; down is kept as an alias of close). Same refusals, same 503 |
Every route sits behind the dashboard's own auth, so a bare curl from the host
gets 401; the app's ctx.rest carries the session token.
active = on the bridge right now (serving). paused = off the bridge on
purpose (paused). finished = done or never published (finished, stopped,
closed, unknown). all = everything. Unknown scopes fall back to all
rather than erroring — a stale bookmark should never show a broken page.
The two vocabularies are deliberately separate: lifecycle is what the
buttons act on and what the badge says, state is the phase the control plane
derives from the deadline and the answer table (live = deadline armed,
pending = nobody has answered, finished = answered, stopped = parked). A
served page that already has answers is finished by state and serving by
lifecycle — both true, and no longer one word asked to mean both.
Settings are read from four layers, most specific first. GET /doctor names the
layer every value came from.
| Layer | How | Use it for |
|---|---|---|
CAHIER_<KEY> environment variables |
CAHIER_BRIDGE_PORT=9000 |
ops, containers, systemd |
plugins.entries.cahier.settings in $HERMES_HOME/config.yaml |
the Desktop → Capabilities → Plugins settings form, driven by config_schema |
anyone using the app |
$HERMES_HOME/cahier.json |
written by the agent when it asks the onboarding questions in a session chat | the first run, conversationally |
| built-in defaults | this repo | everything else |
The plugin was called cahier-hub before v0.5.0. The older
CAHIER_HUB_<KEY> names (CAHIER_HUB_GROUPS, CAHIER_HUB_CTL,
CAHIER_HUB_BRIDGE_PORT, …) are still honoured, so an existing deployment does
not have to rename its environment in the same step as the upgrade — and the
groups-file resolver reads the current name first, the old one second, so the
panel and the control plane can never end up writing and reading different
files mid-rename.
The keys (also in plugin.yaml): bridge_port, base_url, cache_ttl,
control_plane, groups_file, share_dir, projects_root, hosting_mode,
timezone, notify_channel, notify_target, notify_on, quiet_hours,
pin_enabled, users.
Asked in the session chat, by the agent, at the moment it decides to create a cahier — once, and skipped entirely for a plain local viewer. One at a time, with the default named.
| # | Question | Setting | Default |
|---|---|---|---|
| 1 | Who's answering, by name? | users |
just you |
| 2 | Where will they open it — this machine, the same network, or anywhere? | hosting_mode |
lan |
| 3 | What link will you send them? | base_url |
derived from the request host |
| 4 | Where should submitted answers land? | notify_channel, notify_target |
none |
| 5 | What should that tell you about? | notify_on |
new_answer |
| 6 | Any quiet hours? | quiet_hours |
none |
| 7 | Which timezone are the deadlines in? | timezone |
the host's |
The PIN is derived from #2, never asked as its own question: public means a
PIN is mandatory, lan means ask once, local means no PIN, no bridge, no
notifications.
Question 3 exists because a link built from 127.0.0.1 works for the operator and
is dead for everybody else. GET /doctor fails that combination outright when
hosting_mode is not local.
Grouping needs two labels per cahier and refuses to invent them. Each answer
carries its provenance (profile_source / project_source), resolved strongest
first — the same ladder lives in cahier_ctl.filing():
| Source | Meaning |
|---|---|
override |
a human said so: the panel's ✎ or cahier_ctl.py filing --slug … --profile … --project … |
declared |
stamped into the page by cahier.py build (CAHIER_PROFILE / CAHIER_PROJECT) |
session |
the session that built it → profile_name in $HERMES_HOME/state.db |
derived |
the project's own name appears in the slug (acme-restructure → ACME). Slug only — titles are prose |
none |
nobody knows; the panel shows an empty chip |
Overrides live in one hand-editable file,
$HERMES_HOME/state/cahier-groups.json (CAHIER_GROUPS re-points it, which
is how the tests stay hermetic):
{"version": 1, "updated_by": "panel", "cahiers": {"rolodex-merges": {"profile": "default", "project": "rolodex"}}}dashboard/plugin_api.py does not read the registry itself: it iterates through
cahier_ctl.iteration(), the same call the CLI uses, so the panel and the
terminal cannot drift apart. It looks for that module in this order:
control_plane(a configured path),$HERMES_HOME/scripts/cahier_ctl.py— a real installation's own copy, and the one its CLI and cron jobs are already running,lib/cahier_ctl.py— the copy bundled here, so a fresh clone works with nothing else set up.
GET /doctor reports which one is live and whether the local and bundled copies
have drifted (expected after a local edit — the local one wins). When
HERMES_HOME is not ~/.hermes, the control plane's own paths are re-pointed at
it, because it hardcodes ~/.hermes otherwise.
Backend half:
hermes plugins install cahier # or: clone into $HERMES_HOME/plugins/
hermes plugins enable cahier # adds it to plugins.enabled
hermes plugins doctor cahier # runtime discovery + import contract
# restart hermes-serve so the route mountsDesktop half — the app runs its own plugin.js from its own home, so the file
has to land there:
$HERMES_HOME/desktop-plugins/cahier/plugin.js
then Rescan in the app's plugin settings. (On a local app this is what
materializeDesktopHalf copies for you; across a remote backend it is a one-file
drop.)
Check it:
python3 scripts/doctor.py # or: GET /api/plugins/cahier/doctor./tests/run.shRuns the control-plane compile, the doctor, both backend suites (pytest, real FastAPI app + real router) and the panel harness (Node, stubbed SDK, real payload):
- Contract suite — health, scope equality with
cahier_ctl.iteration(), no dropped/double slugs, stable order across calls, and a read-only proof that the hashes ofcahier-registry.jsonandcahier-answers.dbare unchanged by a request, plus cross-implementation parity: the panel's slug set must equalGET /fleet/cahierson the bridge exactly — a slug visible in one and not the other is a cahier the user either can't see or can't open. That half skips cleanly when:8766isn't listening (override withCAHIER_BRIDGE=), so a stopped optional service never turns the suite red. The filing tests write to atmp_pathoverride file: the write lands there and nowhere else, an unknown slug / traversal / oversized label is refused with nothing left behind, a GET never touches the file, and an override always beats every automatic answer. - Configuration and doctor suite — a throwaway
HERMES_HOMEwith nothing in it, proving the layer precedence (env >config.yaml> chat answers > defaults), that another plugin's settings block cannot bleed in, that the bundled control plane loads when no local one exists and the local one wins when it does, thatHERMES_HOMEre-points the state paths, and that the doctor fails a loopbackbase_urlon alandeployment. - Panel harness —
register()wires one sidebar row and one route, the page renders every cahier the backend sent exactly once, grouping buckets those rows by profile ▸ project (and Flat drops the headers) without losing any, "Open" frames the served URL in-window, "Browser" hands the same URL to the OS, "Copy link" writes it to the clipboard, and the ✎ POSTs/filing— the only non-read call the page makes.
The harness runs against scope=active (what the page opens with) and
scope=all (empty-scope edge), so an empty or degenerate payload fails loudly.
What a user should know before installing (admission-policy terms):
- Network. No third-party calls: no telemetry, no analytics, no update pings.
The only requests go to your own bridge — a 1.5 s loopback health probe so
the panel can badge whether it is up, and two probes of the same bridge from
GET /doctor(a GET, plus a POST to its/saveto prove the token gate answers before you rely on it). A bridge, if you run one, listens on your LAN so answerers can open a cahier — that is the only listener. Nothing here reaches a host you did not configure. - Reads outside its own data. The cahier files and the profile/project
filings in the folders you configure (
share_dir,projects_root,groups_file), plus the control plane at$HERMES_HOME/scripts/cahier_ctl.pyor the copy bundled here. It reads no credentials and no other application's files or token stores. - Writes. Inside
$HERMES_HOME(its own state) and the folders you configure. It never writes into Hermes core files or another plugin's directory. WhenHERMES_HOMEis not~/.hermesit re-points the path constants of its own bundled control-plane module at that home (setattron the module it just imported, only on names that already exist, never on Hermes core) so the panel and the CLI read the same files. - Shell. Fixed-argv local commands: no shell (
shell=Trueis never used), no--yolo/non-interactive flag propagated to a child, every call carrying its own timeout (15 s / 30 s) andcheck=False. There are three, and only these:systemctl --user is-active <unit>for the health strip,systemctl --user stop cahier-deadline.timerwhen you pause or close a cahier that had a deadline, andpython3 …/arm-deadline.py --slug … --at …when you spin one back up (that script belongs to thecahierskill; a missing one is reported, never guessed at). The control plane itself is imported, not spawned — same module, same process as the panel. - Background processes. None. The bridge is a service you run yourself; the plugin neither bundles nor starts it.
- Credentials. None required: zero API keys, zero model tokens, empty
requires_env. A cahier PIN, when you enable one, lives inside the page you serve. - What it needs first. The
cahierskill to build and read cahiers. The plugin bundles a copy of the control plane so it can read without the skill, but reading a cahier you cannot build is not much use — install the skill too.
cahier_ctl.py missing or broken → the API degrades to the registry file alone
and says so in integrity.warnings; the page shows a warning strip instead of an
empty list. A half-read is never silently presented as a complete one.
Integrity warnings are the panel telling on itself: inbox files that were never
ingested, served pages with no SLUG declaration (viewers and artifacts are
fine here — they are just not spin-down-able), registry rows whose state no
longer matches disk, and duplicate slugs. They appear in the page's warning
strip, never hidden.