Embauchez un commis, pas un SaaS.
An "AI employee" platform sells you a dashboard and a monthly seat. commis
sells you a config row: one JSON document per worker that grants it tools
and money, declares when it must ask a human, and prices what a run costs. The
row is a file. It lives in your git repo. It runs on your machine.
commis catalog # who you can hire
commis hire menage # the starter: audits your disk, needs nothing
commis brief menage "audit reclaimable disk"
commis plan menage # free, and it touches nothing
commis hire elio --budget-eur 20 --per-run-eur 2 --wallet none --artifact file
commis brief elio "cabinets comptables FR, 5-50 salaries, 25 leads"
commis plan elio # exactly what would run — free
commis run elio # launch it through roam
commis status elio # poll; settles the cost when it ends
commis approve elio # answer a parked command
commis runs elio # receipts
commis deliver elio # re-publish, without re-charging
commis notify elio # re-send the linkOne static binary, 248 kB, no runtime, no daemon, no account.
commis is a client of four hosted rails, and ships with a host for none of them:
| rail | what it does | env | without it |
|---|---|---|---|
| peage | holds and captures the money | PEAGE_URL + merchant key + wallet token |
budget.wallet: none — runs are unmetered |
| hart | publishes the deliverable | HART_URL + HART_OWNER + HART_TOKEN |
output.artifact: file — the same page, written locally |
| relais | catches the approval reply | RELAIS_URL |
gate.mode: none, or answer at the terminal with commis approve |
| Resend | sends the link | RESEND_API_KEY + COMMIS_NOTIFY_FROM |
output.notify: [] — the link is still in commis runs |
commis rails shows what's wired. A row that asks for a rail you haven't
configured is a plan blocker that names the opt-out — never a silent
downgrade, because an unmetered run that was supposed to bill is work you gave
away.
Every rail is itself one static binary you can self-host (peage, hart, relais). Or point at the hosted intrane.fr instances — pay-as-you-go, and you still bring your own wallet, merchant key and owner slug:
export PEAGE_URL=https://peage.intrane.fr \
HART_URL=https://hart.intrane.fr \
RELAIS_URL=https://relais.intrane.frNothing here works by default, and that is the point.
Eight "AI employees" is not eight products. It is one runtime and eight config rows — the same lesson comptoir learned about micro-SaaS, applied to labour. Hiring copies a catalog row into your roster; it does not generate code, and there is no code to maintain per agent.
catalog row ──hire──▶ roster row ──plan──▶ execution plan ──run──▶ artifact
(in the binary) (your git) (pure, free) (a URL)
| id | role | |
|---|---|---|
elio |
commercial | prospection, qualification, prise de RDV |
lou |
seo | articles, publication CMS, audits |
rony |
recrutement | offres, tri de CV, short-list |
charly |
général | supervise le roster |
john |
marketing | visuels, posts, calendrier |
tom |
téléphone | réception 24/7 |
manue |
comptable | prévisionnel, trésorerie |
julia |
juridique | contrats, RGPD |
Or bring your own: commis hire mien --from ./mien.json.
gate.mode defaults to email. An agent that acts without asking is
opt-in, never a default. commis validate warns, loudly and every time, when a
row combines gate.mode=none with a non-empty tool grant.
gate.on_timeout defaults to deny. Silence is not consent.
Secrets are env var names, never values. A row is meant to be committed. A literal secret in one is a validation error, not a warning — and a plan reports whether a secret is present, never what it is.
commis plan compiles the row and the brief into the exact invocation, the tool
allow-list, the money ceilings, the points where a human is asked, and the
reasons it would not run right now:
{ "plan": "commis/plan/v1", "agent": "elio", "ready": false,
"blockers": ["secret GREPAPI_KEY is not set"],
"gate": { "mode": "email", "on_timeout": "deny", "points": [
{ "on": "tool:bland-cli", "why": "tools[].confirm=always", "mode": "email" }]},
"budget": { "per_run_eur": 2.00, "cap_eur": 20.00, "spent_eur": 0.00 } }It touches nothing and costs nothing. It is a separate command from run, not a
--dry-run flag, so reviewing a plan can never spend money.
roam's --allow-shell is all-or-nothing: it cannot express "grepapi yes, curl
no". So commis builds a directory of symlinks containing only the granted
commands plus a base shell set, and runs roam with that as its entire PATH.
What does not hold, at all: "an agent with no curl binary cannot reach
the network." PATH=/usr/bin:$PATH curl ... (or the same trick aimed at a
granted tool that itself shells out) 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 small wrapper script had the
wrapper's own internal curl call reached this way. No claim about which
binaries stay unreachable survives --allow-shell.
What also does not hold: "it can only run the commands you listed." The
agent has a shell, so > file writes with no binary at all — and sed -i or
find -delete mutate through their own flags, which roam's destructive-command
check does not necessarily catch. Verbs are advisory too: PATH gates
bland-cli, not bland-cli call.
What actually holds: roam's gate on commands it recognizes as destructive, and the model's own willingness to stay in its lane. PATH raises the bar against an accident; it is not a boundary against a model actively trying to get out.
commis plan prints the whole capability — the row's grant, the full base
set, the flag-mutators, and this caveat — because the plan is the thing someone
approves. engine.tools_profile: inspect narrows the base commands that mutate
on their own, which helps against accidents, not intent. If the agent does not
need root, do not give it root. And do not hand a row a real secret unless you
would trust the underlying model, not the PATH list, with it.
COMMIS_RUN_AS=<user> drops the worker process to a named unprivileged system
user via setpriv, narrowing the blast radius of a misbehaving run (no other
user's files, no sudo) even though it does not close the PATH gap above — that
needs a mount namespace, not a uid change. It is a single, box-wide setting,
not per-row, and it does not compose with a row that genuinely needs
root-level file access: a live proof of menage-apply (a destructive prune
row) had every rm correctly approved and executed, then denied outright by
the OS, because commis-agent cannot delete root-owned files under
/var/lib/apt no matter how many humans approved it. Rows that legitimately
need root belong on a box (or in a commis tick/cron invocation) that does
not set COMMIS_RUN_AS — there is no per-row escape hatch for it yet.
A row budgets in EUR because that is what the customer agreed to; roam budgets in tokens because that is what the provider meters. commis converts at a rate it prints in the plan and freezes into the receipt, so the number that authorized a run and the number that billed it can be compared later:
{ "jobid": "98177310", "authorized_eur": 2.00, "tokens": 250000,
"cost_eur": 1.50, "conversion": { "eur_per_mtok": 6.00, "token_ceiling": 333333 } }A run is authorized before it starts (against what is already spent) and
charged after it ends (from the tokens roam reports). Settlement is
idempotent: polling status twice cannot bill twice.
Settlement prefers roam's OBSERVED cost over this estimate, when there is
one. OpenRouter reports the actual billed amount per request; roam asks for
it and passes it through as cost_usd. commis converts it to EUR (a fixed,
overridable rate — COMMIS_FX_USD_EUR, default 0.92 — there is no live FX
feed anywhere in this stack) and charges that instead of the token-rate guess.
Anthropic and OpenAI direct have no such field, so cost_usd stays 0 and
the rate-table estimate is used exactly as before. Every settled run's receipt
carries cost_basis: "observed"|"estimated" so which one happened is never in
doubt.
budget.wallet: peage makes a run debit a real peage
wallet. commis is the merchant; the customer holds the wallet. A run holds
the maximum it could cost, and settlement captures the actual — the rest
refunds itself:
$ commis run elio
{"jobid":"deadbeef","hold_id":"h_1","held_eur":2.30} # 2.00 compute + 0.30 fee
$ commis status elio
{"status":"done","tokens":250000,"cost_eur":1.50,
"settlement":{"outcome":"captured","captured_eur":1.80,"refunded_eur":0.50,
"receipt":"rcpt_…"}}
Three rules worth knowing before you point this at a real wallet:
- The fee is earned. Compute is a pass-through — you burned it, you pay it.
The per-run fee is charged only when the run reaches
done; a crash does not pay it, and the receipt saysfee_waived: trueinstead of quietly discounting. - A failed capture defers. If peage is unreachable at settlement the run is
not marked settled, and the next
statusretries. A run marked paid that was not is money nobody will ever ask for again. - The hold outlives a parked gate. peage holds auto-refund at expiry, so the
TTL is derived from
gate.timeout_s— otherwise a run waiting on your approval would silently lose its reservation.
commis wallet shows the balance and how many runs each agent can still afford.
--wallet none runs unmetered.
output.artifact: hart publishes each run as one artifact per agent, one
version per run — /a/<owner>/<agent> is the standing URL a customer
bookmarks, /v7 pins this run in the receipt.
The page is not just the agent's report. It is that report plus the run's facts: the brief, what it was charged, the tokens it burned, the exact commands it could execute, whether a human approved anything, and the goal it was judged against. A report you cannot audit is a report you have to trust.
- Private by default — a deliverable holds leads, CVs, financials. hart
gates it behind a read key that commis stores at
$COMMIS_HOME/keys/<id>.keyand never writes into the row.visibility: publicis legal and warns. - Agent output is escaped before it is marked up — a
<script>in a report renders as characters, not as a tag. - Self-contained — no CDN, no font, no analytics. A deliverable that phones home leaks who read it and when.
HART_TOKENis required, or delivery is a plan blocker. The alternative is quietly shipping a customer's leads to a default host.--artifact filewrites the same page locally and touches no network.
Delivery runs after settlement and can never un-settle a paid run;
commis deliver is the retry, and it is a separate verb so it cannot re-charge.
output.notify takes email:alice@example.com (via Resend) and
webhook:https://host/path (the run record as JSON). A finished run sends the
link:
Elio — livré
Elio (commercial) a terminé.
Brief : cabinets comptables FR, 25 leads
Rapport : https://hart.intrane.fr/a/acme/elio/v1
Clé de lecture (privée) : bcc981d34aca89404390c599
État : done Jetons : 250000 Exécution : deadbeef
The read key travels with the link because without it the link is a dead end —
and the message says plainly that it is a secret, rather than letting someone
discover that later. One dead channel never silences the others, a republish
does not re-announce (--renotify forces it), and notification only fires after
a delivery that actually produced a URL.
It is the one step that does not block a plan: an un-notified run is already
delivered and recorded, so nothing is lost but latency, and commis notify
closes it.
A gate only protects people who are already watching — and the whole point of an agent is that you are not. So when roam parks a command, commis asks:
Elio — autorisation demandée
Elio demande une autorisation.
Commande : bland-cli call +33612345678 --pitch "audit gratuit"
Autoriser : https://relais.intrane.fr/c/in_9f2?d=approve&n=7cc4b9ba…
Refuser : https://relais.intrane.fr/c/in_9f2?d=deny&n=7cc4b9ba…
Sans réponse : refusée. Exécution deadbeef
gate.mode says whether to ask, gate.channels says where —
email:, webhook:, cuzz:. The shape is borrowed from CopilotKit's
channels-sdk: one interrupt,
rendered natively wherever the human already is. What that SDK gets from a live
Slack socket, commis cannot — it is a binary that exits — so the return path is
relais, an inbox that catches any HTTP request.
One tap, and the next commis tick reads the answer and tells roam.
- A stray hit decides nothing. Mail scanners fetch every href; only 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.
- The command is shown verbatim. An approval that paraphrases what it asks for is one nobody can give honestly.
- Silence is not consent. Unanswered by
gate.timeout_s,on_timeoutapplies —denyby default.
commis approvals shows what was asked, by whom it was answered, and when. It
never prints the URLs or the inbox token — that listing ends up in tickets.
schedule.cron is a standard 5-field expression in schedule.tz. Nothing runs
it by itself — commis tick is the whole scheduler, and systemd calls it:
commis schedule # what runs when, in words, and whether it is due
commis tick --dry-run # what would happen
commis cron --emit # the systemd unit that calls tick$ commis schedule
{"id":"lou","cron":"0 7 * * 1","tz":"Europe/Paris",
"means":"at minute 0 of hour 7, on weekday 1 (0=Sunday)","due_now":false}
A tick does three things in order: settle every finished run — the only
reason a scheduled run is ever charged, since nobody is typing commis status
at 07:00 — reap runs that will never finish, then fire what is due.
- 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.
- 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.
- A schedule is not an override. A due run refuses for the same reasons
runrefuses — no brief, plan blockers, cap reached. An agent that outran its budget goes quiet, not faster. - The reaper closes the books. An in-flight run older than
gate.timeout_s + 1his declared abandoned: the job is stopped, the hold is released rather than captured, and the receipt is written. commis does not know what an abandoned run did, and a charge for work of unknown value cannot be defended.
Live-proven. A real run has gone end to end: a real LLM through roam, a real wallet held and captured with a signed receipt, a private artifact published, an approval answered from an email, and a completion notice sent — for five cents.
Every field commis/v1 declares is wired to something that happens: plan, run,
gate from your phone, pay, deliver, tell — on a cadence, with a reaper.
- javimosch.github.io/commis — the site
commis/v1schema — the normative field list and every validation rule- Changelog — what shipped, in product and technical views
commis guide— the whole mental model as JSON, in the binary. Read this before the README.
curl -fsSL https://raw.githubusercontent.com/javimosch/commis/main/install.sh | sh
commis guide # the mental model, as JSON — read this before the READMEcommis drives roam as its runtime — grab its
static binary from
Releases (roam-x86_64-linux). Or build from source: ./build.sh needs
machin and nothing else. A job that hangs never
settles — the hold auto-refunds at its TTL, but no receipt is written; there is
no reaper. See docs/schema.md for the normative schema and
AGENTS.md for the build.
Conformant to every cli-spec:
| spec | in commis |
|---|---|
| output | stdout is data JSON only — an error writes zero bytes there; stderr carries {code,type,recoverable,suggestions}; exit codes are semantic; help-json catalogs every command |
| guide | commis guide carries the whole mental model in the binary, JSON by default, --format text for humans |
| feedback | commis feedback "…" --kind bug reaches the author from the machine it broke on, and never fails the caller |
| update | commis update — the version is the binary's SHA-256; verify, smoke-test, then swap, with a .bak and rollback |
| telemetry | disclosed on stderr before anything sends, once; DO_NOT_TRACK and CI both honoured; commis telemetry prints the exact payload |
commis feedback "the plan says X but the run did Y" --kind bug
commis update --check # exit 5 means an update existsMIT.