# `commis/v1` — the config-row schema

An *agent* in commis is **one JSON document**, not a repo. Hiring an agent copies a
catalog row into the roster and overrides a few fields; it does not generate code.
This is the comptoir lesson applied to labour instead of to products.

A row is a plain file. It is git-committable, diffable, and reviewable by a human
who has never opened a terminal. `commis validate` is a pure function of the row —
no state, no network, no store.

```
catalog row  ──hire──▶  roster row  ──plan──▶  execution plan  ──run──▶  artifact
(read-only)             ($COMMIS_HOME)         (pure, JSON)             (hart URL)
```

## Full row

```jsonc
{
  "spec": "commis/v1",              // REQUIRED. Refuses anything else.
  "id": "elio",                     // REQUIRED. [a-z][a-z0-9-]{1,31}. Unique in a roster.
  "name": "Elio",                   // display name. The persona sells; keep it.
  "role": "commercial",             // free text, but see "roles" below.
  "summary": "Prospection sortante, qualification, prise de RDV.",
  "locale": "en",                   // en | fr-… — the language of every message a human reads

  "engine": {
    "runtime": "roam",              // roam | local | debri
    "provider": "anthropic",        // anthropic | openai | openrouter | debri
    "model": "claude-sonnet-5",
    "tools_profile": "standard",    // standard | inspect — inspect drops the base
                                    // commands that mutate on their own
    "max_steps": 40,                // hard ceiling on tool-loop iterations
    "api_base": "https://openrouter.ai/api/v1",  // WHERE. Defaults per provider;
                                    // set it for OpenRouter, a gateway, a local runtime
    "eur_per_mtok": 6.0             // optional: override the built-in price used to
                                    // convert budget EUR -> roam's token ceiling
  },

  "tools": [                        // THE CAPABILITY GRANT. Allow-list; nothing implicit.
    { "cmd": "grepapi",  "verbs": ["search", "enrich"], "why": "find prospects" },
    { "cmd": "crm-cli",  "verbs": ["add", "list", "note"] },
    { "cmd": "bland-cli","verbs": ["call"], "confirm": "always" }
  ],

  "secrets": ["GREPAPI_KEY", "CRM_TOKEN"],   // NAMES only. A literal value here is a validation error.

  "budget": {
    "wallet": "peage",              // peage | none  — peage debits a real wallet
    "cap_eur": 20.0,                // per period
    "period": "month",              // day | week | month
    "per_run_eur": 2.0,             // must be <= cap_eur
    "on_exhausted": "stop"          // stop | ask
  },

  "gate": {                         // roam's confirm-gate, declared not coded
    "mode": "ask",                  // none | ask  — WHETHER to ask
    "channels": ["ntfy:your-topic",               // WHERE to ask. ntfy/telegram
                 "telegram:123456789",            // need NO sending domain.
                 "email:you@example.com",
                 "webhook:https://ops.example.com/hook"],
    "require": ["send_email", "publish", "spend>1eur", "shell"],
    "timeout_s": 86400,
    "on_timeout": "deny"            // deny | approve   (deny is the only safe default)
  },

  "schedule": { "cron": "0 9 * * 1-5",         // 5 fields; empty = never
                "tz": "Europe/Paris" },        // IANA zone, resolved via `date`

  "inputs": {                       // free-form, agent-specific. Merged from --input k=v.
    "icp": "cabinets comptables FR, 5-50 salaries",
    "quota": 50
  },

  "verify": {                       // roam goal-verify; fail-open by design
    "goal": "50 qualified leads in the CRM, 0 duplicates, >=3 meetings booked",
    "mode": "fail-open"             // fail-open | fail-closed
  },

  "output": {
    "artifact": "hart",             // hart | file  — file keeps it entirely local
    "visibility": "private",        // private | unlisted | public  (private = hart read key)
    "notify": ["email:you@example.com",           // Resend
               "webhook:https://ops.example.com/hook"],
    "retain_days": 90
  },

  "meter": {                        // the SERVICE FEE, charged only on a run that finishes
    "sku": "commis.elio.run",
    "unit": "run",
    "price_eur": 0.30
  }
}
```

## Defaults

Only `spec`, `id`, and `role` are required. Everything else is filled in by
`spec_normalize()` and echoed back by `commis validate --normalized`, so a
three-line row is a legal agent:

| field | default | why |
|---|---|---|
| `locale` | `en` | approval and completion messages follow it; anything not `fr-…` gets English |
| `engine.runtime` | `roam` | the only runtime with a trust layer |
| `engine.max_steps` | `40` | a runaway loop is the expensive failure mode |
| `engine.api_base` | `https://api.anthropic.com`, or `https://api.openai.com/v1` for `provider: openai` | roam defaults to Anthropic, so `provider: openai` without this posts OpenAI-shaped requests at the wrong host |
| `engine.eur_per_mtok` | per-model table (opus 30, sonnet 6, haiku 0.9, debri 0) | conservative on purpose: a run stops slightly early, never slightly over |
| `tools` | `[]` | an agent with no grant can think but not act |
| `budget.wallet` | `peage` | metered beats seats; `none` runs unmetered and warns |
| `budget.cap_eur` | `10.0` | a cap you did not choose is still a cap |
| `budget.on_exhausted` | `stop` | never silently overspend |
| `gate.mode` | `ask` | **an ungated agent is opt-in, never the default** |
| `gate.channels` | `[]` | legal, and warned about: a gate with nobody to ask is not a gate |
| `gate.on_timeout` | `deny` | silence is not consent |
| `verify.mode` | `fail-open` | a broken verifier must not block delivered work |
| `output.artifact` | `hart` | the deliverable is a URL, not a dashboard |
| `output.visibility` | `private` | a deliverable holds customer data; world-readable is a decision, not a default |
| `meter.price_eur` | `0.0` | free until you price it |

## Roles

The eight personas are catalog rows, not code paths. `role` is free text; these
are the ones the catalog ships.

| id | role | status in M0 |
|---|---|---|
| `elio` | commercial — outbound, qualification, booking | **active** |
| `lou` | seo — articles, CMS publish, audits | **active** |
| `rony` | recrutement — job posts, CV screening | **active** |
| `charly` | general — supervises the roster | draft |
| `john` | marketing — visuals, copy, multi-platform publish | draft |
| `tom` | telephone — inbound 24/7 reception | draft (real COGS) |
| `manue` | comptable — forecasts, cashflow | draft (FR ruleset) |
| `julia` | juridique — contracts, RGPD | draft (FR ruleset) |

## Validation rules

`commis validate` returns `{valid, errors[], warnings[], normalized}`. Errors exit
`81`; warnings never fail the caller.

**Errors**

1. `spec` absent or `!= "commis/v1"`.
2. `id` absent, or not `[a-z][a-z0-9-]{1,31}`.
3. `role` absent.
4. `engine.runtime` not in `{roam, local, debri}`.
5. `budget.cap_eur <= 0`, or `budget.per_run_eur > budget.cap_eur`.
6. `budget.period` not in `{day, week, month}`.
7. `gate.mode` not in `{none, ask}`, or a `gate.channels[i]` whose scheme is
   not `ntfy`, `telegram`, `email`, `webhook` or `cuzz`, or with an empty target.
8. `gate.on_timeout` not in `{deny, approve}`.
9. `output.artifact` not in `{hart, file}`, or `output.visibility` not in
   `{private, unlisted, public}`.
10. A `tools[i]` entry with no `cmd`.
11. A `secrets[i]` entry that looks like a value, not a name (contains `=`, or is
    not `^[A-Z][A-Z0-9_]*$`). **A row is committed to git; a secret in one is a leak.**
12. `meter.price_eur < 0`.
13. An `output.notify[i]` whose scheme is not `email` or `webhook`, or that has
    an empty target.
14. A non-empty `schedule.cron` that is not a 5-field expression. An
    unparseable schedule is an **error**, never "never fires" — silence is
    indistinguishable from a working schedule that had nothing to do.

**Warnings**

- `gate.mode == "none"` with a non-empty `tools` grant — an unsupervised agent
  that can act. Legal, deliberate, and worth saying out loud every single time.
- `budget.wallet == "none"` — unmetered.
- `verify.goal` empty — nothing decides whether the run succeeded.
- `output.visibility == "public"` — every deliverable this agent produces is
  world-readable. Sometimes right; never silent.
- an unknown top-level key — forward compatibility, not a typo check.

## How a row reaches roam

roam is the runtime; the row is the contract. Three mismatches are resolved in
`src/bridge.src`, and none of them are papered over:

| row says | roam offers | commis does |
|---|---|---|
| a per-command tool grant | `--allow-shell`, all or nothing | builds a PATH shim of symlinks holding **only** the granted commands plus a base shell set, and runs roam with that as its entire PATH. The OS enforces it. `curl`, `wget`, `ssh`, `git`, `python`, `rm` are absent unless granted. |
| `budget` in EUR | `--tokens` | converts at a rate that is shown in the plan and frozen into the receipt (`engine.eur_per_mtok`) |
| `engine.workdir` | roam owns `~/.roam/work/<jobid>` | ignores it, and **warns** — see the validation warnings |

### What PATH does and does not buy

`commis plan` prints the **full** base set alongside the row's grant, the granted
commands that can mutate through their own flags, and this caveat — because the
plan is what someone approves, and it used to show three commands while the
agent got thirty-one.

- **Does not hold, at all:** "an agent with no `curl` binary cannot reach the
  network." It can: `PATH=/usr/bin:$PATH curl ...` (or the same trick against
  any granted tool that itself shells out to something ungranted) resolves
  whatever binary the *host* has, because a child process inherits the PATH
  the shell line set for it, not the shim's. Found live — a row that granted
  only a wrapper script had its own `curl` call reached this way. No claim
  about which binaries stay unreachable survives `--allow-shell`.
- **Does not hold:** "it can only run the commands you listed" either way —
  the agent has a shell, so `> file` writes with no binary at all, and
  `sed -i` / `find -delete` mutate through their own flags without roam's
  destructive-command check necessarily seeing them.
- **What actually holds:** roam's gate on commands it recognizes as
  destructive, and the model's own willingness to stay in its lane. PATH
  narrows the EASY paths and raises the bar for an accident; it is not a
  security boundary against a model actively trying to get out. Do not hand
  a row a real secret or trust it with anything you would not trust the
  underlying MODEL with.

`engine.tools_profile: inspect` drops `sed`, `awk`, `mkdir`, `cp`, `mv`, `touch`
and `find` from the base set. It removes the easy paths and narrows what a
mistake can reach; it is not a guarantee, and the plan says so.

**If the agent does not need root, do not give it root.** That bounds the damage
far better than PATH can.

Verbs stay **advisory**: PATH can gate `bland-cli`, it cannot gate
`bland-cli call` versus `bland-cli list`. The plan says so in
`invocation.enforcement.verbs` rather than implying an enforcement that is not
there. `gate.mode != none` maps to roam's `--confirm`, which is the real
backstop for anything a verb rule would have caught.

## How a run is paid for

`budget.wallet: "peage"` makes a run debit a real wallet. commis is the
**merchant**; the customer holds the wallet. Money moves in two steps because a
run's cost is not known until it ends:

| when | call | amount |
|---|---|---|
| `run` | `POST /v1/holds` | `per_run_eur` + `meter.price_eur` — the maximum this run could cost |
| `status`, terminal | `POST /v1/holds/capture` | the actual: compute burned + the fee, if earned. The rest refunds itself |
| `status`, nothing to charge | `POST /v1/holds/release` | all of it back — peage refuses a zero capture, and is right to |

Two rules that are easy to get wrong and expensive to get wrong quietly:

- **The fee is charged only when the run reaches `done`.** Compute is a
  pass-through: you burned it, you pay it, whatever the outcome. The per-run fee
  is the price of a *delivered* run, so a crash does not pay it — and the
  receipt carries `fee_waived: true` rather than quietly discounting.
- **The hold's TTL is derived from `gate.timeout_s`.** A peage hold auto-refunds
  at expiry. That is the safety net for a run that hangs, and a trap for a run
  parked on a human approval: a reservation that expires while the run is still
  live would silently un-reserve the money. A row that can park for a day holds
  for a day.

`budget.cap_eur` governs **compute only**. The fee is the price of the product,
not a draw on the leash. The receipt shows the split so it can be checked
instead of taken on trust.

Env: `PEAGE_URL`, `PEAGE_MERCHANT_KEY` (`pm_…`), `PEAGE_WALLET_TOKEN` (`pw_…`).
There is no default host — see *The rails* in the README.
Set `budget.wallet: "none"` — or `commis hire … --wallet none` — to run unmetered.

## How the deliverable is delivered

`output.artifact: hart` publishes each run to [hart](https://hart.intrane.fr) as
**one artifact per agent, one version per run**:

```
/a/<owner>/<agent>        the standing URL — what a customer bookmarks
/a/<owner>/<agent>/v7     this run, immutable — what the receipt records
```

The page is not the agent's Markdown. It is that Markdown rendered to HTML
**plus the run's facts**: the brief, what it was charged, the tokens it burned,
the exact commands it was allowed to run, whether a human approved anything, and
the goal it was judged against. The deliverable *is* the receipt — a report you
cannot audit is a report you have to trust.

Three properties worth stating:

- **Private by default.** A deliverable holds leads, CVs, financials. hart gates
  a private artifact behind a read key; commis generates one per agent and stores
  it at `$COMMIS_HOME/keys/<id>.key`, **never in the row** — same rule as
  `secrets`: the row names things, it does not hold them. The key is reported
  once, on the run that creates it.
- **Agent output is escaped before it is marked up.** A deliverable is untrusted
  text — the agent that wrote it read the open web — so `<script>` in a report
  renders as characters. Inline HTML in Markdown is unsupported, and that is the
  feature.
- **Self-contained.** No CDN, no font, no analytics. A deliverable that phones
  home leaks who read it and when.

`HART_URL`, `HART_OWNER` and `HART_TOKEN` are all required; without them
delivery is a **plan blocker**. `HART_OWNER` has no default on purpose: a shared
one puts every operator into a single namespace on a shared instance, colliding
on artifact ids and sharing one storage quota. `output.artifact: file` writes the identical
page to `$COMMIS_HOME/out/` and touches no network at all.

Delivery happens **after** settlement and can never un-settle a paid run. A hart
outage leaves a paid run with a retryable delivery; `commis deliver <id>` is the
retry, and it is a separate verb precisely so it cannot re-charge.

## Who gets told

`output.notify` is a list of `<scheme>:<target>` channels:

| channel | what it sends | needs |
|---|---|---|
| `email:alice@example.com` | the link, the brief, the outcome, the cost — and, for a private artifact, its read key | `RESEND_API_KEY` |
| `webhook:https://host/path` | the run record and the delivery, as JSON | nothing |

Notification fires **only after a delivery that produced a URL**. Announcing a
link nobody published sends the recipient looking for something that is not
there.

**It is the one step that is not a plan blocker**, and the inconsistency is
deliberate. An unpayable run is a legal problem; an undeliverable run produces
nothing the customer can reach. An un-notified run has already been delivered
and recorded — nothing is lost but latency, and `commis notify <id>` closes it.
So a missing `RESEND_API_KEY` warns in the plan and the run proceeds.

Three details that are decisions, not accidents:

- **A private link travels with its read key.** Without it the link is a dead
  end. The recipient owns the data, so sending it is delivering their own
  property — but it *is* a secret in an email, and the message says so rather
  than letting someone discover it.
- **One dead channel never silences the others.** Each channel reports its own
  outcome; a bad address does not stop the webhook.
- **A republish does not re-announce.** `commis deliver` notifies only if the
  run never did; `--renotify` forces it. A channel that repeats itself is a
  channel people stop reading.

A webhook URL routinely carries its own token in the path, so the plan and the
run record store it **redacted** (`https://host/…`) while the request itself
uses the full URL.

## The approval channel

`gate.mode` says **whether** to ask; `gate.channels` says **where** — the same
`<scheme>:<target>` grammar as `output.notify`, plus `cuzz:<channel>`. Splitting
them fixed a category error: `email` and `panel` were transports masquerading as
modes.

The shape is borrowed from CopilotKit's channels-sdk — *one interrupt, rendered
natively wherever the human already is*. What that SDK gets for free, a Slack
app with a live socket, commis does not have: it is a binary that exits. So the
return path is [relais](https://relais.intrane.fr), an inbox that catches any
HTTP request — exactly the inbound URL a process with no server lacks.

```
roam parks a command
  → tick opens an approval: mints a relais inbox, renders ONE request per
    channel, each carrying two capability URLs
  → the human taps; relais captures ?d=approve&n=<nonce>
  → the next tick reads relais, calls `roam approve`, closes the book
```

Details that are decisions:

- **The decision rides in the query, not the path.** relais routes on
  `/c/<inbox>` and would read a path suffix as part of the inbox id.
- **A stray hit decides nothing.** Mail scanners and link previews fetch every
  href. Only a request carrying the CSPRNG nonce counts.
- **The URL is a capability**: whoever opens it decides. The message says so,
  and the inbox is handed back the moment it is spent, so a leaked link from an
  old thread decides nothing.
- **The request shows the command verbatim.** An approval that paraphrases what
  it is asking for is one nobody can give honestly.
- **A gate that may wait a day cannot use an inbox that expires in an hour** —
  a long `gate.timeout_s` buys a persistent relais inbox with the peage wallet
  the run is already charged against.
- **Silence is not consent.** Unanswered by `gate.timeout_s`, `on_timeout`
  applies — `deny` by default.
- `commis approve` at the terminal closes the same request, so the next tick
  does not time out a question already answered.

**One record per request, not per run.** roam parks once per destructive command,
so a run can be asked several times; each question gets its own record and its
own inbox. A loop is bounded at five requests per run, after which commis denies
and says why — an agent re-issuing a refused command would otherwise mail the
operator until they stop reading the channel.

`commis approvals` lists what has been asked and answered. It never prints the
decision URLs or the inbox token: that listing is the kind of thing that gets
pasted into a ticket, and either one decides.

## Cadence

`schedule.cron` is a standard 5-field expression (`* / , -` and `*/n`), read in
`schedule.tz`. Names like `MON` are not accepted: a schedule an agent writes is
numeric, and a parser that takes three spellings of Tuesday has three ways to be
wrong. When day-of-month **and** day-of-week are both restricted the match is an
**OR**, as in Vixie cron — `commis schedule` says so in words, because that is
the rule everyone misreads.

**Nothing runs the schedule by itself.** `commis tick` is the entire scheduler:

1. settle every finished run (this is the only reason a scheduled run is ever
   charged — nobody is typing `commis status` at 07:00),
2. reap runs that will never finish,
3. fire what is due.

systemd or cron calls it; `commis cron --emit` prints the unit. commis has **no
daemon on purpose**: restart-on-boot, crash recovery, and single-instance
locking are the init system's job, and it already does them.

Three rules the scheduler follows:

- **A missed window fires once, not once per missed slot.** A box that was off
  overnight must not wake up and run yesterday's report twelve times.
  `COMMIS_CATCHUP_MINUTES` (default 1440) bounds how far back it looks.
- **The fired stamp is written before the launch.** A crash between the two
  costs one skipped run; the other order costs an unbounded retry loop against a
  real wallet.
- **A schedule is not an override.** A due run refuses for exactly the reasons
  `run` refuses — no brief, plan blockers, or a cap already reached. An agent
  that outran its budget goes quiet; it does not go faster.

### The reaper

`tick` declares an in-flight run **abandoned** after `gate.timeout_s + 1h` (2h
when `gate.mode` is `none` — nothing is waiting on a human). It stops the job,
**releases** the hold rather than capturing anything, and writes the receipt.
commis does not know what an abandoned run did, and a charge for work of unknown
value cannot be defended. peage would auto-refund at the hold's TTL anyway; the
point of doing it here is the *record* — a run that vanished without a trace is
the one an operator finds three weeks later in a bank statement.

## What is deliberately *not* in the row

- **Prompts.** A row grants capability and money; it does not carry the wording.
  The brief (`commis brief`) is the task, and it changes far more often than the
  grant. Mixing them makes every prompt tweak a permission review.
- **Credentials.** Names only (`secrets`), resolved from the environment at run.
- **Schedules of other agents.** No row references another row. Supervision is
  `charly`'s brief, not a graph in the schema — a config format that can express
  a fleet topology will eventually be asked to debug one.
- **Retries and backoff.** That is the runtime's job (roam), not the row's.

## Stability

`spec` is the only compatibility surface. New optional keys may appear in
`commis/v1` and older binaries must ignore them (hence: unknown keys warn, never
fail). A change to a default, a required field, or a validation rule is
`commis/v2`.
