CrowdControl.Agent adapter for Oh My Pi (omp).
Launches omp --mode rpc, omp's newline-delimited JSON-RPC protocol over
stdio, and normalizes its frames into CrowdControl.Protocol.message/0 so
subscribers written against Claude Code work unchanged.
Wire mapping
| omp frame | CrowdControl message |
|---|---|
get_state response (sent once at startup) | {:system_init, map} with "session_id" |
message_end (assistant) | {:assistant, map} |
message_end (user) | {:user, map} |
message_update | {:stream_event, map} |
agent_end with isTerminal != false | {:result, "success", map} |
agent_end with isTerminal == false | {:stream_event, map} |
prompt response or prompt_result with agentInvoked: false | {:result, "success", map} with "local_only" => true |
failed prompt response | {:result, "error_prompt_failed", map} |
| anything else | {:unknown, map} |
omp acknowledges a prompt command before the turn runs, so the completion
signal is agent_end — and only when isTerminal is not false, since a
false means maintenance or async delivery will resume the session. An
agent_end is turned into a Claude-shaped result map: "result" is the final
assistant text, "total_cost_usd" the summed cost of the turn's assistant
messages, plus "usage", "num_turns", "duration_ms" and "stop_reason".
A prompt that omp resolves locally — a slash command such as /tools — never
produces an agent_end. It completes with agentInvoked: false, which maps
to a result carrying "local_only" => true and an empty "result": the
command's own text arrives separately as command_output frames, and
decode_line/1 is stateless. Without this a local-only prompt would hang a
collector until its deadline.
The adapter stays on protocol v1 (no negotiate_protocol), so an oversized
logical frame is truncated by omp rather than chunked; :max_line_bytes on
the session still bounds what a single line may cost.
Credentials
Everything below travels through the session's validated environment — a
0600 env file locally, the exec Env array remotely. Never argv, never ps.
| You have | Pass | Sets |
|---|---|---|
| An Anthropic API key | :api_key | ANTHROPIC_API_KEY |
| A Claude subscription, headless | :oauth_token | ANTHROPIC_OAUTH_TOKEN, which omp resolves ahead of the API key |
| A Claude subscription already logged in on this host | nothing — it just works | omp reads ~/.omp/agent/agent.db |
| A self-hosted endpoint (vLLM, SGLang, LiteLLM) | custom_provider: [base_url: …, api_key: …] | a generated models.yml plus one env var |
| Any other provider (OpenAI, Groq, …) | env: %{"OPENAI_API_KEY" => …} | that variable verbatim |
:api_key and :api_url are Anthropic-specific shorthands inherited from
CrowdControl.CLI; for any other hosted provider use :env with the variable
omp documents for it.
Subscription passthrough
A session with no :custom_provider uses omp's real agent directory, so a
/login you have already done applies with no configuration at all — that is
the easiest way to bill sessions to a Claude subscription.
Two cases need help:
- No stored login (CI, a container, another user): mint a token with
ompand pass it as:oauth_token. :custom_provideris in play: it relocates the agent directory (see below), which leaves the stored login behind. Addinherit_auth: trueto the spec to link the real store back in, and the session can reach both its custom endpoint and Anthropic.
inherit_auth is opt-in rather than the default on purpose: it symlinks your
OAuth store into a directory a session's own bash tool can read, while that
session is talking to a third-party endpoint. Turn it on for endpoints you
trust; leave it off for ones you do not.
Options
Shared with CrowdControl.CLI (Claude Code): :executable (default "omp"),
:model, :system_prompt, :allowed_tools, :permission_mode, :resume,
:continue, :add_dir, :no_session_persistence, :extra_args, :env,
:api_key, :api_url.
:permission_mode is translated to omp's approval modes:
"bypassPermissions" => yolo, "acceptEdits" => write, "default" =>
always-ask. "plan" has no approval-mode equivalent and raises; use
extra_args: ["--plan-yolo"] if that is what you want.
omp-native options:
:approval_mode-"always-ask","write"or"yolo"; wins over:permission_mode:auto_approve-trueto auto-approve every tool call:append_system_prompt- text (or file path) appended to the system prompt:thinking-"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "auto":provider,:service_tier,:profile,:max_time:cwd,:session_dir- paths (sanitized + expanded):config,:hooks,:extensions- path or list of paths, one flag each:models,:skills- lists joined with,:no_tools,:no_lsp,:no_pty,:no_extensions,:no_skills,:no_rules,:no_title,:advisor,:allow_home,:hide_thinking- booleans:streaming_behavior-"followUp"(default) or"steer"; how a prompt sent mid-turn is queued:agent_dir- path forPI_CODING_AGENT_DIR, the directory omp readsmodels.ymlandconfig.ymlfrom (sanitized + expanded):custom_provider- declarative OpenAI-compatible endpoint; see below:sandbox_agent_dir- ship:custom_provider's rendered files into a remote sandbox instead of rendering them on the host;truefor the default in-sandbox directory, or an absolute in-sandbox path. NeedsCrowdControl.Backend.Sandboxd; see below:oauth_token- Claude subscription token; setsANTHROPIC_OAUTH_TOKEN
Claude-Code-only options (:mcp_config, :strict_mcp_config, :agents,
:plugin_dir, :settings, :settings_file, :settings_json,
:setting_sources, :max_budget_usd, :session_id, :bare) have no omp
equivalent and raise ArgumentError rather than being dropped silently.
:include_partial_messages is accepted and ignored: RPC mode always streams
deltas as message_update frames.
Custom providers (vLLM, LiteLLM, any OpenAI-compatible endpoint)
omp resolves a provider's baseUrl from models.yml under its agent
directory — there is no CLI flag for it. :custom_provider renders that file
into a private 0700 temp directory and points PI_CODING_AGENT_DIR at it:
CrowdControl.run("Explain this repo",
agent: :omp,
custom_provider: [base_url: "http://10.0.0.5:8000/v1"],
model: "vllm/Qwen/Qwen3-Coder-30B"
)Spec keys:
:base_url- required, the OpenAI-compatible endpoint:id- provider id, default"vllm". omp has a built-invllmprovider that readsmax_model_lenfrom/v1/models; any other id is a plain custom provider. The id is theprovider/prefix in:model.:api- default"openai-completions"; use"openai-responses"for a server exposing/v1/responses, or"anthropic-messages":api_key- provider credential. Never written tomodels.yml: it is passed through the same validated environment channel as:api_key(a0600env file locally, the execEnvarray remotely), and the config references it by variable name. Omit it for an unauthenticated server, which rendersauth: none.:api_key_env- name of that variable, default"OMP_CUSTOM_PROVIDER_KEY":models- explicit model list, each[id: ..., name: ..., context_window: ..., max_tokens: ..., reasoning: true | false, input: ["text", "image"]]. Omit to discover models from the server's/v1/modelsinstead.:headers- extra request headers as a string-keyed map:inherit_auth-trueto link omp's stored-login database (~/.omp/agent/agent.db) into the generated directory, so the session keeps the logins omp already has despite the relocation. Accepts a path to a specific agent directory instead oftrue. Off by default — see "Subscription passthrough" above for the trade-off.
The directory is content-addressed, so every session in a fan-out sharing one
spec shares one directory rather than writing N copies. Build it yourself with
provider_dir!/1 and pass :agent_dir when you want to own the lifecycle
(and remove_provider_dir/1 to delete it).
Custom providers on a remote sandbox
A host temp directory is not visible inside a sandbox, so a rendered
models.yml has to get there some other way. Which backends solve that and
which still leave it to you:
CrowdControl.Backend.Sandboxd— solved. Passsandbox_agent_dir: trueand the rendered file is written into the sandbox at/tmp/cc-omp-agent/models.yml, mode0600, over the agent'sPUT /v1/files— after the sandbox exists and before the CLI starts — withPI_CODING_AGENT_DIRpointing there. Pass an absolute in-sandbox path instead oftrueto choose the directory. Nothing at all is rendered on the host on this path.CrowdControl.Backend.DockerandCrowdControl.Backend.Kubernetes— still yours. Neither has a file-transfer channel, so the directory has to be baked into the image or mounted in (a bind mount, aConfigMap); render it withprovider_dir!/1and pass:agent_dirnaming the path it has inside the container, not on the host.CrowdControl.Backend.Local— never a problem, since the host is the sandbox and:custom_provideralone already works.
:inherit_auth is refused together with :sandbox_agent_dir. omp's auth
store is a host file it refreshes in place, so a symlink means nothing across
a substrate boundary and a copy would strand refreshed tokens in the sandbox
— while handing a live subscription credential to the untrusted code the
sandbox exists to contain. Use :oauth_token to bill a subscription from a
sandboxed session.
PI_CODING_AGENT_DIR relocates more than models.yml
It moves the whole ~/.omp/agent base for that session: config.yml, the
auth store (agent.db), and saved sessions. So a :custom_provider session
starts with none of your global omp settings and none of your stored
logins — usually what you want for an isolated endpoint. Add
inherit_auth: true when you want the logins back, and :config to point
at a settings overlay. ~/.omp itself (skills, plugins) is unaffected.
Summary
Functions
Renders a models.yml for a custom provider into a private temp directory
and returns its path, for use as :agent_dir.
Removes a directory created by provider_dir!/1.
Renders the models.yml body for a custom-provider spec.
Functions
Renders a models.yml for a custom provider into a private temp directory
and returns its path, for use as :agent_dir.
The directory is 0700, the file 0600, and the name is derived from a
per-VM random salt plus a digest of the rendered config — so one spec maps to
one directory no matter how many sessions share it, and the path is not
guessable by another local user. Writing is idempotent.
The spec never carries a secret to disk: :api_key is referenced by
environment-variable name (see :api_key_env), and the value itself travels
through the session's normal environment channel.
Delete it with remove_provider_dir/1 when the last session using it is done;
it is a few hundred bytes, so leaving it until the OS clears the temp
directory is also fine.
dir = CrowdControl.Agent.Omp.provider_dir!(base_url: "http://10.0.0.5:8000/v1")
CrowdControl.run("hi", agent: :omp, agent_dir: dir, model: "vllm/my-model")inherit_auth: true additionally links omp's stored-login database into the
directory, so the session keeps the logins omp already has — see the
moduledoc for why that is opt-in.
@spec remove_provider_dir(String.t()) :: :ok | {:error, :not_a_provider_dir}
Removes a directory created by provider_dir!/1.
Refuses anything that is not demonstrably one of ours, so a caller cannot turn
a stray option value into a recursive delete: the path must sit directly in
the temp directory, carry the cc_omp_ prefix, and — checked without
following symlinks — actually be a directory.
Renders the models.yml body for a custom-provider spec.
Emitted as JSON, which every YAML parser accepts: it keeps quoting and
escaping in JSON.encode!/1 rather than in a hand-rolled emitter.