The problem: Bahn moves 25,000 used cars a year through freelance drivers.
The ops team runs every delivery and depends on people who don't work for Bahn — the seller holding the car, the driver, the ferry line, whoever signs at the other end. Any of them can be late, wrong, or silent, and every problem starts a chain. A missed ferry becomes a new booking, a hotel question, an email to the line, and a cost decision. Hundreds of cases are open at any moment. Twelve need a person right now. Today that chain lives in one operator's head, and when it slips, it gets rebuilt from phone logs.
What we built: A control room where the agent does the legwork and a person makes the decision. The board shows only the cases that need a person — the rest are behind a panel that says what was checked. Every card opens with a plain-English sentence about what happened, then a hint about what to do, then the agent's suggestion, then the effect of accepting it ("If you accept, this runs ops.route.replan_delivery on CD-420437 · no customer is contacted · needs your approval"). When the evidence doesn't support action, the card says "Not ready for action" with the missing fact and no Accept button.
Every card links to the case page. The whole chain on one screen: "Where this is" and "What's next" in two sentences, then the trace of every tool the agent called, then a text box where the operator can keep talking to the agent. The agent answers with its own tools — ask_driver, lookup_ferry_status, get_reservations, and so on.
How it works: The agent is a real tool loop, and you watch it work. case_chat.py runs six tools with tool results fed back as role=tool messages until the model proposes an action or exhausts its turns. In the demo, you type "ask the driver if they are safe" and the answer streams back word by word — you see ask_driver fire, the driver's reply arrive, and the model's sentence land, live. Not a canned response. A real loop.
Bring your own model. The agent speaks any OpenAI-compatible chat endpoint. AnyAPI, OpenRouter, OpenAI, Anthropic (via a proxy), a local llama.cpp or Ollama — all work by setting three lines in .env. BAHN_MODEL_NAME can be swapped mid-run and the same tools run against a different brain. Nothing about the agent is hardcoded to a specific provider or model.
The agent never contacts a customer on its own — and it's a guard, not a rule. bahn_ops/execute.py refuses any command starting with customer., notify.customer, email.customer, or sms.customer, and the card's strip turns red with the refusal reason. Every executable command has an explicit effect in EXECUTABLE_EFFECTS. Commands the model proposes that aren't on the allowlist don't get executed and don't get refused outright — they land in a third state, "Waiting on a person," and an operator has to approve them before they run. That's how trust gets extended over time.
Refuse before the model. Three signal classes (TRACKING_GAP, RELEASE_DOCUMENT_UNCLEAR, SITE_ACCESS_UNCONFIRMED) are refused before the model is ever called, with a deterministic refusal that costs nothing. The model sees only the cases it can act on. SOURCES: {deterministic: 3, model: 9} is the shape of that fix. It's a better shape than twelve model calls.
The phone log makes the simulation honest. When the agent asks the driver a question, the message lands in /phone with a question_id. The scripted reply lands with a reply_id. The operator can override the reply as if they were the driver — the old reply is marked superseded, the new one operator_override, nothing is deleted. The brief allows a simulated driver; this design makes visible what was simulated, on what channel, and at what time.
The decision loop is visible and it persists. Accept writes bahn_ops/decisions.jsonl with a timestamp, then fires the executor in the background. The card's strip goes from "Executing alert.resolve..." to "Executed at HH:MM:SS: Alert marked resolved. No customer contacted." If the executor refuses (customer contact), the strip turns red. If the command is unknown, the strip turns amber. Three states, every one visible.
Runs from a fresh clone with one command. No dependencies outside the standard library. A deterministic fallback runs the same tools in the same order when the model is unreachable, and records why in model_error. The demo never goes dark — not on stage, not on Bahn's wifi.
What's next: More agents, same room. Pickup readiness by phone, and driver travel. The control room, the source/state taxonomy, the phone log, and the approval gate already exist. Each new agent plugs into the same case page and the same executor.
Learn from every "Change." Every time an operator clicks Change instead of Accept, that's the most valuable signal in the system: a labelled example of where the agent's reasoning went wrong. Capture it, feed it back.
Earn more autonomy. The approval gate is the trust boundary. Start fully gated, and graduate.


