The openmausbot command can drive a running workspace without a screen: set
up a team from a file, give a bot a job, wait until it and every teammate it
asked are done, and keep what they made. It works against the server on this
computer or one somewhere else, and it is built for scripts, CI and other
agents: every command can print one JSON document, and the exit code says how
things went.
npm install -g openmausbot # or use npx openmausbot …
openmausbot serve --no-pair & # a workspace with no screen (or use your desktop app)
openmausbot engine set claude --key-env ANTHROPIC_API_KEY
openmausbot team apply launch-room.yaml
openmausbot run "Room Lead" "Rehearse the launch of https://example.com" --export ./rehearsalrun prints progress as it goes, then the Room Lead's final reply and the
files it links, and writes everything to ./rehearsal.
--url URLorOPENMAUSBOT_URL: a workspace somewhere else. Usehttps://unless it is this computer (ALLOW_INSECURE_HTTP=trueallows plainhttp://).--port NorOMB_PORT: this computer, on that port.- Otherwise the command looks on this computer's usual ports (8799, 18799, 28799), as the desktop app uses them.
On this computer a headless server treats you as its owner, and no token is needed. Anywhere else you need a token:
openmausbot connect https://maus.example.com --code ABCD1234 --out ~/.openmausbot-token
openmausbot doctor --url https://maus.example.com --token-file ~/.openmausbot-tokenGet the code in the app under Settings → Phone → Set up a phone. On this
computer's own server, connect makes the code itself (--client gives a
token that can chat but not change settings). The token goes to a file only
you can read, and is never printed. Pass it with --token-file FILE or
OPENMAUSBOT_TOKEN; there is no --token flag, because a command line can be
read by other programs. A token is never sent while the command is still
looking for a server: with a token, name the server.
Without --json you get plain lines. With --json you get exactly one JSON
document on stdout, a failure included ({ "ok": false, "error", "hint", "exitCode" }). Progress goes to stderr as one JSON object per line; --quiet
drops it. Everything printed, either way, has secrets removed (the same
redaction the transcript uses). Durations are written 90, 90s, 5m or 2h.
| Exit code | Meaning |
|---|---|
| 0 | Done |
| 1 | Failed, stopped or stalled, or a step was refused |
| 2 | The command line or a file is wrong |
| 3 | Someone must answer (a question or an approval card) |
| 4 | Timed out (the work keeps running) |
| 5 | Cannot reach the server, not allowed, or the server is too old |
| 6 | team apply --check found changes |
| 130 | Ctrl-C (the work keeps running) |
Run openmausbot help VERB for any of these. A TARGET is a bot or room by
name or id, or --bot ID, --channel ID or --thread ID; --task ID picks
one of its conversations.
| Command | What it does |
|---|---|
doctor [--wait 2m] |
Checks the server answers, is new enough, who you are to it (and when your session expires), that an engine is ready, and whether bots have a browser. |
bots, channels, models |
Your bots and what each is doing, your rooms, the engines and the models each offers. |
messages TARGET [--limit 30] |
The latest messages of a conversation. |
send TARGET TEXT |
Sends one message and returns at once. --send-id ID makes a retry safe: the same id never sends twice. |
run TARGET TEXT |
Gives a bot or room one job and waits until it is done (below). |
wait TARGET |
Waits on work already running. |
interrupt TARGET [--tree] |
Presses Stop. --tree also stops every teammate still working on its job, in that teammate's own conversation. |
export TARGET --out DIR |
Writes a conversation's job to a folder (below). |
events |
The server's live events as JSON lines (below). |
team apply FILE, team export --out FILE |
Makes the workspace match a team file, or writes one (below). |
engine set KIND, engine list |
Gives the workspace an engine key without a screen (below). |
connect [URL] |
Gets a token for a workspace elsewhere. |
new-bot, new-channel, set-model, edit |
Make a bot or a room, change a bot's model, rewrite an earlier message. |
mcp |
The OpenMausBot MCP server on stdin and stdout (docs/mcp-server.md). |
The message for send and run can be the next word, --text TEXT,
--message-file FILE, or - to read it from stdin.
openmausbot run "Room Lead" --message-file brief.md --timeout 45m --on-input exit --export ./outrun starts a new conversation (titled by --title or the message's first
line) unless --task current or --task ID says otherwise, sends the message
once, and waits for the whole job: the bot's own turn and every piece of work
it handed to a teammate, and theirs in turn. Its first progress line names the
conversation and when the job began (since), so a later openmausbot wait --thread ID --since MS can pick the same job up again.
--wait job(the default) waits for the bot and every teammate;--wait turnonly for the bot's own turn;--wait nonesends and returns.--on-input exit(the default) stops waiting with exit 3 when someone must answer;waitkeeps waiting for the answer;stopstops the job.--on-timeout leave(the default) leaves the work running;stopstops it.--strictfails the run (exit 1) if any teammate's part failed, even when the bot finished.--export DIRwrites the job toDIRonce it is done.DIRmust be new or empty, and that is checked before anything is sent;--forcewrites into a folder that already has files.
A teammate the bot sent a one-way notice to, or one still working after the
bot was stopped, does not hold the job open: nobody is waiting on it. A
message no turn ran for at all (a room set to answer mentions, sent a message
that names nobody; a queued message removed before its turn) is not a job
done: run ends with state: "idle" and exit 1.
With --json the result carries the job's state (completed, idle,
needs-input, failed, stalled or timed-out), which the exit code
follows (idle after run is exit 1, as above), the job tree, the final
reply and the files it links. Its status
keeps the words MCP clients already read: settled there only says the
conversation stopped working, so a lead whose work failed is settled with
state: "failed" and exit 1.
wait TARGET takes the same options. If nothing is running it returns at
once with exit 0. --for turn waits only for the conversation's own turn.
--since MS (the number run prints, and puts in its "wait again" line)
keeps watching the same job: if it ended while nobody was watching, wait
says how it ended instead of "nothing is running". Ctrl-C prints that line too.
If the server drops for a moment (a restart, a proxy in front of it), run
and wait keep asking for the same job until their time runs out; a refusal
(an expired token) ends them with exit 5 at once.
Stopping a job (interrupt --tree, --on-input stop, --on-timeout stop)
presses Stop in its conversation, then in each teammate's own conversation
that still holds part of it: a turn still running, waiting on its own
teammates, parked on an answer, or about to run again. Work that never
started is dropped by the first Stop, and its teammate's conversation is left
alone.
The waiting is done by the server: each request is held until the job
changes, so a long wait costs one request at a time, not a poll loop. The
same answer is available to anyone: GET /api/threads/:id/job (add
wait=MS&version=V to hold it until the job changes from the version you
have).
openmausbot export "Room Lead" --out ./rehearsal # its current conversation
openmausbot export --thread 3f2a… --out ./rehearsal --root ROOT_ID| Written | What it holds |
|---|---|
manifest.json |
What was written, and anything skipped and why |
job.json |
The job tree: every piece of work, who did it, how it ended and what it said |
transcripts/ |
Every conversation of the job, as .json and .md, secrets removed, at most 2,000 messages each |
files/ |
The files the replies link to or attach (a report, screenshots), per conversation |
screens/ |
Screen captures the server kept |
Each file is fetched under its own reply's permission, exactly as the app
opens a link; a link to something outside that conversation's folders is
skipped and listed in manifest.json. A reply that links a folder brings the
files directly in it. Limits: --max-files 200, --max-screens 100,
--max-bytes 500MB (counted on what is written, so a file that runs past it
is skipped). A folder with files in it is refused unless --force, which
replaces what an earlier export wrote there: the files its manifest.json
lists are removed first, and nothing else in the folder is touched.
openmausbot events --job 3f2a… --until-settled > events.jsonlOne JSON line per event: messages as they are written and changed, threads,
bots and rooms. --kind names the kinds to print instead of those
(--kind message,runtime is messages and token-level events); --screens
adds live screen frames. --thread ID (repeatable) keeps one conversation's
events; --job ID follows every conversation of that job as it grows, and
--until-settled ends when the job does, with its exit code. After a dropped
connection it resumes from where it was; the server's first line says whether
it could ("resumed": false means read the state again). A refusal (an
expired token, a missing scope) ends it with exit 5 instead. The output is
always JSON lines; --jsonl is accepted and changes nothing.
openmausbot engine set claude --key-env ANTHROPIC_API_KEY --base-url https://gateway.example.com --install
openmausbot engine listKIND is claude, openai, openrouter, openai-compatible (needs
--base-url), xai, groq, mistral or cerebras. The key comes from an
environment variable (--key-env) or a file (--key-file), is written once
through the same setting the Settings screen uses, and is never printed or
read back. An engine that already has a key is left alone unless --rotate,
so a script can run this on every start. The server never shows the address
of a key it has, so on such an engine a different --base-url is not written
either (it says so): --rotate writes both. For claude, the key runs every
Claude bot (engine claude); --only-claude-api keeps it to "Claude (API
key)" bots (engine claudeApi). --install installs the engine's command if
it is missing, and waits for the install to finish (minutes on a fresh
machine). Changing keys needs a session with full access.
A team file says what the workspace should have: teams and their briefs, bots with their standing instructions, models, approval levels and computers, skills, rooms, and the engines they run on. Write it in YAML or JSON.
openmausbot team apply launch-room.yaml # plan, then apply
openmausbot team apply launch-room.yaml --dry-run # only the plan
openmausbot team apply launch-room.yaml --check # exit 6 if anything would change (CI)
openmausbot team export --team "Launch Room" --out exported.yamlApply prints its plan, then makes the changes:
team "Launch Room": create · brief set (5 lines)
bot "Room Lead": create (Chief of Launch Room · claude/claude-sonnet-5-5 · Full access · computer local)
bot "Priya": update title "Buyer" → "Skeptical procurement manager"; soul 3 lines changed
bot "Old visitor": not in file (kept; --prune deletes it)
Plan: 6 to create, 1 to change, 0 unchanged, 0 to delete.
Applied: 6 created, 1 changed, 0 deleted.
Run it again and it prints No changes. and sends nothing: it compares the
file with what the server holds, field by field. Bots are matched by name (any
case), or by was: when you rename one; of two bots with one name, the one
already in the file's team. A rename onto a name another bot has is refused.
A field the file leaves out is left as it is (a new model on the same engine
keeps the bot's effort). Teams are never deleted, nor skills the file does not
list. Bots and rooms a team no longer lists are deleted only with --prune,
and only in a team marked prune: true. Apply is not atomic: if a step is
refused it stops, says what it had already done, and exits 1; applying again
carries on. A bot that is in the middle of a turn is changed once it is idle,
waiting up to --wait-idle (2 minutes) and then reporting it.
Every change goes through the same requests the app makes, with the same checks. Apply adds no confirmations of its own: where the server needs one (Auto on this computer, a Chief's access to other teams), the file saying so is the confirmation.
version: 1 # required
engines: # optional; needs full access
claude: { key: SECRET, baseUrl: URL | SECRET, everyClaudeBot: true, install: false }
openai | openrouter | xai | groq | mistral | cerebras: { key: SECRET }
openaiCompatible: { key: SECRET, baseUrl: URL | SECRET }
# SECRET is { env: NAME } or { file: PATH }. A key written in the file is refused.
defaults: BOT FIELDS # every bot gets these unless it sets its own
teams:
- name: Launch Room # 1-60 characters
brief: TEXT | { file: PATH } # the team's shared instructions
prune: false # --prune may delete its bots and rooms the file leaves out
bots: # at least one
- name: Room Lead # 1-100 characters, unique
was: Old name # rename that bot instead of making a new one
team: Launch Room
title: Launch rehearsal lead
description: TEXT
chief: true # Chief of Staff of its team (one per team); false removes it
manages: [Support] # other teams this Chief may hand work to
soul: TEXT | { file: PATH } # standing instructions, at most 24,000 bytes
model: claude/claude-sonnet-5-5 # INSTANCE/MODEL; openmausbot models lists them
effort: none | low | medium | high | xhigh | max
approval: ask | edits | auto | full # a new bot starts at full; Custom is set in the desktop app
computer: auto | off | browser | local | vm | cloud
cwd: /work/room # a full path that exists on the server
browser: true | false | { enabled: true, profile: guest }
tools: { allow: [SELECTOR], deny: [SELECTOR] } # docs/tool-selection.md; {} means every tool
skills:
- { file: skills/launch-report/SKILL.md, enabled: true }
rooms:
- name: Rehearsal
team: Launch Room
members: [Room Lead, Priya] # bot names
brief: TEXT | { file: PATH }
responder: everyone | mentions | BOT NAMEPaths are relative to the team file. Apply refuses (exit 2), naming where:
an unknown field, a duplicate name, two Chiefs in one team, manages without
chief, a model not written as INSTANCE/MODEL, a cwd that is not a full
path on the server (/work/room, or C:\work\room on a Windows server; ~
is refused too: it would not mean the same there), a missing file or
environment variable, a key written into the file, and anything longer than
the app takes (a name or title on more than one line or over 100 or 200
characters, a soul or team brief over 24,000 bytes, a room brief over 12,000
characters, a skill over 256 KiB). It warns about a bot with no computer: on
auto it may ask which computer to use, and nobody is there to answer a
headless run. An engine's key, and a base URL kept as a secret, are read only
when they have to be written: when the engine has no key yet, or with
--rotate-secrets (on an engine that has a key, the plan says its base URL
could not be compared). Souls, briefs and skills are read with LF line ends,
and paths in the file may use either separator, so the same file applies
alike from a Windows checkout and from Linux CI.
Before it writes anything, apply also checks the file against the server
(exit 1): every model must be one its engine offers, every room member a bot
that exists or that the file makes, and every tools selection one the bot's
engine can hold. Claude, Codex and the other agent engines limit only MCP
tools (mcp:SERVER:TOOL); a native: selector for one of them would stop
every turn it runs, so apply refuses it (docs/tool-selection.md lists which
engines can limit built-in tools). tools: {} or { deny: [] } means every
tool: the bot's selection is cleared.
team export writes the workspace in this format, skills as SKILL.md files
beside it and engine keys as { env: SET_ME } placeholders, so applying an
export to the same workspace prints No changes.. A key written into a soul,
a brief or a skill is taken out («redacted N chars»), as the app's own team
export takes it out, and the export says where; apply leaves a field with that
mark as it is, so the secret stays only on the server.
One Room Lead and the visitors who rehearse a launch, on a sandbox with no Claude login: the key runs every Claude bot.
# launch-room.yaml — openmausbot team apply launch-room.yaml
version: 1
engines:
claude:
key: { env: ANTHROPIC_API_KEY } # or { file: /run/secrets/anthropic }
baseUrl: { env: ANTHROPIC_BASE_URL } # your gateway; a plain https:// address works too
everyClaudeBot: true # every Claude bot (engine "claude") runs on this key
install: true # install Claude Code if the sandbox lacks it
defaults:
team: Launch Room
model: claude/claude-haiku-5-5
approval: full # nobody is there to answer a permission card
computer: browser # works only in its own built-in browser
browser: { enabled: true, profile: guest } # a fresh temporary profile per bot
cwd: /work/visitors # where screenshots go (create it first)
teams:
- name: Launch Room
prune: true
brief: |
We rehearse a product launch before it happens. Visitors use the site the way a
first-time visitor would. Never sign up with real details, pay, post or email anyone.
Never ask the person anything: if something blocks you, say exactly what and stop.
Save a screenshot at every step as <your-name>-<step>.png in your folder, and end
your report with a Markdown list linking each one.
bots:
- name: Room Lead
chief: true
title: Launch rehearsal lead
description: Hands the site to every visitor, then writes the launch report.
model: claude/claude-sonnet-5-5
effort: medium
computer: local
cwd: /work/room
browser: false
soul: { file: souls/room-lead.md }
skills:
- file: skills/launch-report/SKILL.md
- name: Priya
title: Skeptical procurement manager
soul: |
You are Priya, 41, who buys software for a 200-person company. You distrust vague
claims and look for pricing, security and who else uses it before anything else.
- name: Marcus
title: Rushed founder on a phone-sized screen
soul: |
You are Marcus, 29, running a two-person startup. You give a site 30 seconds,
skim headlines, and try the main button first. Use a 390x844 viewport.Then rehearse, wait for every visitor, and keep the report, transcripts and screenshots:
openmausbot run "Room Lead" "Rehearse the launch of https://example.com with every visitor, then write report.md." \
--timeout 45m --export ./rehearsalA Room Lead can brief up to 64 teammates in one turn before any of them reports back.
The visitors read sites nobody vetted. A Claude bot keeps its built-in tools
(a shell, file edits) whatever its tools say, so run them where those tools
can do no harm, such as a throwaway sandbox like Launch Room's, or give them
an engine that can limit built-in tools.
# .github/workflows/rehearse.yml (a sketch)
- run: npm install -g openmausbot @anthropic-ai/claude-code
- run: openmausbot serve --no-pair --data-dir "$RUNNER_TEMP/omb" &
env: { OMB_ANTHROPIC_API_KEY: "${{ secrets.ANTHROPIC_API_KEY }}" } # the workspace's Claude key from the start
- run: openmausbot doctor --wait 2m # the server answers and an engine is ready
- run: openmausbot team apply team.yaml
- run: openmausbot run "Release checker" "Check the deploy at ${{ env.URL }}" --timeout 20m --strict --export ./out
- uses: actions/upload-artifact@v4
with: { name: release-check, path: ./out }doctor --wait waits for an engine too, so the server gets its key in its
own environment, as here (OMB_ANTHROPIC_API_KEY; the team file's engines:
section, or openmausbot engine set, can set it later instead).
The step fails when the job fails (exit 1), needs someone (exit 3) or runs out
of time (exit 4). To drive a workspace that is already running elsewhere, set
OPENMAUSBOT_URL and pass a token made with openmausbot connect as a secret
file (--token-file).
openmausbot mcp serves the same operations as MCP tools on stdin and stdout
(docs/mcp-server.md): wait_for_conversation waits on the
same job, and returns the same result, as wait.