CLI Agents Setup
Setup guide for the three CLI agents the engine spawns as subprocesses — Claude Code, Codex, OpenCode: what to install, authenticate, and (optionally) configure on the host.
TL;DR
- You do not configure the omnislash MCP server in any agent config file. The engine injects it programmatically per invocation, with the caller’s auth context appended as URL query params. If you declare a server named
omnislashyourself, the engine drops it with a warning — it owns that name. .mcp.json,.codex/config.toml,opencode.jsonare for your extra MCP servers (Playwright, Supabase, internal services, …) — not for omnislash.omnislash doctorverifies that the CLI binaries are onPATH, that their credentials are still valid, and that at least one user-level MCP config file exists.
Required CLI binaries
which claude codex opencode
If the binary is not on PATH, set the absolute path in .env:
| Env var | Default |
|---|---|
CLAUDE_CLI_PATH | claude |
CODEX_CLI_PATH | codex |
OPENCODE_CLI_PATH | opencode |
omnislash doctor (check CLI binaries) only probes for the providers that actually appear in your models.json chains for the configured roles — projects that run pure-API (smart / fast only) need none of them.
Authentication
Claude Code and Codex use OAuth tokens stored in your home directory. Both keep two tokens, and the difference matters:
| Provider | Credential file | Access token (short) | Session / refresh token (long) |
|---|---|---|---|
| Claude Code | ~/.claude/.credentials.json | claudeAiOauth.expiresAt (ms timestamp, ~8h) | claudeAiOauth.refreshToken + refreshTokenExpiresAt (~3 weeks) |
| Codex | ~/.codex/auth.json | tokens.access_token (JWT — expiry from exp, ~1h) | tokens.refresh_token + auth_mode: chatgpt |
An expired access token is not a problem — for about a day. Both CLIs refresh it lazily on their next run, so the state normally heals itself within seconds and needs no manual action. What actually requires a re-login is an expired or missing session.
Everything downstream follows that split (the engine reads both files through one shared credential parser):
- Billing attribution (
billing: subscriptionvsmeteredon every usage segment) keys on the session — a run that starts mid-refresh is still billed to your subscription. omnislash doctorwarnsrefreshes on next runfor a stale access token — visible, but explicitly not an action item — and fails only when the session is gone.omnislash auth-agentsskips the interactive login while the session is alive.
Where the split stops holding. “Refreshes on next run” is a prediction about the provider, and no file check can confirm it — confirming would mean performing a refresh, and a refresh rotates the single-use token. So the prediction is bounded: a token expired for more than 24 hours was renewed by nothing, and both surfaces stop calling it self-healing. doctor reports it as blocked with omnislash auth-agents <cli> --force, and auth-agents prints the same instead of skipping (--force matters — the plain check short-circuits on exactly that credential). Check whether any agent has actually run on that harness before re-logging in: a login rotates the refresh family, so it is not free.
The signal that arrives first is the chat notice. A rejected refresh is classified authentication_failed and announced on your status surface — the failing model plus how to fix it, once a day per model — while the fallback chain keeps answering on the next step. That notice, not the file check, is how you learn a credential died, and you can act on it without leaving the chat: its Sign in button starts the same login /connect → 🤖 Agent sign-in does (see below). It also names the two terminal-side alternatives — the native login in the dashboard terminal (Terminal → pipelines) and the auth-agents command from the brain repo.
OpenCode authentication is delegated to whatever provider you select via the model ref (opencode:openai:gpt-4o → standard OPENAI_API_KEY). No credential file in the registry today.
Local interactive login
Run the provider’s native command from your shell — the same argv the engine
spawns (CLI_HARNESS_CREDENTIALS):
claude auth login # Claude Code
codex login --device-auth # Codex
Neither is the obvious spelling, and the obvious one silently does nothing:
claude login is forwarded to the interactive TUI as a prompt and never
authenticates, and codex auth is the TUI form. --device-auth prints a link
and a code instead of opening a localhost callback, so it also works over SSH.
Production refresh from chat
/connect → 🤖 Agent sign-in lists every harness with a credential file and what its session looks like, and signing one in needs nothing but the chat you are already in. Each harness is graded the same way the dashboard’s Credentials page grades it. A harness whose token simply has not refreshed because nothing has run it shows as idle, not as a problem. ⚠️ appears only when a re-login would actually fix something: never signed in, signed out, an unreadable file, or a sign-in that agents saw rejected.
To sign a harness in:
- Pick the harness. The engine starts its native login inside the
pipelinescontainer — the only one that may touch~/.claude/~/.codex. - You get the provider’s sign-in link. Open it in any browser.
- Claude Code shows you a code on that page — send it back as a reply. Codex shows a one-time code to type into the page instead, and finishes by itself.
- The engine re-reads the credential file and tells you what it now holds.
No shell, no SSH, no terminal. The whole flow is bounded at ten minutes, and a mistyped code is simply re-asked (three attempts).
Two sign-ins of the SAME harness at once is not possible by construction: the login is a Restate Virtual Object keyed by harness, and its per-key lock serializes them — a second request waits rather than racing. That matters more than it sounds: refresh tokens are single-use, so two concurrent logins against one harness invalidate the whole token family. Different harnesses are different keys and do run side by side, which is safe — they are different files. A request that arrives within a minute of a successful sign-in is treated as the same tap and stops — that is the owner pressing a button that showed them nothing, not a second login. The window keys on when a login last SUCCEEDED, never on how healthy the credential looks: a revoked refresh token still reads as a live session off the file, and refusing to re-auth that one would refuse the very case this flow exists for.
Production refresh via SSH
omnislash auth-agents runs the same login commands inside the production container over SSH (TTY forwarded) — the terminal-side equivalent of the flow above, for when you are already at a shell.
omnislash auth-agents # all CLIs needed by config
omnislash auth-agents claude # one provider
omnislash auth-agents --all # every known CLI (claude + codex)
omnislash auth-agents --force # re-auth even if the OAuth session is still alive
Required env (in your local .env):
PROD_SERVER_HOST— e.g.omnislash@1.2.3.4PROD_PROJECT_DIR— remote checkout path, e.g.~/my-brain
Optional: user MCP servers
Each CLI harness has its own native config file for MCP servers you want available outside omnislash (Playwright, Supabase, internal company servers, …). Place these in your brain repo so they ride along with the project. The engine never writes to these files.
Every address in these files has to be right in every place the repo is checked out — your laptop, CI, and the production container where an agent runs against a mounted copy. Two rules follow, and the samples below obey both:
- Address a sidecar by its service name, not
localhost—http://playwright-mcp:8931/mcp.localhost:8931is right only on a host runningomnislash dev, and wrong inside every container. ${VAR}is an ENGINE feature, and only one of these three files gets it. The engine interpolates.mcp.json(below) and.agent.mdfrontmatter;.codex/config.tomlandopencode.jsonare read by Codex and OpenCode themselves, and neither expands${VAR}— a placeholder written there reaches the harness as the literal string. Use a plain address in those two, and each harness’s own env mechanism for secrets.- In
.mcp.jsonand frontmatter, give every${VAR}a default, or expect the entry to disappear. A placeholder nothing provisions is not mounted at all (the engine drops the entry and warn-logs which variable was missing) — because a literal${…}reaching the harness fails to connect, and the harness then tells the agent that a human must authorize the server. An unattended heartbeat has no human, so that becomes a false finding on every tick.
Claude Code — .mcp.json (repo root)
The engine reads .mcp.json in the agent’s working directory itself and hands Claude Code the merged map (.mcp.json < agent frontmatter < omnislash), in strict mode: that map is the agent’s entire MCP set. Anything else Claude Code would normally mount on top — the claude.ai connectors of the account the CLI is logged into (Gmail, Drive, Claude Docs, …), user-scope claude mcp add servers, plugin servers — is not mounted. Those sources bypass your declaration and your capabilities, and they connect on their own timing, which changed the agent’s tool set from turn to turn and broke the prompt cache. A server you want an agent to have goes in .mcp.json or its frontmatter.
Every session logs the servers it mounted (Claude session started — MCP servers mounted); a declared server that did not reach connected (pending, failed, needs-auth) turns that line into a warn, since its tools are missing from that turn.
{
"mcpServers": {
"playwright": {
"type": "http",
"url": "${PLAYWRIGHT_MCP_URL:-http://playwright-mcp:8931/mcp}"
},
"supabase": {
"type": "http",
"url": "${SUPABASE_MCP_URL}",
"headers": { "Authorization": "Bearer ${SUPABASE_ACCESS_TOKEN}" }
}
}
}
${VAR} placeholders interpolate from the process environment (recursively, in URLs and header values). supabase here deliberately has no default: it is the shape of an entry that requires provisioning, and on a deployment that does not set SUPABASE_MCP_URL it is dropped rather than mounted broken.
Codex — .codex/config.toml (repo root)
Codex reads .codex/config.toml from cwd. Project entries plus the home-level ~/.codex/config.toml (written outside the engine) get merged by Codex itself; the engine still injects omnislash per call regardless.
[mcp_servers.playwright]
url = "http://playwright-mcp:8931/mcp"
[mcp_servers.supabase]
url = "https://api.supabase.com/mcp"
bearer_token_env_var = "SUPABASE_ACCESS_TOKEN"
Codex remote-MCP supports only Streamable HTTP; SSE-only servers are silently upgraded. Codex does not expand ${VAR} in this file — bearer_token_env_var is how it reads a secret from the environment, and the URL has to be literal.
The engine sets developer_instructions on every runtime Codex spawn — that is how the role’s system prompt reaches Codex — so a developer_instructions key in this file (or in ~/.codex/config.toml) is overridden for engine agents. Leave Codex’s own base prompt (base_instructions / model_instructions_file) alone too: the role prompt is built to sit on top of it.
OpenCode — opencode.json (repo root)
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"playwright": {
"type": "remote",
"url": "http://playwright-mcp:8931/mcp"
}
}
}
OpenCode collapses http + sse into the single remote transport. Like Codex’s file, this one is read by OpenCode rather than the engine, so ${VAR} does not expand here either.
Note:
omnislashis delivered to OpenCode through a per-invocation config the engine injects, so even with noopencode.jsonpresent the agent still sees the engine MCP. The same injected config carries the role’s system prompt as aninstructionsentry (a file the engine writes; OpenCode concatenatesinstructionsarrays across layers, so your own entries still apply).
Per-agent MCP entries
If a server should only appear for one role (e.g. qa-tester gets Playwright but coder does not), declare it in the agent’s .agent.md frontmatter instead — the engine merges it on top of the per-provider config:
---
name: QA Tester
modelRef: cli:smart
mcpServers:
playwright:
type: http
url: ${PLAYWRIGHT_MCP_URL:-http://playwright-mcp:8931/mcp}
enterprise:
type: http
url: https://enterprise.example.com/mcp
headers:
Authorization: Bearer ${ENTERPRISE_TOKEN}
---
Merge priority (rightmost wins): per-provider config file < agent frontmatter < omnislash (engine-managed, always last).
Two traps worth knowing when a role declares mcpServers:
- Pin a CLI-mode
modelRef(cli:smartor a concreteclaude:<model>). MCP servers reach CLI harnesses only, and a bare role word (smart,smartest) resolves API-first — such an agent boots with its full prompt and zero MCP tools. The engine logs a warning when this happens. - Give every
${VAR}a default (${PLAYWRIGHT_MCP_URL:-http://playwright-mcp:8931/mcp}). Frontmatter is env-interpolated at load time, and an unresolved placeholder drops the whole agent file — the agent silently falls back to the general-role defaults. (In.mcp.jsonthe blast radius is smaller — one entry, warn-logged — but the remedy is the same. The Codex and OpenCode files never interpolate at all, so a placeholder there is simply a broken address.)
Doctor checks
omnislash doctor runs three checks relevant to CLI agents:
| Check | Pass condition |
|---|---|
| CLI binaries | Every CLI referenced by your active model chains resolves the way the runtime spawns it: <HARNESS>_CLI_PATH when set (a value with a / is probed on disk), otherwise which <binary>. Warns on miss, with both remedies — the install command and the *_CLI_PATH override. |
| CLI credentials | Credential file present for every needed provider and its OAuth session alive. A recently expired access token warns (refreshes on next run) instead of failing — it heals itself on the next agent spawn, so that warning carries no auth-agents hint. One expired for over 24 hours does not: nothing refreshed it, so it warns with omnislash auth-agents <cli> --force. Also warns on a missing file or a dead session; hint there: omnislash auth-agents. |
| MCP configs | At least one of .mcp.json, .codex/config.toml, opencode.json exists in project root. Pure presence check — content is user territory. |
Pass output for the MCP check looks like:
✓ MCP configs Claude Code, Codex, OpenCode (omnislash MCP injected at runtime)
The trailing parenthetical is the doctor’s way of reminding you that omnislash itself is not configured here — the engine wires it up at runtime.