Ctrl K
ComunidadGuías…

docs/headless-cli.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Run OpenMausBot from scripts and CI

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.

```sh
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 ./rehearsal
```

`run` prints progress as it goes, then the Room Lead's final reply and the
files it links, and writes everything to `./rehearsal`.

## Which workspace, and as whom

- `--url URL` or `OPENMAUSBOT_URL`: a workspace somewhere else. `https://`
  works anywhere; plain `http://` works only on this computer or a private
  network (a LAN address, Tailscale's 100.x or `*.ts.net`, `*.local`).
- `--port N` or `OMB_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:

```sh
openmausbot connect https://maus.example.com --code 7KQ2-M9XD-H4TP --out ~/.openmausbot-token
openmausbot doctor --url https://maus.example.com --token-file ~/.openmausbot-token
```

`connect` also takes the whole link the server printed
(`https://maus.example.com/pair#code=…`), so `--code` can be left out then.
Make the pairing code on the server with `openmausbot pair` (add
`--client --ttl 24h` for a chat-only code that stays open up to a day; a code
works once). 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.

For CI, make an access token that ends on a date you choose:

```sh
openmausbot token create "Nightly CI" --scope client --expires 90d --out ci-token   # on the server, or with an admin token
openmausbot token list                     # name, scope, end date, last used (never the token)
openmausbot token revoke ID                # it stops working at once
```

A `connect` session renews itself while it is used, up to 180 days; an access
token never renews and ends on its date. `--expires` is at most the server's
session limit (180 days unless `OMB_SESSION_MAX_DAYS` says otherwise), and
`--scope client`, the default, can chat and answer approvals but not change
settings. Tokens are listed and revoked like any session (`openmausbot
sessions`, and Settings), and the token itself is written only to `--out`.

## Output and exit codes

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`, `2h` or `30d`.

| 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 |
| 7 | `doctor --host`: this machine is missing something the server needs |
| 130 | Ctrl-C (the work keeps running) |

## Commands

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. On a shared server without a session (`docker compose exec omb openmausbot doctor`), it learns only how many engines are ready, not which (`GET /api/health/engines`). |
| `doctor --host` | Checks this Linux machine can run the server and the bots' sandbox (below). |
| `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, to the conversation the bot or room has open. `--task new` starts a new conversation for it (`--title` names it, else the message's first line), and `--task ID` picks one. It says which conversation it went to. `--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. |
| `token create NAME`, `token list`, `token revoke ID` | Access tokens for CI that end on their date (above). |
| `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](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.

## doctor --host

Before you install a server, check the machine it will run on:

```text
$ sudo openmausbot doctor --host --data-dir /srv/mausbot
ok    Ubuntu 24.04.1 LTS, kernel 6.8.0-45-generic
ok    x86_64
ok    Docker 27.3.1
FAIL  Bot sandbox: AppArmor blocks unprivileged user namespaces
      fix: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0   (and put it in /etc/sysctl.d/ to keep it after a reboot)
ok    seccomp is available
ok    cgroup v2
ok    412 GB free for /srv/mausbot
warn  /srv/mausbot is not on an encrypted disk
      fix: use LUKS full-disk encryption, unless your provider encrypts the disk at rest
ok    8 CPUs, 31 GB memory (a team of 20 needs 8 CPUs and 16 GB)
ok    Port 8799 is free for the server
ok    Ports 80 and 443 are free
ok    Clock is within 1 s of network time (signed requests allow 300 s)
1 problem, 1 warning.
```

It checks the system (Ubuntu 24.04 or 22.04, or Debian 12, are the ones we
test), the processor (x86_64; arm64 runs a server but not a self-hosted Team
server), Docker or Podman, that unprivileged user namespaces are allowed (the
AppArmor and Debian switches), seccomp, cgroup v2, free disk for the data
folder (`--data-dir`, else `OMB_DATA_DIR` or `~/.openmausbot`: at least 10 GB,
and 100 GB for a Team server), disk encryption, CPUs and memory (a Team needs
4 CPUs and 8 GB), the server's port (`--port`, else 8799), ports 80 and 443
(required with `--domain HOST`, else only a warning), and the clock against
network time (signed requests allow 300 seconds). It changes nothing.

On a server that runs MausBot's own connector (a team on its own server), it
also asks the connector for its checks (`mausbot-connector probe --json`,
which starts the bots' box once under the home's own security settings) and
lists any that fail. There the home runs under its own AppArmor profile, so
AppArmor's server-wide switch can stay on: the user-namespace check passes
when the connector's box check does, and otherwise says to run the install
command again.

A problem fails the check (exit 7) and says its fix on the next line; a
warning does not. `--json` prints `{ ok, problems, warnings, checks: [{ id,
status, message, fix }] }`. On macOS and Windows it says it checks Linux
servers, and exits 7.

## run and wait

```sh
openmausbot run "Room Lead" --message-file brief.md --timeout 45m --on-input exit --export ./out
```

`run` 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 turn`
  only for the bot's own turn; `--wait none` sends and returns.
- `--on-input exit` (the default) stops waiting with exit 3 when someone must
  answer; `wait` keeps waiting for the answer; `stop` stops the job.
- `--on-timeout leave` (the default) leaves the work running; `stop` stops it.
- `--strict` fails the run (exit 1) if any teammate's part failed, even when
  the bot finished.
- `--export DIR` writes the job to `DIR` once it is done. `DIR` must be new or
  empty, and that is checked before anything is sent; `--force` writes 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, which ends everything it handed out:
teammates mid-turn, what they handed on, and work that never started. If a
teammate's own conversation still holds part of it after that (a turn still
running, waiting on its own teammates, parked on an answer, or about to run
again), Stop is pressed there too. `stopped` (`teammates` for `interrupt
--tree`) lists each teammate task that was at work and ended; work that never
started is dropped without being listed.

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

## export

```sh
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.

## events

```sh
openmausbot events --job 3f2a… --until-settled > events.jsonl
```

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

## engine

```sh
openmausbot engine set claude --key-env ANTHROPIC_API_KEY --base-url https://gateway.example.com --install
openmausbot engine list
```

KIND is `claude`, `openai`, `openrouter`, `openai-compatible` (needs
`--base-url`), `xai`, `groq`, `greenference`, `modelgo`, `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.

## The team file

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.

```sh
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.yaml
```

Apply prints its plan, then makes the changes:

```text
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.

### Fields

```yaml
version: 1                                  # required
engines:                                    # optional; needs full access
  claude: { key: SECRET, baseUrl: URL | SECRET, everyClaudeBot: true, install: false }
  openai | openrouter | xai | groq | greenference | modelgo | 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 is also in charge of (every bot reaches every bot anyway)
    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 NAME
```

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

### Example: Launch Room

One Room Lead and the visitors who rehearse a launch, on a sandbox with no
Claude login: the key runs every Claude bot.

```yaml
# 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:

```sh
openmausbot run "Room Lead" "Rehearse the launch of https://example.com with every visitor, then write report.md." \
  --timeout 45m --export ./rehearsal
```

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

## In CI

```yaml
# .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`).

## For other agents

`openmausbot mcp` serves the same operations as MCP tools on stdin and stdout
([docs/mcp-server.md](mcp-server.md)): `wait_for_conversation` waits on the
same job, and returns the same result, as `wait`.