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 omnislash yourself, the engine drops it with a warning — it owns that name.
  • .mcp.json, .codex/config.toml, opencode.json are for your extra MCP servers (Playwright, Supabase, internal services, …) — not for omnislash.
  • omnislash doctor verifies that the CLI binaries are on PATH, 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 varDefault
CLAUDE_CLI_PATHclaude
CODEX_CLI_PATHcodex
OPENCODE_CLI_PATHopencode

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:

ProviderCredential fileAccess token (short)Session / refresh token (long)
Claude Code~/.claude/.credentials.jsonclaudeAiOauth.expiresAt (ms timestamp, ~8h)claudeAiOauth.refreshToken + refreshTokenExpiresAt (~3 weeks)
Codex~/.codex/auth.jsontokens.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: subscription vs metered on every usage segment) keys on the session — a run that starts mid-refresh is still billed to your subscription.
  • omnislash doctor warns refreshes on next run for a stale access token — visible, but explicitly not an action item — and fails only when the session is gone.
  • omnislash auth-agents skips 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:

  1. Pick the harness. The engine starts its native login inside the pipelines container — the only one that may touch ~/.claude / ~/.codex.
  2. You get the provider’s sign-in link. Open it in any browser.
  3. 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.
  4. 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.4
  • PROD_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:8931 is right only on a host running omnislash 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.md frontmatter; .codex/config.toml and opencode.json are 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.json and 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: omnislash is delivered to OpenCode through a per-invocation config the engine injects, so even with no opencode.json present the agent still sees the engine MCP. The same injected config carries the role’s system prompt as an instructions entry (a file the engine writes; OpenCode concatenates instructions arrays 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:

  1. Pin a CLI-mode modelRef (cli:smart or a concrete claude:<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.
  2. 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.json the 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:

CheckPass condition
CLI binariesEvery 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 credentialsCredential 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 configsAt 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.

Screens from Sixty Seconds Studio, a fictional demo studio, running on the real dashboard.