Skip to content
Operator has no release yet: these pages describe unreleased main.

Running Seats on a Coding CLI

An LLMProvider of type CLIAgent runs a seat on a coding CLI you already subscribe to, such as Claude Code or Codex, rather than on a metered API key.

It renders Crewlet’s cli-agent provider, which drives the CLI on a subscription login; Crewlet’s Subscription LLM backends describes what the engine does with it. This guide covers what changes when the engine runs in a pod: the image, the login, and agent mode.

The CLI is a program the engine starts, in the engine’s own container, and Crewlet’s image ships none: a derived image adds it, with whatever runtime it needs (Node.js for most of them; Claude Code and OpenCode need none, each being one native binary). Build one from the engine image you run, named by digest: the engine of the operator’s release, or the copy of Crewlet’s main image that Installing makes in your registry. Pin both the engine and the CLI.

A class names one engine image, so a company that runs two CLIs, or a CLI and stdio tool servers, needs one image with all of them. Each Dockerfile here and the one of Building the image takes the image it extends as ENGINE_IMAGE: push the first image, then build the next Dockerfile with its ENGINE_IMAGE naming it, rather than pasting one Dockerfile into another (One image with a coding CLI).

The engine’s root filesystem is read-only: a CLI writes under the home the engine gives each seat and under /tmp, both writable. Each running CLI takes 200 to 450 MB, so size the class’s data.resources for cli.maxConcurrent of them per provider. An OpenCode call is at the top of that range: measured on OpenCode 2.0.32, one call held about 440 MB at its peak, some 365 MB of it the private server every call starts, and each call running beside it about 250 to 350 MB more, the binary’s pages being shared between them; a short opencode api session.get reading a call’s usage follows each. OpenCode also unpacks about 19 MB of native libraries into each seat’s home, under the TMPDIR the engine gives it (<home>/tmp), beside a database of about 7 MB while a seat’s calls run: about 26 MB per OpenCode seat on the data volume, and on a seat pod’s scratch volume (scratchSizeLimit) for each seat the pod holds.

Anthropic’s installer runs in a build stage of its own and only the binary it installed is kept:

# The engine image you run, by digest, and the Claude Code release to add.
ARG ENGINE_IMAGE
ARG CLAUDE_CODE_VERSION=2.1.303
# The installer needs curl, which the engine image does not ship, and leaves a
# download cache, a launcher and the build user's ~/.claude.json behind, none
# of which a seat reads: only the binary leaves this stage.
FROM ${ENGINE_IMAGE} AS claude-code
ARG CLAUDE_CODE_VERSION
USER root
RUN apt-get update && apt-get install -y --no-install-recommends curl
USER crewlet
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN curl -fsSL https://claude.ai/install.sh | bash -s "${CLAUDE_CODE_VERSION}"
FROM ${ENGINE_IMAGE}
ARG CLAUDE_CODE_VERSION
COPY --from=claude-code --chown=0:0 \
/home/crewlet/.local/share/claude/versions/${CLAUDE_CODE_VERSION} /usr/local/bin/claude
# The image is how the CLI is updated. The engine starts it with only an
# allowlist of its own environment, which DISABLE_UPDATES is not on, so an ENV
# here would not reach it: the switch goes in the managed settings every
# claude reads.
COPY <<'JSON' /etc/claude-code/managed-settings.json
{"env": {"DISABLE_UPDATES": "1"}}
JSON

The installer checks the binary’s SHA-256 against Anthropic’s release manifest before it installs it, and a CLAUDE_CODE_VERSION that names no release fails the build. Updates are off because, left on, Claude Code would download each new release into each seat’s home on the data volume, about 250 MB a copy that nothing runs: the engine runs the claude on its PATH.

Build it from the engine image,

Terminal window
docker build --build-arg ENGINE_IMAGE=<your registry>/crewlet@sha256:<digest> \
-t <your registry>/crewlet-claude-code:<crewlet commit> .

and name it in the class, which every company of the class then runs:

# EngineClass with-claude-code, excerpt
spec:
engine:
image: <your registry>/crewlet-claude-code@sha256:<digest> # crewlet <commit>

Crewlet drives OpenCode 2 only, npm’s @opencode/cli. npm’s opencode-ai is OpenCode 1.x and rejects the --standalone every command of Crewlet’s profile carries, so on it every call fails. The package’s install script puts the binary of one of its platform packages in place, a self-contained program that needs no Node.js. This Dockerfile takes that binary straight from the platform package and checks it against the integrity npm’s registry records, as the Slack server’s is (Building the image):

# The engine image you run, by digest.
ARG ENGINE_IMAGE
# The platform package is fetched and checked here, with a curl the engine
# image does not ship: only the binary leaves this stage.
FROM ${ENGINE_IMAGE} AS opencode
ARG TARGETARCH
# The OpenCode 2 release, and each platform package's dist.integrity on npm's
# registry. amd64 takes the baseline build, which runs on any x86-64 node: the
# other needs AVX2, and the image runs on whichever node its pod lands on.
ARG OPENCODE_VERSION=2.0.32
ARG OPENCODE_INTEGRITY_AMD64=sha512-NrB4iglJb7vGZWJ6B0qVH1EMHhWBZ4ADsAG9W0xLanzgxJ6eLEEsGBvxRX0cm/KPag5E/N0yZGsDVDZYcnz/OQ==
ARG OPENCODE_INTEGRITY_ARM64=sha512-YR5O03irvmot/G0f8W49ehvhn5uqCFN3vf9JTsChvf47W6ldjKCNvsaCSXS2SP26dPOUMQBn7WqbKnyHzrNR5w==
USER root
RUN apt-get update && apt-get install -y --no-install-recommends curl
SHELL ["/bin/bash", "-euo", "pipefail", "-c"]
WORKDIR /fetch
RUN case "${TARGETARCH}" in \
amd64) package=cli-linux-x64-baseline integrity=${OPENCODE_INTEGRITY_AMD64} ;; \
arm64) package=cli-linux-arm64 integrity=${OPENCODE_INTEGRITY_ARM64} ;; \
*) echo "no OpenCode build for ${TARGETARCH}" >&2; exit 1 ;; \
esac; \
curl -fsSLo opencode.tgz "https://registry.npmjs.org/@opencode/${package}/-/${package}-${OPENCODE_VERSION}.tgz"; \
test "sha512-$(openssl dgst -sha512 -binary opencode.tgz | base64 -w0)" = "${integrity}"; \
tar -xzf opencode.tgz --strip-components=2 package/bin/opencode
FROM ${ENGINE_IMAGE}
COPY --from=opencode --chown=0:0 /fetch/opencode /usr/local/bin/opencode

Pin the newest @opencode/cli of npm’s latest tag, never its beta or dev, within the major Crewlet’s opencode profile is written for (OpenCode 2 today), with the dist.integrity of its cli-linux-x64-baseline and cli-linux-arm64 packages at that version. The obvious alternatives are wrong:

  • OpenCode’s own installer, curl https://opencode.ai/v2/install, fetches the same package with no checksum, and picks the baseline or musl build from the CPU and libc of the machine running the build, not of the node the pod runs on.
  • No update switch is needed, unlike Claude Code’s. Every call the engine makes carries update: disable in the configuration it writes for that call, and the commands it runs outside a call (auth import, auth export, auth list, --version) check for no release. OpenCode’s updater replaces only what a package manager (npm, pnpm, Bun, Yarn, Vite+, Homebrew or WinGet) or its own installer put in place, so it cannot touch /usr/local/bin/opencode, and the root filesystem is read-only anyway.
  • Never add an /etc/opencode configuration to the image: OpenCode reads it as machine-wide managed configuration over every seat’s call, and nothing the engine sets overrides it (Tool calls in text mode).
  • The binary links glibc, which the engine image’s Debian has; the musl packages are for Alpine. Of the native libraries it unpacks, only its file watcher’s needs more, libstdc++ and libgcc_s, which Debian has too. The engine switches the watcher off on every command it runs (OPENCODE_FILEWATCHER_DISABLE), and the check below loads it anyway, since cli.env can switch it back on.

Build it from the engine image, or from another image of One image with a coding CLI,

Terminal window
docker build --build-arg ENGINE_IMAGE=<your registry>/crewlet@sha256:<digest> \
-t <your registry>/crewlet-opencode:<crewlet commit> .

check that OpenCode runs in it as the engine’s pod runs it, as uid 10001 on a read-only root, with an empty HOME and /tmp that, like the pod’s, allow running what is written there, no capabilities and no network,

Terminal window
image=<your registry>/crewlet-opencode:<crewlet commit>
docker run --rm --read-only --network none --user 10001:10001 --cap-drop ALL \
--tmpfs /tmp:exec --tmpfs /home/crewlet:exec,uid=10001,gid=10001 -e HOME=/home/crewlet \
--entrypoint /usr/local/bin/opencode "$image" auth list --standalone --print-logs 2>&1 |
grep -E 'level=ERROR|No authenticated integrations'

which passes when it prints one line, No authenticated integrations. It starts the private server on loopback that every call the engine makes starts, and loads every native library the binary unpacks, where --version alone loads one. A library that does not load is no failure to OpenCode: it carries on without it, prints the same answer, exits 0, and says so only in its log, as level=ERROR with watcher backend not supported, which is why the check reads the log. That is what a missing libstdc++ looks like, and what a plain --tmpfs does, which mounts noexec: the pod’s /tmp and home are emptyDir volumes, which allow exec, and each seat’s TMPDIR is on the data volume, which ordinarily does too. Run the check on a machine of the platform the image is for: under emulation, as docker buildx runs another platform’s image, OpenCode misses its own five-second deadline for activating its plugins and fails with Location plugins did not finish activating. The binary adds about 210 MB to the image. Then name it in the class:

# EngineClass with-opencode, excerpt
spec:
engine:
image: <your registry>/crewlet-opencode@sha256:<digest> # crewlet <commit>

A subscription login is made at a terminal, by crewlet llm login or the CLI’s own command, and a pod has none. cli.auth therefore takes the login from a Secret, in one of two forms; takes a metered key instead; or, for the three CLIs that read their model provider’s key from their environment, takes none and signs in with that key in cli.env:

Sign-inWhat the Secret holdsUse it when
auth.tokenSecretRefa long-lived headless token the CLI mints, such as the CLAUDE_CODE_OAUTH_TOKEN of claude setup-tokenthe CLI is Claude Code, the one CLI whose Crewlet profile names a token variable: it never needs refreshing, and it is the only one of Claude Code’s subscription logins an agent-mode run can carry (an agent-mode OpenCode carries its model provider’s key in env instead)
auth.credentialBundleSecretRefa bundle of the CLI’s login (its credential files, or for OpenCode the document opencode auth export --standalone prints), from crewlet llm export <key> on a machine where you logged inany CLI: the others mint no token Crewlet hands on
auth.mode: APIKey with one apiKeySecretRefs keythe CLI’s metered API keythe CLI’s Crewlet profile names a key variable (below)
env, with auth.mode: Subscriptionthe key of the model provider the CLI runs on, in the variable the CLI reads for it (OPENROUTER_API_KEY, ANTHROPIC_API_KEY, NOUS_API_KEY, …)the CLI is Hermes, Pi or OpenCode: with its model provider’s key in its environment, it needs no login

What each CLI takes, in the engine the operator is tested against, Crewlet’s main (Crewlet’s profiles name the variables; a test holds the API’s rules and this table to that engine’s own verdict on every CLI and every sign-in, so neither can fall behind it):

cli.agenttokenSecretRefauth.mode: APIKey fillsA key in envcredentialBundleSecretRefmode: Agent
ClaudeCodeCLAUDE_CODE_OAUTH_TOKENANTHROPIC_API_KEYnoyeson the token or the key
CodexnoOPENAI_API_KEYnoyesno
GeminiCLInoGEMINI_API_KEYnoyesno
QwenCodenoDASHSCOPE_API_KEYnoyesno
CursorAgentnoCURSOR_API_KEY, a Cursor account’s API keynoyesno
CopilotnoGITHUB_TOKENnoyesno
GroknoXAI_API_KEYnoyesno
MuseCodenoMETA_API_KEYnoyesno
KimiCodenono: it reads a key only from its own config.toml, so give that in the bundlenoyesno
OpenCodenonoyesyeson the key in env (below)
Hermesnonoyesyesno
Pinonoyesyesno

A provider names one of the two logins: given both, which login the CLI acts on would be its own choice between its environment and its files, so the API server refuses the pair. Crewlet hands a token to a CLI only through the variable its profile names, and the engine refuses a token for a CLI whose profile names none, since it would reach nothing, so the API server refuses tokenSecretRef on any agent but ClaudeCode first. A Subscription provider that names neither is refused, with one exception:

  • Hermes, Pi and OpenCode on a key in env. Each fronts many model providers and reads each provider’s key from that provider’s own variable, so Crewlet’s profile for it names no key variable and marks it as signing in through its environment: Crewlet passes the CLI’s env to it untouched, which is the route its profile documents for a metered key. Crewlet counts an entry as a sign-in only by its name (below).
  • Every other CLI keeps to a token or a bundle. A CLI whose profile names a key variable takes its key with auth.mode: APIKey; the engine refuses that variable in env (below), and a key in env under any other name is one the CLI does not read.

env may never set the CLI’s own token or key variable, the ones the table names (ANTHROPIC_API_KEY for ClaudeCode, CURSOR_API_KEY for CursorAgent, and so on), in any auth mode: auth owns them, and whether a value in env reached the CLI would turn on the mode and on what else holds a credential, so the engine refuses the document and the API server refuses the provider first. Another CLI’s variable is no such conflict: ANTHROPIC_API_KEY is OpenCode’s Anthropic key.

Nor may env set a variable the engine sets on every call to keep the CLI inside its seat’s own home and the call’s working directory: the home, the XDG directories and the temporary directory of every CLI, and the CLI’s own configuration directory, or, for OpenCode, its configuration directory and OPENCODE_CONFIG_PROJECT_DISABLE, the switch that keeps OpenCode from reading project configuration from the directories above a seat’s call. env is layered over them, so a value there would replace that isolation for every seat on the provider: the engine refuses the document, and the API server refuses the provider first. Another CLI’s variable is no conflict here either: CODEX_HOME means nothing to OpenCode. A test holds the API’s rule, its message and this table to the engine’s lists:

env may not setFor
HOME, XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_STATE_HOME, XDG_CACHE_HOME, TMPDIRevery CLI
CLAUDE_CONFIG_DIRClaudeCode
CODEX_HOMECodex
OPENCODE_CONFIG_DIR, OPENCODE_CONFIG_PROJECT_DISABLEOpenCode
CURSOR_CONFIG_DIRCursorAgent
KIMI_CODE_HOMEKimiCode
HERMES_HOMEHermes
PI_CODING_AGENT_DIRPi
apiVersion: crewlet.ai/v1alpha1
kind: LLMProvider
metadata:
name: hermes
spec:
companyRef: {name: acme}
type: CLIAgent
model: anthropic/claude-sonnet-5 # an OpenRouter model id: OPENROUTER_API_KEY selects OpenRouter
cli:
agent: Hermes
auth:
mode: Subscription
env:
- name: OPENROUTER_API_KEY
valueFrom:
secretKeyRef: {name: openrouter, key: apiKey}

OpenCode reads its model as <provider>/<model>, splitting it at the first slash: anthropic/claude-sonnet-5 runs Anthropic’s model on ANTHROPIC_API_KEY, and openrouter/anthropic/claude-sonnet-5 runs it through OpenRouter on OPENROUTER_API_KEY (opencode models --standalone lists the providers it knows). A model with no slash, or with nothing but white space before or after it, names no provider or no model, and every call the provider made would fail inside OpenCode, which OpenCode 2 refuses with Invalid model reference, so the engine refuses it and the API server refuses it first, at spec.model.

For Claude Code, on a headless token:

Terminal window
claude setup-token # on your machine: prints the token
kubectl create secret generic claude-subscription -n acme --from-literal=oauthToken=...
apiVersion: crewlet.ai/v1alpha1
kind: LLMProvider
metadata:
name: claude-code
spec:
companyRef: {name: acme}
type: CLIAgent
model: sonnet
cli:
agent: ClaudeCode
auth:
tokenSecretRef: {name: claude-subscription, key: oauthToken}

The login, and the homes the CLI keeps for each seat, live on the data volume, under /var/lib/crewlet/llm-cli/<loginName>, so a restarted data pod keeps them; a seat pod keeps them on a scratch volume it loses at every start (see Seat pools). That matters for a bundle: the engine restores one, when it starts and at every revision it applies, only while the login there is empty, so the login the running engine refreshed wins over the bundle in the Secret, whose refresh token the vendor may have retired since, and a new bundle in the Secret reaches nothing until the provider moves to another loginName (Rotating a credential). Nor does it restore one into a login crewlet llm logout emptied (A seat on a coding CLI runs signed out). A bundle restored over a newer login is how a vendor that rotates refresh tokens logs a deployment out, which is why a token is the better of the two wherever the CLI mints one.

Providers that set the same loginName share one login and its seats’ homes, which is how several models run off one subscription; they must drive the same CLI, or the engine refuses the document.

OpenCode 2 keeps its logins in a database of its own rather than in files, so an OpenCode bundle holds one file: the document opencode auth export --standalone printed. The engine keeps it as the login of record (credentials/exported-credentials under /var/lib/crewlet/llm-cli/<loginName>), imports it into an empty database in a seat’s home before the seat’s calls, and exports it back when OpenCode refreshed it (Token refresh across seats).

Make it on a machine with OpenCode 2, npm’s @opencode/cli: crewlet llm login <key> runs OpenCode’s own opencode auth login --standalone in a login home of the entry’s own, or sign in with OpenCode yourself and adopt that login with crewlet llm login <key> -from-host, which reads it through your OpenCode’s export (Broker the vendor’s own login); then crewlet llm export <key>. The export prints every login the OpenCode it runs in holds, keys stored with opencode auth login included, so a bundle adopted from your own home carries every provider you signed in to there: adopt from a home holding only the login the provider should run on (-from-host -home <dir>), or sign in with crewlet llm login, whose login home holds the entry’s login alone.

A login OpenCode 1.x made (~/.local/share/opencode/auth.json) is not read: a bundle holding one is refused when the engine restores it, which it logs as cli_agent_bundle_restore_failed, and the provider runs signed out, so export it again from OpenCode 2. A bundle serves mode Text only: an agent-mode run never receives it (Text mode and agent mode).

This is the sign-in of a Hermes, Pi or OpenCode provider in auth.mode: Subscription with neither a token nor a bundle: a key in its env. Crewlet counts an entry as that sign-in only when its name says it holds a credential: one of its parts between underscores is, in any case, KEY, KEYS, APIKEY, ACCESSKEY, SECRETKEY, TOKEN, SECRET, PASSWORD, PASSWD, CREDENTIAL, CREDENTIALS, AUTH, OAUTH or PAT. So OPENROUTER_API_KEY and GH_TOKEN sign the CLI in, and HTTPS_PROXY or HERMES_MAX_TOKENS (a count of model tokens) do not; the API server refuses such a provider when its env holds no such name, since crewlet llm list would report it signed in to nothing. Write its auth.mode: Subscription explicitly, as auth may not be empty. A key in env is a ${NAME} the engine resolves from its encrypted store, so it never refreshes and runs on a seat pod too.

Name only the sign-in you mean the CLI to use. Beside a credential bundle a key in env is a second sign-in, which the CLI settles by its own rules rather than by the order you wrote them in. Hermes, for one, loads its home’s .env over its environment, so a variable the bundle’s .env also sets takes the file’s value, while a model provider’s key in env wins over an OAuth login in the bundle’s auth.json, so Hermes runs on the metered key and the subscription goes unused.

auth.mode: APIKey gives the CLI the provider’s one apiKeySecretRefs key instead, billing the metered account. Crewlet hands the key over in the variable the CLI’s profile names, and in Crewlet’s main eight name one (the table above); for the others the key would reach nothing, so the engine refuses the mode for them and the API server refuses it first. Hermes, Pi and OpenCode take their model provider’s key in env instead, and KimiCode its key in the bundle. CursorAgent’s key is a Cursor account’s API key, from the Cursor dashboard, not a model provider’s.

apiVersion: crewlet.ai/v1alpha1
kind: LLMProvider
metadata:
name: cursor
spec:
companyRef: {name: acme}
type: CLIAgent
model: auto
apiKeySecretRefs:
- {name: cursor, key: apiKey}
cli:
agent: CursorAgent
auth:
mode: APIKey

cli.mode: Text (the default) runs the CLI as a model behind the engine’s own tool loop, as a process in the engine’s pod. cli.mode: Agent runs a seat’s executor as the CLI’s own agentic run, with its own shell and editor, in the company’s sandbox. Every other phase on the same provider (the reviewer, a delegated worker, the summariser) is still a text call, so the CLI must be in the engine’s image either way.

prompt and answer

starts a detached run

the seat's tools, with a tokensigned for the run

Enginethe seat's turn

CLI, text modea model behind theengine's tool loop

:8081 public listener/mcp/ tool bridge

CLI, agent modeits own shell, editorand agentic loop

The company's E2B sandbox

The data pod

In agent mode:

  • The run is a sandbox run: the Company needs spec.sandbox. With no spec.sandbox.e2b.template, each run gets E2B’s prebuilt template for its CLI (claude for Claude Code, opencode for OpenCode); a template you set is used for every run, so it must carry the CLI of every coding agent and agent-mode provider the company runs. For OpenCode that is OpenCode 2, the @opencode/cli package (opencode --version prints opencode v2.…): Crewlet’s runner ends a run at once in a box that has OpenCode 1.x, npm’s opencode-ai, with this box runs OpenCode 1.…; the opencode runner drives OpenCode 2 (the @opencode/cli package) in the run’s report. E2B does not say which OpenCode its prebuilt opencode template carries, so where runs end that way, set spec.sandbox.e2b.template to a template built with @opencode/cli, which, used for every run, must also carry Claude Code if any seat’s code work runs it (Crewlet’s OpenCode runner). cli.runIn is E2B, the only cell v1alpha1 offers: Crewlet’s local cells would run every seat’s code in the engine’s pod, with its credentials.
  • Claude Code and OpenCode run in agent mode: they are the CLIs the engine has a sandbox runner for, the coding agents spec.sandbox offers, and the API server refuses mode: Agent for any other. The run is handed model exactly as the provider’s text calls pass it, so one model serves the executor’s run and every other phase.
  • The login must follow the run into the remote sandbox, which only a variable of the run’s environment can: an agent-mode Claude Code takes auth.tokenSecretRef or auth.mode: APIKey, and an agent-mode OpenCode its model provider’s key in env, under a name the engine reads as a credential, which the engine exports into the box. A credential bundle stays on the data volume, since its login carries a refresh token the engine keeps rotating there, so the API server refuses an agent-mode provider signed in with nothing else. A key in the Seat’s spec.sandbox.env reaches the box as well, but the API server cannot see the Seat from the provider, so it does not count it.
  • The run calls the seat’s own tools back through the engine’s tool bridge. While any LLMProvider of the company is in agent mode, the operator gives the engine its public address as the bridge’s (CREWLET_MCP_BRIDGE_URL), and the engine serves the bridge on its public listener, beside the webhooks (Exposure). A company with no public address cannot run one: the engine applies the provider and refuses every turn of a seat whose executor’s chain it heads. The operator applies it too and reports it, so the rest of the company still runs: the LLMProvider reports AgentModeRunnable=False, NotExposed, and each such Seat Ready=False, NotExposed. With an address, AgentModeRunnable is True, ToolBridgeServed, naming the bridge’s address.
spec: # LLMProvider claude-agent
type: CLIAgent
model: sonnet
cli:
agent: ClaudeCode
mode: Agent
loginName: claude-code # the same login as the text-mode provider
auth:
tokenSecretRef: {name: claude-subscription, key: oauthToken}

or OpenCode, on its model provider’s key:

spec: # LLMProvider opencode-agent
type: CLIAgent
model: openrouter/anthropic/claude-sonnet-5
cli:
agent: OpenCode
mode: Agent
auth:
mode: Subscription
env:
- name: OPENROUTER_API_KEY
valueFrom:
secretKeyRef: {name: openrouter, key: apiKey}

A seat runs on it by naming it in spec.llm.default; see the Seat’s spec.llm in the API reference.

A seat with spec.sandbox runs code in the company’s sandbox with a coding agent, ClaudeCode or OpenCode (spec.sandbox.codingAgent, else the Company’s spec.sandbox.defaultCodingAgent, else ClaudeCode), on the provider at the head of spec.llm.sandbox, else of spec.llm.default, else of the Company’s order. That provider is the run’s model and sign-in, unless the seat’s sandbox environment signs the coding agent in (below), so the pairing has to be one the coding agent can read. The Seat reports whether it is on its CodeWorkRunnable condition, saying what the run uses, and naming the field to change when it cannot run. Only the first pairing below also holds the company’s document (Applied=False), because Crewlet refuses it on every write; Crewlet applies a seat in either of the other two, which runs its turns while Crewlet refuses each of its coding runs, so the operator applies it too and the company is not held over one seat’s code work. CodeWorkRunnable does not count towards the Seat’s Ready, and the operator records a Warning event on the Seat each time the condition becomes False or its reason changes.

  • A CLIAgent provider must drive the coding agent’s own CLI. Its model is written for its CLI and its sign-in is that CLI’s, so Crewlet refuses the pairing on every write (CodingAgentMismatch). Codex, Gemini CLI, Kimi Code, Hermes, Pi and the other CLIs have no coding agent, so they cannot back code work: put an API provider first in spec.llm.sandbox.
  • ClaudeCode reads Anthropic’s API alone. On a provider of type OpenAI or OpenAICompatible it can read neither the model nor the key, and Crewlet refuses each coding run (SandboxModelUnsupported), unless the seat’s sandbox environment (spec.sandbox.env, its setup steps’ env, or the Company’s spec.sandbox.setup steps’) signs Claude Code in itself, with CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX or CLAUDE_CODE_USE_FOUNDRY; it then runs on that sign-in and its own model choice (ANTHROPIC_MODEL in spec.sandbox.env picks one; like every variable there, it is read from a Secret key). OpenCode reads either wire. An OpenCode coding run needs OpenCode 2 in the box, as an agent-mode run does (Text mode and agent mode).
  • The login must reach the remote sandbox. A credential bundle never does, so a ClaudeCode provider signed in by a bundle, with neither CLAUDE_CODE_OAUTH_TOKEN nor ANTHROPIC_API_KEY in the seat’s sandbox environment, or an OpenCode one with no credential-named variable in cli.env or the seat’s sandbox environment, has each coding run refused (SandboxLoginNotPortable).

A seat whose executor runs in agent mode has no code work of its own: Crewlet never offers it run_sandbox, since it codes in its executor’s run, on that provider’s CLI, so none of the pairings above applies, its spec.llm.sandbox is not read for one, and it has no CodeWorkRunnable. Its spec.sandbox.codingAgent drives nothing: leave it out. Crewlet refuses one that names another CLI than the executor’s on every write, and the operator refuses it too (CodingAgentMismatch on Applied, at spec.sandbox.codingAgent).

Crewlet starts a run_sandbox coding run only once every value of its environment and files holds something: a value read from a Secret that does not exist, holds no such key or holds an empty value has every run refused before a box exists. A value the run can do without is listed by its key: in spec.sandbox.optionalKeys, a variable of spec.sandbox.env; in a setup step’s optionalKeys, the Company’s or the Seat’s, a variable of the step’s env or a path of its files.

spec: # Company nimbus
sandbox:
setup:
- name: npm-registry
files:
- path: .npmrc
valueFrom:
secretKeyRef: {name: nimbus-npm, key: npmrc}
optionalKeys: [.npmrc]

Under a listed key, such a value is left out: the variable is not set, or the file not written, and the run starts without it. A missing Secret or key there holds nothing back, and the object that gives the value names it in its ResolvedRefs=True message, with the list that lets it go: the Seat for its own spec.sandbox and steps, and the Company, which has no ResolvedRefs, in its SecretsSynced message for its own steps. Once the Secret is back, the operator pushes its value and applies the document again. When it is deleted, the operator, under the Enforce policy, takes the value out of the engine’s secret store and has the engine read the store again, by applying the next document or, while that one is held, by activating the revision the engine runs again, so no run starts with a deleted Secret’s value, but in a revision the engine runs that reads the key as required, as one from before the key was listed does, or whose reading the operator does not know, which keeps the value until the next document runs, the new revision activating once with it and then again without it (Applying a document); a Secret key also read where it is required holds the document instead, and keeps its value in a revision the engine runs that requires it too until the next one applies. The layers of a run’s environment are laid in order, the Company’s steps, then the Seat’s, then spec.sandbox.env, a later one winning a variable, and the list beside the value that wins is the one that counts for it; a step’s files are its own. A provisioned value an Integration mints for the seat is never left out, whatever the list says: until it is minted, each run is refused, the seat told to try again. One nothing mints, while no Integration of the company has provisioning: Engine, is left out. An agent-mode executor’s run starts without a value that holds nothing, listed or not (Crewlet’s Credentials and the run environment).

The API refuses an entry naming no variable or path of its own block. A Secret key that holds an empty value under a key nothing lists does not hold the document, since Crewlet applies the seat and refuses each run: the object that gives the value reports ResolvedRefs=False SecretValueEmpty, with a Warning event, or the Company’s SecretsSynced for its own steps, and the Seat’s CodeWorkRunnable=False SecretValueEmpty, as does a provisioned value nothing mints (NoProvisioner). Either is reported only where it is the value a run is given: a step’s variable the Seat’s own env replaces is none.

The verdict above reads the variables that sign the coding agent in once the reconcile has read their Secrets: one a run goes without, listed and missing or empty, signs nothing in, and the box is given the provider’s key and endpoint as it is with no sign-in. What the box receives from the provider is in What a coding box carries.

A seat placed in a seat pool runs on a seat pod, which keeps nothing between starts and serves only its probes. Two things a data pod does for a CLI it cannot do:

  • Agent mode. A run calls the seat’s tools back through the tool bridge of the very pod that holds the seat, on an address the sandbox reaches; the operator gives the data pod the company’s public address for it. The engine would serve the bridge on a seat pod too, on the listener its probes use, but nothing publishes an address of a seat pod’s own that a remote sandbox could dial: the company’s public address reaches the data pod alone, and a seat pod is reached by its kubelet and nothing else. Without an address the engine refuses every agent-mode turn on the pod. Crewlet runs a turn in agent mode only when the HEAD of the executor’s chain is in mode: Agent, so a seat in a seat pool whose executor’s chain (spec.llm.default, or the Company’s first provider when the seat names none) starts with such a provider can run no turn. The engine applies it all the same, and so does the operator, which reports it on the Seat as Ready=False, AgentModeBridgeUnsupported, with a Warning event, rather than hold the rest of the company’s changes: place the seat in data, or put a provider in mode: Text at the head of the chain. The same provider behind another in that chain, or in another phase’s chain, is a text call, and is fine.
  • A credential bundle. The login lives under the CLI’s state directory, which on a seat pod is its scratch volume, so every seat pod restores the bundle at each start and then refreshes the login on its own, beside the data pod. A vendor that rotates refresh tokens retires the token the other pods hold at each refresh, and logs them out. A seat in a seat pool that runs on a provider with credentialBundleSecretRef, in any phase, is refused with Applied=False, CLIAuthNotPortable, which holds the company’s document: applied, it would log out every seat on that login, the data pod’s included. Sign it in with tokenSecretRef (Claude Code), auth.mode: APIKey (a CLI the table above gives a key variable) or, for Hermes, Pi or OpenCode, its model provider’s key in env, or place the seat in data.

Crewlet’s custom CLI and the profile overrides of the built-in ones are not offered: they name the binary and the arguments the engine’s pod runs. Nor are the direct and container cells, for the reason above.

Nor is Crewlet’s inherit-env login, which gives the CLI whichever token or key the engine’s container environment holds. A credential there is outside the engine’s encrypted store: every process the engine runs (the CLI, a stdio MCP server) can read it, and the engine resolves a ${NAME} written in many fields of the company document from its environment too, so whoever can write such a field can have the engine send the value anywhere. The operator puts no credential of the engine’s own into its environment for the same reason (Credential Positions); tokenSecretRef, credentialBundleSecretRef, auth.mode: APIKey and a key in env for Hermes, Pi or OpenCode take the login from a Secret instead.