Skip to content

Latest commit

 

History

History
439 lines (359 loc) · 22 KB

File metadata and controls

439 lines (359 loc) · 22 KB

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

{
  "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 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, 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.