Engines
BattleLab does not implement agents. It organizes them: six AI coding CLIs, a plain shell, API clients that drive Codex, Claude and opencode without a terminal, and an API agent that talks to a model endpoint you configure, all presented through one session list. Each agent or terminal is described by a single declarative manifest (plugin.toml) that tells BattleLab everything it needs: the binary, the shape of a session id, where the engine keeps its sessions, how to resume and start one, what the app may do with it, and how to show it.
The roster is the set of manifests. Nothing else in the app keeps a list of engines, so the sidebar, the map, the new-session picker, handoff, missions, usage and Settings → Agents all show the same engines in the same order. Agent definitions documents the format.
The rule that shapes everything
Every engine handles its own conversation persistence. BattleLab reads each tool's own on-disk history for the sidebar, and "resume" always means launching that tool's own resume command under BattleLab's dtach PTY. Nothing is re-implemented and, with one exception, nothing is written back.
The API agent is the one engine with no tool of its own, so BattleLab keeps its transcript itself (see The API agent).
The other exception is Claude Code, whose archive moves its JSONL between projects/ and projects-archive/. Every other engine's archive is a flag in BattleLab's own sidecar. opencode's SQLite database in particular is opened read-only and never written, which is why renaming, favouriting and archiving an opencode session change nothing inside opencode.
The roster
This table is generated from the in-tree manifests, so it cannot drift from what the app runs. The commands are the argv the launcher builds (<id> is the session id, <dir> its working directory), shown with the binary's name instead of its resolved path.
| Engine | Binary · override | Sessions read from · override | Resume | New session | Permission bypass |
|---|---|---|---|---|---|
Claude Code (claude) | claude · AGENT_SESSIONS_CLAUDE_BIN | ~/.claude | claude --resume <id> | claude --session-id <id> — pinned id | --dangerously-skip-permissions |
Claude — API (claude-api) | Claude Code's | ~/.local/share/agent-sessions/native/claude-api · AGENT_SESSIONS_CLAUDE_API_DIR | contained worker — runtime api, protocol claude-stream-json | new conversation | — |
opencode (opencode) | opencode · AGENT_SESSIONS_OPENCODE_BIN | ~/.local/share/opencode · AGENT_SESSIONS_OPENCODE_DB, AGENT_SESSIONS_OPENCODE_LOG | opencode <dir> --session <id> | opencode <dir>, then adopt the id it mints | — |
OpenCode 2 (opencode2) | opencode2 · AGENT_SESSIONS_OPENCODE2_BIN | ~/.local/share/agent-sessions/agents/opencode2 · AGENT_SESSIONS_OPENCODE2_DIR | opencode2 --standalone <dir> --session <id> | opencode2 --standalone <dir>, then adopt the id it mints | --auto |
opencode — API (opencode-api) | opencode's | ~/.local/share/agent-sessions/native/opencode-api · AGENT_SESSIONS_OPENCODE_API_DIR | contained worker — runtime api, protocol opencode-acp | new conversation | — |
Codex (codex) | codex · AGENT_SESSIONS_CODEX_BIN | ~/.codex/sessions · AGENT_SESSIONS_CODEX_SESSIONS_DIR | codex resume <id> | codex --cd <dir>, then adopt the id it mints | --dangerously-bypass-approvals-and-sandbox (new sessions only) |
Codex — API (codex-api) | Codex's | ~/.local/share/agent-sessions/native/codex-api · AGENT_SESSIONS_CODEX_API_DIR | contained worker — runtime api, protocol codex-app-server | new conversation | — |
Gemini CLI (gemini) | gemini · AGENT_SESSIONS_GEMINI_BIN | ~/.gemini/tmp · AGENT_SESSIONS_GEMINI_TMP_DIR | gemini --resume <id> | gemini --session-id <id> — pinned id | --yolo --skip-trust |
GitHub Copilot CLI (copilot) | copilot · AGENT_SESSIONS_COPILOT_BIN | ~/.copilot | copilot --no-auto-update --resume <id> | copilot --no-auto-update --session-id <id> — pinned id | --yolo |
Copilot — API (copilot-api) | GitHub Copilot CLI's | ~/.local/share/agent-sessions/native/copilot-api · AGENT_SESSIONS_COPILOT_API_DIR | contained worker — runtime api, protocol copilot-acp | new conversation | — |
Antigravity (antigravity) | agy · AGENT_SESSIONS_AGY_BIN | ~/.gemini/antigravity-cli · AGENT_SESSIONS_ANTIGRAVITY_DIR | agy --conversation <id> | agy, then adopt the id it mints | --dangerously-skip-permissions |
Kimi Code (kimi) | kimi · AGENT_SESSIONS_KIMI_BIN | ~/.kimi-code · AGENT_SESSIONS_KIMI_DIR | kimi -S <id> | kimi, then adopt the id it mints | -y |
API agent (apichat) | — | ~/.local/share/agent-sessions/chat · AGENT_SESSIONS_CHAT_DIR | no process — runtime chat, endpoint openai-chat | new conversation | — |
Shell (shell) | bash · AGENT_SESSIONS_BASH_BIN | ~/.claude/shell-sessions · AGENT_SESSIONS_SHELL_DIR | a fresh process — nothing to resume | bash -l — pinned id | — |
Every override is read from the app's env file. The same store path drives both the sidebar row and the scroll-up transcript, so pointing one at a different location moves both together:
| Variable | Engine | Overrides |
|---|---|---|
AGENT_SESSIONS_CLAUDE_BIN | Claude Code | the binary |
AGENT_SESSIONS_CLAUDE_API_DIR | Claude — API | the store root (~/.local/share/agent-sessions/native/claude-api) |
AGENT_SESSIONS_OPENCODE_BIN | opencode | the binary |
AGENT_SESSIONS_OPENCODE_DB | opencode | the store's db (~/.local/share/opencode/opencode.db) |
AGENT_SESSIONS_OPENCODE_LOG | opencode | the store's log (~/.local/share/opencode/log/opencode.log) |
AGENT_SESSIONS_OPENCODE2_BIN | OpenCode 2 | the binary |
AGENT_SESSIONS_OPENCODE2_DIR | OpenCode 2 | the store root (~/.local/share/agent-sessions/agents/opencode2) |
AGENT_SESSIONS_OPENCODE_API_DIR | opencode — API | the store root (~/.local/share/agent-sessions/native/opencode-api) |
AGENT_SESSIONS_CODEX_BIN | Codex | the binary |
AGENT_SESSIONS_CODEX_SESSIONS_DIR | Codex | the store root (~/.codex/sessions) |
AGENT_SESSIONS_CODEX_API_DIR | Codex — API | the store root (~/.local/share/agent-sessions/native/codex-api) |
AGENT_SESSIONS_GEMINI_BIN | Gemini CLI | the binary |
AGENT_SESSIONS_GEMINI_TMP_DIR | Gemini CLI | the store root (~/.gemini/tmp) |
AGENT_SESSIONS_COPILOT_BIN | GitHub Copilot CLI | the binary |
AGENT_SESSIONS_COPILOT_API_DIR | Copilot — API | the store root (~/.local/share/agent-sessions/native/copilot-api) |
AGENT_SESSIONS_AGY_BIN | Antigravity | the binary |
AGENT_SESSIONS_ANTIGRAVITY_DIR | Antigravity | the store root (~/.gemini/antigravity-cli) |
AGENT_SESSIONS_KIMI_BIN | Kimi Code | the binary |
AGENT_SESSIONS_KIMI_DIR | Kimi Code | the store root (~/.kimi-code) |
AGENT_SESSIONS_CHAT_DIR | API agent | the store root (~/.local/share/agent-sessions/chat) |
AGENT_SESSIONS_BASH_BIN | Shell | the binary |
AGENT_SESSIONS_SHELL_DIR | Shell | the store root (~/.claude/shell-sessions) |
Two ways a new session gets its id
This is the one genuine asymmetry between engines, and it explains a behaviour you will otherwise find puzzling. A manifest declares it as session_id.mint.
Pinned id (pinned). The engine accepts a caller-supplied session id, so BattleLab mints a UUID and launches straight into it. The row exists with its final identity from the first frame.
Launch then reconcile (adopt). The engine mints its own id and will not be told otherwise. BattleLab snapshots the engine's store before launch, starts the session under a temporary new-<uuid> placeholder, then diffs the store afterwards and adopts whichever id appeared. A reconciling engine's row therefore carries a placeholder id for the first moments of its life.
Choosing a model, and the instruction files an engine reads
The new-session form offers a Model select for an engine whose manifest declares launch.model. default is always first and selected: it adds no flag, which is exactly how a session started before the picker existed. Any other choice is one of the manifest's models (an alias such as opus is replaced by its id before anything is stored or launched) or an id you added under Settings → Agents → engine, for a model released after this build.
- A choice that is no longer offered — an added id you have since removed, a model from another engine — is refused before anything starts, never quietly replaced by
default. - An engine whose model is set in its own configuration (opencode, Kimi Code, Antigravity) or, for the API agent, in its endpoint settings, offers
defaultonly. - A resume keeps its model. Where the engine takes the flag on resume (Claude Code), a session started on a model resumes on it; if that model is no longer offered, the resume is refused rather than quietly using the default. Asking a Codex or Gemini session to resume on a different model is refused, and you start a new session instead. Attaching to a session that is already running never changes its model.
- The session records the model it asked for. What it actually ran on is read only from the engine's own transcript (Claude Code and Codex write it); anywhere else it is shown as unknown.
| Engine | Model at launch | Models offered | Instruction files |
|---|---|---|---|
Claude Code (claude) | --model <id> on new sessions and on resume | claude-opus-5 (opus), claude-sonnet-5 (sonnet), claude-haiku-4-5 (haiku), claude-fable-5-1, claude-opus-4-8, claude-sonnet-4-6 | CLAUDE.md |
Claude — API (claude-api) | — | — | — |
opencode (opencode) | set in the agent's own configuration | default only | AGENTS.md |
OpenCode 2 (opencode2) | set in the agent's own configuration | default only | AGENTS.md |
opencode — API (opencode-api) | — | — | — |
Codex (codex) | --model <id> on new sessions (a resume keeps its model) | gpt-5-codex, gpt-5 | AGENTS.md |
Codex — API (codex-api) | — | — | — |
Gemini CLI (gemini) | --model <id> on new sessions (a resume keeps its model) | gemini-2.5-pro, gemini-2.5-flash | GEMINI.md |
GitHub Copilot CLI (copilot) | --model <id> on new sessions and on resume | gpt-5.5 | AGENTS.md, CLAUDE.md |
Copilot — API (copilot-api) | — | — | — |
Antigravity (antigravity) | set in the agent's own configuration | default only | GEMINI.md |
Kimi Code (kimi) | set in the agent's own configuration | default only | AGENTS.md |
API agent (apichat) | set in the agent's own configuration | default only | — |
Shell (shell) | — | — | — |
The shell engine: a terminal with no agent
shell is a plain bash -l login shell, declared with identity.kind = "terminal". It exists because a session organizer that can only hold agents forces you out to a separate terminal for everything else.
Three consequences worth knowing:
- No reboot survival. Agent engines survive a reboot because their own resume command restores the conversation from their own store. A shell has no saved conversation, so it reattaches only while its PTY lives. Scroll-up is the scrollback ring alone, with no transcript renderer behind it.
- Nothing types into it for you. A
terminaldefinition may not declare seeding, orchestrator input or raw-TTY repair — text the app typed into a bare shell would run as a command — so it is never a handoff target and never driven by a mission. - AI review still works. The reviewer builds its input from the transcript and the live screen, and errors only when both are empty — so a shell is reviewed on its terminal screen alone, with no special-casing anywhere in the review code.
API clients: Codex, Claude and opencode without a terminal
Codex — API (codex-api), Claude — API (claude-api) and opencode — API (opencode-api) run the CLI you already have installed, but through its structured protocol instead of a terminal: Codex's app-server, Claude Code's stream-JSON mode and opencode's Agent Client Protocol (opencode acp). Its login, configuration, MCP servers, skills and instruction files all apply — BattleLab adds no tools and no keys of its own. Each session runs in its own contained worker; New session lists these under API — structured, no terminal.
The session view shows your messages, what the agent did, and every request it makes. A request card lists the whole request — command, folder, network access, proposed rule changes, or a Claude prompt's title, path and reason — and offers only the choices the agent accepts right now, one request at a time. A standing rule is offered only under Approve always (below). Codex file changes are shown with their patch but can only be declined, because nothing guarantees Codex writes exactly the patch shown. opencode — API runs BattleLab's own opencode agent, which asks before every tool call — commands, edits, file reads, web fetches, subagents, MCP tools — whatever your own opencode config allows; it uses your default model and prompt, like build. An edit's card shows the full diff, and Approve once applies exactly that request (opencode's "always allow" is never offered). It needs opencode 1.18.35 or later; your console opencode and its config are unchanged, and a conversation started here does not show up in the console's session list (nor can the console resume it). Interrupt stops the current turn; Stop ends the worker and reports whether it is confirmed gone. Closing the tab never cancels a turn: the view picks up where it left off.
A client that cannot start is listed with the reason — the CLI is missing or older than the protocol needs, there is no systemd user session, or its console agent is disabled. If the console agent is later disabled, existing API conversations stay readable but take no new messages until it is back.
Sending. Send empties the composer at once: the message shows at the end of the conversation as Sending… while BattleLab records it, and you can write the next one straight away. Messages sent meanwhile wait and go out in order. If one is refused, it says why and offers Edit (its words and pictures back in the composer) or Discard; if its outcome is unknown, Retry sends the same message again, which is never recorded twice. Messages not yet recorded live only in this tab: the page asks before a reload or close, and leaving the session drops them.
Attachments. The paperclip accepts documents and other files, including PDFs, Office documents, text, spreadsheets, archives and files without an extension. You can send files without a message, paste files into the composer, or mix them with images, up to four attachments per message. Files use the existing 25 MB upload limit; native image inputs retain their 5 MiB limit. The agent reads documents using its normal tools and permissions, just as in a terminal session. Sent turns show document filenames and image thumbnails; Sent can restore the attachments. After an update, an older running API session may need Stop and a new message to resume before document attachments become available.
Skip permission prompts. New session offers the same toggle for the Codex, Claude and OpenCode API clients, starting at your Settings → Agents → Permission bypass default, as a terminal agent does. A session started with it ticked never asks: Codex runs with no approvals and no sandbox, Claude in its bypassPermissions mode, and OpenCode with an agent whose tool rules allow every action. The session header shows SKIP PERMISSIONS, and reopening the conversation keeps the choice. Such a session is launched only once BattleLab has confirmed your start: if the connection drops mid-start, the session waits with Start (skips prompts) and Discard, and an unconfirmed one expires after 10 minutes without ever running.
You can also enable it after a supported API session has started. Open Skip permissions from the session header (under Actions on a phone, or the window menu on the map). Confirm Stop turn & enable, or Enable Skip permissions when idle. This stops the current turn, queued messages and pending approvals while preserving the conversation. Send a new message to continue with prompts skipped. If stopping the tools cannot be confirmed, the setting stays off and Retry enabling finishes the same change; reloading does not enable it by itself.
Approve always. When Codex or Claude proposes a standing rule for a request, the card offers it under Approve always, worded with what it covers ("… and any command starting with it", "every Bash call") and where it is saved (Codex's exec policy, your project's or your user Claude settings, or this session only). Broad rules are offered too — it is your instance — and a risky command's options carry its RISKY reason. BattleLab never invents or widens a rule, and a rule can never switch the session's permission mode (use the separate Skip permissions action). OpenCode's native Allow always is offered too. OpenCode omits the matching patterns, so the rule may cover every use of that tool; it lasts for this worker and folder and is lost when the worker restarts. The card states this before you select it.
Approval cards show the action, folder and reason first; Details retains the complete request and identifiers. If OpenCode supplies no usable arguments for a tool, the card explains why it can only be denied or stopped. During a permission change, the approval buttons are disabled.
Risk marks. A command that matches a short, fixed list of risky shapes (recursive or forced deletes, sudo, force-push and other destructive git, recursive chmod/chown, dd/mkfs, killing processes, a download piped into a shell, writes outside the session folder) is marked RISKY with the reason, on its approval card and on the turn's list of what the agent did; one BattleLab could not read is "not classified". The mark only informs — it removes no choice, and an Approve always option for such a command carries the same mark. An unmarked command is not promised to be safe.
Model. New session offers the models the client's own CLI reports — Codex's app-server model list, Claude Code's model picker list — so a model your CLI gained after a BattleLab release shows up without an update. default leaves the choice to the CLI's own configuration. If the CLI cannot be asked, the picker says why and offers default only.
The API agent: a model endpoint with no process
apichat declares runtime.kind = "chat": there is no binary and no terminal. BattleLab sends the conversation to an OpenAI-compatible chat endpoint and shows the replies in a chat pane where the terminal would be. Set it up in Settings → Agents → API agent → Endpoint: a base URL, an API key and a model. Test checks the draft (it lists the endpoint's models) and saves nothing; Save stores it, and the agent appears in the new-session picker at once.
What to know:
- By default it has no tools. It can only talk. Replies are shown as text, never executed and never typed into a terminal, so it is never a handoff target and never driven by a mission. You can let it read files or propose edits (below). Each save needs your approval; it cannot create or delete files or run commands.
- Your text leaves the host. Every message, with the earlier turns that fit, goes to the endpoint you configured. Template secrets are redacted on the way out, the same as AI review.
- The key stays with its endpoint. It is encrypted at rest, never shown again, and bound to the URL's origin: changing the URL to another host needs a new key (or removing the key).
- Long conversations are trimmed. Each request carries as many recent turns as fit the model's context window minus the reply reserve; older turns are left out, and the pane says how many. A single message too long for the window is refused.
- Messages wait for the reply. It answers one message at a time: what you send while it is replying shows as waiting and goes out, in order, once the reply is done. Send empties the composer at once, the same as in the API clients (see Sending above).
- A failed turn keeps your message. Retry sends that same turn again. A timeout means the endpoint may already have processed — and billed — the request, and the pane says so.
- Transcripts are BattleLab's. Each conversation is a JSONL file under
~/.local/share/agent-sessions/chat(AGENT_SESSIONS_CHAT_DIR), mode 0600. Removing the endpoint keeps them readable.
Tools: reading files in the conversation's folder
The Endpoint card's Tools setting is None by default. Read files gives the agent two read-only tools, list_files and read_file, confined to the folder the conversation was started in:
- What leaves the host. Any file it opens is sent to the endpoint you configured, with the conversation. The agent's replies can quote those files, and replies are kept in the transcript like any other. The raw tool output is never stored or shown: the pane lists each call as one line (Listed, Read with the line span, Refused with the reason).
- What is refused. Paths outside the folder; symbolic links as the final path component; hidden paths (
.env,.git/,.ssh/, …); key and credential file names (*.pem,*.key,id_rsa*,credentials*,secrets*, …) — also when reached through a link; binary files; and anything under your excluded folders or outside your project roots. The boundary is the file's path: a hard link placed inside the folder reads its target like any other file there. An ordinary file can still hold a secret, so turn this on only for folders you are content to share with the endpoint. - Checked every time. The setting and your folder rules are read again before every call and before every request. Turning Tools off, or excluding a folder, mid-reply stops the next call — and anything already read that the new rules no longer allow is withdrawn before it is sent.
- Bounded. At most 8 rounds of tool calls per message and 16 calls per round; a read returns at most 2000 lines or 64 KiB, a listing at most 500 entries; everything counts against the context window. After the last round the endpoint must answer. An endpoint or model without tool support fails the message with that reason — turn Tools off for it.
Tools: proposed file edits
Read files and propose edits adds changes to existing text files. The agent must read the complete file first (at most 64 KiB), then propose its replacement. The chat pane shows the path and diff with Reject and Approve & save. Only that proposal is approved; there is no bulk approval. Pending proposals survive reloads and app restarts, and the agent waits for your decision.
Approval checks the file's original version and your current folder/tool settings again. A changed file, excluded folder, moved link, disabled tool, or changed endpoint refuses the save. Ask the agent to read the file again and propose a fresh change. The save uses the Files panel's existing editor: a kernel lease and a retained recovery copy protect concurrent work, and the file's line endings, BOM, mode and access ACL are preserved. A file open elsewhere may refuse a save.
Pending base/proposed text and a continuation checkpoint are held in private proposal files so you can review after restart. Earlier tool-read bodies are withheld from that checkpoint; the agent can read them again. A recorded decision clears pending content from the proposal sidecar, which retains only the audit (path, hashes, size, outcome, operator and time). The separate private file-recovery store retains the displaced file and before/after snapshots, including after a successful save; it does not prune them automatically. If the app stops during a save, it does not replay the approved write: inspect the file before retrying. An interrupted recovery that cannot distinguish its partial restoration from another writer's edit preserves those bytes for inspection. Model replies may quote file text and remain in the ordinary transcript.
Which engines are present
Settings → Agents lists every engine with its state, and each engine has its own page showing everything its manifest declares. The same data is GET /api/engines (the roster, with present, supports_new, the resolved binary, display, capabilities, runtime and status) and GET /api/engines/{id} (one engine's full manifest and binary provenance).
Presence is decided by the binary. agent-sessions doctor runs on every install and resolves each engine's binary — its *_BIN override above if that still executes, else your PATH, else the manifest's binary.search_paths — and records the result into the env file. There is no PATH lookup at launch: the launcher execs the absolute path it resolved, after checking that nobody but you or root can replace that file or any directory above it. A binary you installed yourself is shown as adopted — BattleLab found it rather than installed it.
The API agent has no binary: it is present once its endpoint has a URL, a key and a model.
An engine without a runnable binary cannot start or resume a session. If its store is still on disk, its existing sessions stay listed, so you can find and archive them, but resuming one fails until the binary is back. There is no switch to disable an engine — absence is the off switch.
Removing an engine
The roster is read when the app starts. If an engine's manifest is gone after an update, its running sessions are not killed: they stay attachable, marked retiring, until they exit. No new session, handoff or mission dispatch can start on it — those are refused as "agent removed". Nothing stored is lost: its sessions stay on disk, and a budget or a default that names it stays in your preferences, unused, until the engine comes back.
Identity
A session's app-facing id is engine-qualified: <engine>:<native_id>, e.g. claude:3f2a…. That id is what the sidebar sends on every route call and what keys the metadata sidecar. A single gate resolves an id to its engine and checks the native part against that engine's session_id.pattern before any dispatch, so an id that does not match is rejected rather than passed along.
Verified against
Commit b48d0c4 — src/agent_sessions/plugins/first_party/*/plugin.toml; src/agent_sessions/plugins/; src/agent_sessions/routes/system.py § /api/engines. The tables are generated by scripts/gen-engine-docs.