Skip to content

Latest commit

 

History

32 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

commis

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 link

One static binary, 248 kB, no runtime, no daemon, no account.

The rails are yours to choose

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.fr

Nothing here works by default, and that is the point.

Why a row and not a repo

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)

The catalog

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.

Three defaults that are opinions

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.

plan is free and plan is honest

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.

What the PATH sandbox does, and what it doesn't

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.

Euros in the row, tokens on the wire

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.

The wallet moves

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 says fee_waived: true instead of quietly discounting.
  • A failed capture defers. If peage is unreachable at settlement the run is not marked settled, and the next status retries. 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.

The deliverable is the receipt

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>.key and never writes into the row. visibility: public is 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_TOKEN is required, or delivery is a plan blocker. The alternative is quietly shipping a customer's leads to a default host. --artifact file writes 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.

And someone gets told

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.

Approve from wherever you are

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 whereemail:, 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_timeout applies — deny by 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.

On its own cadence

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 run refuses — 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 + 1h is 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.

Status

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.

Docs

  • javimosch.github.io/commis — the site
  • commis/v1 schema — 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.

Install

curl -fsSL https://raw.githubusercontent.com/javimosch/commis/main/install.sh | sh
commis guide            # the mental model, as JSON — read this before the README

commis 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.

Agent-first — all five specs

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 exists

MIT.

About

Hire an agent, not a SaaS. One config row per AI worker: it grants tools and money, asks a human before it acts, bills per run, and delivers an auditable URL. One static binary, no default host for anything.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages