Wrap an agent
What wrapping means, how to wrap Claude Code, Codex, Cursor, and Stella, how to set it up from a coding agent, how to run two agents on one machine, and how to wrap a custom agent with oxagen agent run or oxagen hook.
Wrapping an agent puts its runs under a mandate: the access it used, the budget it spent, and a record of every step, all attributed to the workspace it ran for. For the capabilities it hands the agent, Oxagen checks the access and budget clauses on the server. The wrapper this page covers is how a run's steps become that record.
Hook-based records are vouched for only by the machine that sends them. The process owner can remove the hooks, so the hook tier does not enforce against that person. Observe mode records what the policy would decide on a governed call and lets the call go ahead. Operator controls, and the budget and model limits on model calls routed through Oxagen, still apply.
The collector also has a loopback model proxy and an MCP gateway. Their controls apply to calls actually routed through them. A configured base URL alone does not establish that a run reached the gateway tier. Inspect the run's recorded tier and attribution before treating its cost or decisions as gateway-observed evidence.
The MCP gateway serves Oxagen's own tools to Claude Desktop, Claude Code, Codex, Cursor and Stella. Enrolling a harness adds the gateway to its MCP servers under the name oxagen, in the file the table below names. Oxagen's steering reads answer only through that entry. Each harness calls every other MCP server you configure directly, and its hooks check each call. A hook also sees each call through the gateway, and the record keeps the call once, on the run.
Supported harness adapters include Claude Code, Codex, Cursor, and Stella. A custom agent runs under oxagen agent run, or calls oxagen hook around its own steps. The table below describes all four hook adapters.
Install the Oxagen app
Install the app on the machine the agent runs on before you enroll it. The app links the oxagen command onto your PATH on first launch, so every command on this page runs from a new terminal.
The newest Oxagen app for each platform
Every version, with SHA-256 checksumsmacOS 12 or newer. Open the .dmg and drag Oxagen to Applications. Builds are not yet notarized, so macOS refuses the first launch. Choose Done, then click Open Anyway in System Settings > Privacy & Security.
- Apple silicon (.dmg)Oxagen_aarch64.dmg
- Intel (.dmg)Oxagen_x64.dmg
Windows 10 or newer, x64.
- Installer (.exe)Oxagen_x64-setup.exe
- Installer (.msi)Oxagen_x64_en-US.msi
x86_64. Install the package for your distribution.
- .deb (Debian, Ubuntu)Oxagen_amd64.deb
- .rpm (Fedora, RHEL)Oxagen.x86_64.rpm
- AppImage (any distribution)Oxagen_amd64.AppImage
On first launch the app links the oxagen and recorder commands onto your PATH, so a new terminal can run them.
On macOS, the app installs new versions on its own and runs them after your next restart. Windows and Linux ask first. How updates work and how to turn them off.
To install the oxagen CLI alone, without the app, see Installing the CLI.
Wrap Claude Code, Codex, Cursor, or Stella
Name the harness on enroll or reassign:
oxagen agent enroll --harness claude-code,codex,cursor,stellaenroll merges the wrapper's hook entries into each harness's own configuration without touching an entry it did not write. It never removes a harness on a re-apply. oxagen agent reassign --harness <list> with no --workspace is the one way to drop one. Without a token, enroll uses your CLI login. See Account setup. To enroll the machine as a registered agent's host, pass the one-time token from agent registration with --token. On a machine that holds more than one agent, --harness on reassign also names the agent to change. See Two agents on one machine.
| Claude Code | Codex CLI | Stella | Cursor | |
|---|---|---|---|---|
| Hook file | ~/.claude/settings.json (CLAUDE_CONFIG_DIR) | ~/.codex/hooks.json (CODEX_HOME) | ~/.stella/stella.toml, or ~/.stella/settings.json when only that exists (STELLA_HOME) | ~/.cursor/hooks.json. When CURSOR_CONFIG_DIR or Linux/BSD XDG_CONFIG_HOME changes the config directory, enrollment writes both locations |
| Oxagen MCP server | oxagen in ~/.claude.json, or in .claude.json inside CLAUDE_CONFIG_DIR when that is set | [mcp_servers.oxagen] in $CODEX_HOME/config.toml (~/.codex/config.toml by default) | oxagen in $STELLA_HOME/mcp.toml (~/.stella/mcp.toml by default), as one marked block. Needs Stella 0.9.491 or newer, which reads that file | oxagen in ~/.cursor/mcp.json |
| How the entry is written | merged into the JSON hook block | merged into the JSON hook block | a marker-delimited block, # >>> tacho enrollment tch_… >>> to # <<< … <<<, so your own comments and settings around it survive | Version 1 JSON with flat hook entries, merged by enrollment ID |
| Events wrapped | SessionStart, UserPromptSubmit, PreToolUse, PermissionRequest, Stop as blocking command hooks. SessionEnd as a command hook that is spooled while the collector is down. 25 more events as telemetry, posted straight to the collector, among them PostToolUse, PostToolUseFailure, SubagentStart, SubagentStop, PreCompact, PostCompact, and Notification | the same five blocking command hooks as Claude Code. PostToolUse, SubagentStart, SubagentStop, PreCompact, PostCompact, SessionEnd, and Interrupt as telemetry command hooks. No OTel export | SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, PreCompact, SubagentStart, SubagentStop. Stella has no SessionEnd, PermissionRequest, PostCompact, or Interrupt hook | sessionStart, sessionEnd, beforeSubmitPrompt, preToolUse, postToolUse, postToolUseFailure, subagentStart, subagentStop, preCompact, stop, and afterAgentResponse |
| Hook command | oxagen hook --enrollment tch_… | oxagen hook --enrollment tch_… --harness codex | oxagen hook --enrollment tch_… --harness stella | oxagen hook --enrollment tch_… --harness cursor |
| Session boundary | one hooked Claude Code session. It closes on SessionEnd, or within a minute of the claude process exiting | one hooked Codex session. It closes on SessionEnd, or within a minute of the codex CLI process exiting. On Windows, and under the app server the Codex app and IDE extension drive, it closes on SessionEnd or after six idle hours | keyed on the Stella process. The session closes when the process exits, since Stella sends no session id and no SessionEnd | Cursor's conversation_id identifies the run across turns. It closes on sessionEnd, or after one idle hour, because no Cursor process lives exactly as long as one conversation. A later hook reopens it |
| Session label | agent.harness = claude-code, runtime = claude-code | agent.harness = codex, runtime = codex | agent.harness = stella, runtime = stella | agent.harness = cursor, runtime = cursor |
| Verify it | oxagen agent verify --harness claude-code | oxagen agent verify --harness codex | oxagen agent verify --harness stella runs stella run "<prompt>" | oxagen agent verify --harness cursor runs Cursor CLI with -p |
Enrolling Claude Code also adds Oxagen's MCP server to Claude Code's user config, under the name oxagen. The server runs oxagen mcp-stdio, which passes each call to the collector on this machine, so each Claude Code session gets Oxagen's own tools, such as record_reflection. When a run has a failed tool call, a call the policy denied, the same call three times in a row, or a prompt from you that corrects it, the Stop hook asks Claude Code once to call record_reflection. Claude Code loads MCP servers when a session starts, so the hook asks only a session that started after the server was added. Start a new session to see the tools, and run claude mcp list to check that oxagen connects. A machine enrolled before the server existed gets it the next time you run oxagen agent enroll or oxagen agent status. The desktop app runs status for you, so it adds the server after it updates. If another server already used the name oxagen, enrollment moves that server aside, and oxagen agent unenroll puts it back.
Cursor uses native camelCase hook events. Enrollment does not import Claude Code hooks or add an MCP server entry. At the veto points (beforeSubmitPrompt, preToolUse, and subagentStart), the writer sets failClosed: true. Telemetry-only hooks use failClosed: false. The adapter turns an approval request into a denial because Cursor does not enforce ask at preToolUse. sessionStart can supply context but cannot veto. These checks are vouched for only by the machine that runs them, and they depend on the installed hooks.
Stella itself is not required before you enroll. oxagen agent enroll --harness stella only writes the hook entry. Install Stella when you are ready for it to run:
curl -fsSL https://raw.githubusercontent.com/macanderson/stella/main/install.sh | shSetup from a coding agent
A coding agent connected to the Oxagen MCP server can do this setup for you. Ask in your own words, such as "put this laptop under Oxagen for Claude Code." The agent calls the tools below, and each tool has a CLI command that does the same job. Registering a machine and every removal need an organization Owner. An organization Owner or a workspace Admin can set up a cloud environment.
Machine registration
The agent runs hostname on the machine, then calls register_machine with the harness and the host name. The machine joins the workspace your MCP connection is for. Oxagen finds or makes the runtime named after the machine, finds or registers your own agent for the harness on it, and mints a one-time enrollment token. The answer's steps are the commands to run on the machine, in order: install the CLI, enroll with the token, and start the harness. The agent runs them.
From a terminal on the machine, after oxagen login, one command does all of it:
oxagen agent register-machine --harness claude-code --workspace-slug <slug> --enrollWithout --enroll, the command prints the steps, so you can run them on another machine. The token works once and expires unused after 30 minutes. Run the command again for a new one. It reuses the runtime and the agent. A machine runs one agent per harness, so the call is refused when another operator's agent already runs that harness there.
Cloud environment
A cloud run needs a cloud environment: a runtime where each run gets a sandbox of its own, on the workspace's own Modal account. Connect the workspace's Modal account first, on the Runtimes tab of Agents, as Connect Modal shows. Then setup_sandbox_runtime sets one up with no settings to pick. It writes a template from the workspace's linked repositories, or falls back to a starter template or a public Ubuntu image, and queues the template's build. A workspace that already has a cloud environment gets that one back, and nothing changes.
oxagen sandbox runtime setup --workspace-slug <slug>Modal bills the workspace for each template build and each run. To set up the cloud environment while you register a machine, pass cloudSandbox: true to register_machine, or --cloud-sandbox to oxagen agent register-machine.
Cloud runs
launch_sandbox_run starts a run of your own agent in a sandbox of its own. Name the harness, the repository, and the prompt:
oxagen sandbox run --harness claude --workspace-slug <slug> --repo acme/api --prompt "Fix the failing date test"oxagen sandbox list lists your runs, and oxagen sandbox show <run> reads one. oxagen sandbox attach <run> opens a run's terminal, and oxagen sandbox stop <run> stops it. Over MCP, list_sandbox_runs, get_sandbox_run, and stop_sandbox_run do the same. See oxagen sandbox for every flag.
Removal
| Change | MCP tool | CLI command |
|---|---|---|
| Revoke one machine an agent is enrolled on | revoke_tacho_enrollment | oxagen agent unenroll <agent> --host <tch_id> |
| Revoke every machine an agent is enrolled on | The same tool, once for each machine | oxagen agent unenroll <agent> |
| Suspend an agent until you resume it | suspend_agent | oxagen agent suspend <agent>, then oxagen agent resume <agent> |
| Retire an agent | retire_agent | oxagen agent retire <agent> |
| Delete a runtime | delete_runtime | oxagen runtime rm <runtime>, or oxagen sandbox runtime rm <runtime> for a cloud environment |
get_agent lists the machines enrolled under an agent, each with its tch_… id. Retiring an agent revokes every credential, enrolled machine, and mandate it holds, and its runs keep its name. A runtime can be deleted only once no live agent, enrolled machine, or running sandbox uses it. Revoking a machine through MCP, or with an agent argument, leaves its hooks in place, and every session on it is refused at its next step. That fits a lost machine. To take Oxagen off a machine you still have, run oxagen agent unenroll on it with no agent argument. See Remove one agent.
Wrap a custom agent
Any agent you run yourself, in a script, a CI job, or your own loop, can report to Oxagen. There is no settings file to merge into, because there is no fixed harness. You own the call sites. Start the agent with oxagen agent run, and Oxagen records the session around it. To record and gate each step, the agent also pipes the step into the hook. An agent you start some other way calls oxagen hook --agent <name> itself.
Enroll the machine first with oxagen agent enroll. oxagen hook reads the enrollment in ~/.config/oxagen/tacho/ itself, so your agent never sees a credential.
Run it under Oxagen
oxagen agent run --name my-agent -- ./my-agent --task buildEverything after -- is the agent's command and its arguments. The command does this, in order:
- Refuses on a machine with no enrollment, and exits
1. - Opens the session with a
SessionStarthook call under the agent's name. If Oxagen refuses the session, for example because an operator paused or suspended this machine, the agent does not start and the command exits1. - Starts the agent with three variables added to its environment.
- Closes the session with
SessionEndwhen the agent exits.
The command exits with the agent's own exit code. An agent stopped by a signal exits with 128 plus the signal number.
| Variable | Holds |
|---|---|
OXAGEN_AGENT | The name the session is recorded under |
OXAGEN_SESSION_ID | The session's id |
OXAGEN_HOOK | The shell command line of oxagen hook --agent <name> for this install |
--name sets the name the session is recorded under. Without it, the name comes from the command's file name: lowercased, with a script extension such as .py or .sh removed, and each run of characters the name pattern refuses turned into -. A script run through an interpreter, such as python3 agent.py, takes its name from the interpreter, so pass --name for it. A name that does not match the pattern in Call the hook yourself, or that names a built-in harness or runtime, is refused with exit 2.
A command that names a harness, claude, codex, cursor-agent, or stella, runs as it is, and that harness's own hooks record the session. If no agent on this machine wraps that harness, the command exits 2 and prints the oxagen agent enroll --harness command to run. With --name, the command is recorded as a custom agent whatever it names.
Pass --contained to start Claude Code or Codex in the contained launcher instead. It needs Linux with Docker. oxagen run -- claude … does the same.
Report each step
oxagen agent run records the session's start and end. To record and gate the steps in between, pipe one JSON payload per step into $OXAGEN_HOOK, with session_id set to $OXAGEN_SESSION_ID:
printf '{"session_id":"%s","hook_event_name":"UserPromptSubmit","prompt":"fix the failing test"}' "$OXAGEN_SESSION_ID" \
| sh -c "$OXAGEN_HOOK"$OXAGEN_HOOK is a shell command line with its words quoted, so run it through a shell. From Python, pass it to subprocess.run with shell=True. From Node, pass it to spawnSync with shell: true.
Send UserPromptSubmit, PreToolUse, PostToolUse, and Stop this way, as The call sequence describes. Skip SessionStart and SessionEnd, because oxagen agent run sends them. The payloads and the answers are the ones below. An agent that never calls $OXAGEN_HOOK is recorded only at its start and its end.
Call the hook yourself
An agent you do not start with oxagen agent run pipes one JSON payload into oxagen hook --agent <name> before and after each step, all six events of the call sequence:
oxagen hook --agent <name><name> matches ^[a-z0-9][a-z0-9._-]{0,63}$ and becomes the session's harness label. It cannot be a built-in harness or runtime name (claude-code, codex, cursor, stella, claude-desktop, claude-agent-sdk, custom, proxy). oxagen hook refuses those, so a custom agent is never filed as a built-in one.
On a machine with more than one agent enrolled, a custom agent reports under one of them: the agent whose directory under ~/.config/oxagen/tacho/agents/ comes first in name order. oxagen agent status prints each agent's directory.
The call sequence
Call the hook around each step of a run, in this order:
SessionStart, once, before the first prompt.UserPromptSubmit, once per prompt the agent receives.PreToolUse, before each tool call. Wait for the answer before running the tool.PostToolUse, after each tool call, with its result.Stop, when the agent finishes a turn.SessionEnd, once, when the run ends.
Under oxagen agent run, skip the first and the last.
Skip a call and that step is invisible to Oxagen. A PreToolUse you skip is a tool call nobody gated. See Limits.
Payload
The payload is the same JSON shape Claude Code sends on stdin. Omit the fields you don't have. Unrecognized fields are kept, not dropped.
| Field | Type | Carries |
|---|---|---|
session_id | string | your agent's own id for this run, or $OXAGEN_SESSION_ID under oxagen agent run |
hook_event_name | string | one of the six events above |
cwd | string, optional | the working directory the step ran in |
prompt | string, optional | the prompt on UserPromptSubmit |
tool_name | string, optional | on PreToolUse / PostToolUse |
tool_input | object, optional | the arguments about to run |
tool_use_id | string, optional | correlates a PreToolUse with its PostToolUse |
tool_response | unknown, optional | the tool's result, on PostToolUse |
transcript_path | string, optional | send null (the collector treats null the same as omitted) if you have none |
Answers
oxagen hook always exits 0 and prints one JSON document on stdout. Read it before you act.
| Event | Field | Meaning |
|---|---|---|
PreToolUse | hookSpecificOutput.permissionDecision | allow, deny, or ask, with permissionDecisionReason |
UserPromptSubmit | decision | "block" stops the prompt from reaching the agent |
SessionStart | continue / additionalContext | continue: false stops the run before it starts. additionalContext is text to fold into the system prompt |
| every event | (no special field) | an empty object means continue |
When the collector is unreachable, oxagen hook decides from the last policy bundle it cached, which Oxagen locked, and spools the event for the collector to replay once it is back. Locked means any change after that point shows. The answer still exits 0 and still carries a decision. That decision is enforcement_tier: client_attested, the same tier Claude Code and Codex run at. The failure mode is a run that skips the call, not a run that gets a deny.
A runnable example
Python, using subprocess:
import json
import subprocess
def hook(agent: str, event: str, **fields) -> dict:
payload = {"session_id": SESSION_ID, "hook_event_name": event, **fields}
result = subprocess.run(
["oxagen", "hook", "--agent", agent],
input=json.dumps(payload),
capture_output=True,
text=True,
check=False,
)
return json.loads(result.stdout or "{}")
SESSION_ID = "my-run-42"
hook("my-agent", "SessionStart", cwd="/work")
hook("my-agent", "UserPromptSubmit", prompt="fix the failing test")
answer = hook(
"my-agent",
"PreToolUse",
tool_name="run_shell",
tool_input={"command": "pytest -x"},
tool_use_id="call-1",
)
decision = answer.get("hookSpecificOutput", {}).get("permissionDecision", "allow")
if decision == "deny":
raise RuntimeError(answer["hookSpecificOutput"]["permissionDecisionReason"])
# … run the tool only after an allow or ask you've resolved …
hook(
"my-agent",
"PostToolUse",
tool_name="run_shell",
tool_use_id="call-1",
tool_response={"exit_code": 0},
)
hook("my-agent", "Stop")
hook("my-agent", "SessionEnd")TypeScript, using node:child_process:
import { spawnSync } from "node:child_process";
function hook(agent: string, event: string, fields: Record<string, unknown> = {}) {
const payload = JSON.stringify({ session_id: SESSION_ID, hook_event_name: event, ...fields });
const result = spawnSync("oxagen", ["hook", "--agent", agent], { input: payload, encoding: "utf8" });
return JSON.parse(result.stdout || "{}");
}
const SESSION_ID = "my-run-42";
hook("my-agent", "SessionStart", { cwd: "/work" });
hook("my-agent", "UserPromptSubmit", { prompt: "fix the failing test" });
const answer = hook("my-agent", "PreToolUse", {
tool_name: "run_shell",
tool_input: { command: "pytest -x" },
tool_use_id: "call-1",
});
const decision = answer.hookSpecificOutput?.permissionDecision ?? "allow";
if (decision === "deny") {
throw new Error(answer.hookSpecificOutput.permissionDecisionReason);
}
// … run the tool only after an allow or ask you've resolved …
hook("my-agent", "PostToolUse", {
tool_name: "run_shell",
tool_use_id: "call-1",
tool_response: { exit_code: 0 },
});
hook("my-agent", "Stop");
hook("my-agent", "SessionEnd");To run either example under oxagen agent run, drop the SessionStart and SessionEnd calls, take SESSION_ID from OXAGEN_SESSION_ID, and run OXAGEN_HOOK through a shell in place of oxagen hook --agent.
A shell one-liner for a quick check:
echo '{"session_id":"probe-1","hook_event_name":"SessionStart","cwd":"'"$PWD"'"}' \
| oxagen hook --agent my-agentConfirm it works
Three places agree, because they all read the same record:
-
oxagen agent statuson the machine: shows each enrollment, each wrapped harness, and whether hooks are present. -
The Oxagen app's Wrapped agents panel: lists Claude Code, Codex, Cursor, Stella, and every custom agent the collector has seen, each with a status:
Status Means Healthy runs recorded and delivered to Oxagen Sending recorded, waiting to send Needs attention hooks missing, Oxagen refused events or was unreachable, or events have waited more than 10 minutes Collector down the collector is not answering Idle wrapped with no runs recorded yet, or no run in the last seven days Not wrapped no agent on this machine hooks this harness Unknown oxagen agent statushas not yet said whether the hooks are in place -
The operator console: in the Oxagen app, Open this workspace in Oxagen opens the workspace in the browser, where the run lands in the workspace's fleet record once the collector ships it.
Two agents on one machine
An agent is one operator on one runtime with one harness, so Claude Code and Codex on your laptop are two agents. Each one gets its own enrollment: its own agent key, device key, credentials, collector port, and event log. One collector service runs a collector for every agent on the machine.
Enroll the second agent
Register the second agent on the Agents page. Then run the command the page shows on the machine that already runs the first agent:
oxagen agent enroll --token <oxe_1time_…> --harness codexThe first agent keeps recording, and nothing is revoked. Each agent keeps its files in a directory of its own under ~/.config/oxagen/tacho/agents/, named by a short random id, and the two directories hold the same files under the same names. The harness's own hook file, such as ~/.codex/hooks.json, stays where it is, and each hook entry names the enrollment it belongs to.
Three rules decide what an enroll does on a machine that already has an agent:
- A harness belongs to one agent. A token for a harness that an agent on the machine already hooks is refused. Pass
--forceto replace that agent's enrollment with the agent the token names. The other agents keep theirs. - An enroll for a harness that no agent hooks enrolls a new agent in a new directory, with a token or without one.
- To add a harness to an agent, name the harness it already hooks and the new one:
oxagen agent enroll --harness claude-code,codex. The command revokes that agent and enrolls it again under a new id with both harnesses. An enroll that names harnesses two different agents hook is refused.
Check both agents
oxagen agent statusOn a machine with more than one enrollment, the output starts with a count. Each agent's report follows a line that names its harnesses, its agent key, and its directory:
This machine holds 2 enrollments.
Agent claude-code as acme.core.cc-laptop in /Users/you/.config/oxagen/tacho/agents/3f9a1c20
…
Agent codex as acme.core.codex-laptop in /Users/you/.config/oxagen/tacho/agents/b71e04d8
…An agent removed on this machine whose revoke has not reached Oxagen yet reads (retired on this machine). An agent an operator revoked in the web app, from the agent's Runtime tab on the Agents page, reads (revoked on the Runs page).
oxagen agent status --json keeps the shape a script already reads. Its top-level fields are the report of the first agent still enrolled on this machine. The output always carries enrollments: one full report per agent, oldest first, each with an id and a dir that name its directory. This command lists every agent:
oxagen agent status --json | jq -r '.enrollments[] | [.host.agent_key, (.host.harnesses // [] | join(",")), .enrolled, .shipping.healthy] | @tsv'oxagen agent status exits 1 when no agent on the machine is enrolled, or when any agent's events are not reaching Oxagen (shipping.healthy is false).
Remove one agent
oxagen agent unenroll --harness codex--harness names the agent by the harness it hooks. The command removes that agent's hooks, revokes its enrollment, and deletes its keys. The other agents stay enrolled. To remove every agent on the machine, pass --all:
oxagen agent unenroll --allPass one of the two flags. Passing both is an error. On a machine with more than one enrollment, oxagen agent unenroll with neither flag changes nothing. It lists the agents and exits 1:
error: this machine holds 2 enrollments: claude-code as acme.core.cc-laptop; codex as acme.core.codex-laptop. Pass --harness to name the one to remove, or --all to remove them allRemoving one agent restarts the collector service, because one service runs every agent's collector. Until the service is back, the other agents have no collector and no model proxy. Their hooks decide from the cached policy bundle and spool their events, and the collector sends the spooled events when it starts. A model call routed through the proxy fails in that window. If the service does not start again, the command names the agents left without a collector and exits 1. Run oxagen agent enroll --harness <harness> with a harness one of those agents hooks to install the service again.
With --purge, the collector log is kept while another agent is still enrolled, because every agent writes to the same log.
Reassign one agent
oxagen agent reassign --harness codex --workspace <slug>On a machine with more than one enrollment, reassign needs --harness. The list picks the agent that hooks at least one of those harnesses, and it becomes that agent's harness list. A list that names harnesses of two agents is refused. Reassign one agent at a time.
reassign enrolls the agent again through your CLI session, the way oxagen agent enroll does without a token, so run oxagen login first. It moves only an agent you enrolled that way.
An agent you enrolled with a one-time token from the Agents page stays in the workspace it is registered in. reassign refuses it and changes nothing: the agent keeps its enrollment, its hooks, and its record. It refuses a --harness change on such an agent too, because the new enrollment would go through your CLI session and would not be linked to the agent. To report the harness to another workspace:
- Register an agent in the target workspace on the Agents page.
- Run
oxagen agent unenroll --harness <harness>with a harness the current agent hooks. - Run the enroll command the new agent's page shows.
Adding a harness to an agent with oxagen agent enroll --harness also enrolls it again through your CLI session, so it is refused on an agent you enrolled with a token. Register an agent for the new harness on the Agents page and run the enroll command its page shows. The new agent gets a directory of its own beside the first.
Limits
A run's cost comes from the usage its model calls reported. Claude Code reports it itself. Codex and Stella on Anthropic report it through the local model proxy once enrollment points them at it. Cursor never does, because Cursor has no setting that routes its model calls through the proxy. Stella on another provider, or with an Anthropic base URL you set yourself, does not either. Such a run records its tool calls and no usage, so its cost is not in any spend total. The Spend page and the Runs spend tile say how many runs reported no usage, and on which harness, beside each total that leaves them out.
Wrapping is client_attested: the hook call is voluntary, so a custom agent that skips it is not gated and not recorded. Under oxagen agent run, such an agent is recorded only at its start and its end. Nothing on the collector side can force the call or detect that a step happened without it. A missing event shows up in the fleet record as silence, not as a flagged violation. Each agent enrolled with its own one-time token has its own enrollment, host identity, and key. Harnesses enrolled together in one oxagen agent enroll share one enrollment. Custom agents report under the agent whose directory under ~/.config/oxagen/tacho/agents/ comes first in name order. When that agent is removed, they report under the next one, and with no agent left on the machine they are not recorded.
Records by harness
Every harness's hooks record its prompts and tool calls. The rest of the record depends on what each harness hands its hooks:
| Claude Code | Codex CLI | Stella | Cursor | |
|---|---|---|---|---|
| Agent messages | every message | every message | the last message of each turn | every message |
| Model | each model call | each model call | not recorded | the chosen model, on each hook |
| Effort | each model call | each model call | not recorded | the chosen effort setting, on each hook, when the model has one |
| Prompt sender | each prompt | each prompt Codex records a sender for | not recorded | a stop hook's follow-up only |
| Message sent mid-turn | each message, with its words | not recorded | not recorded | not recorded |
Stella's hooks carry no model and no effort. They see the agent's words only at the end of each turn. A model call the local model proxy carries records its model, its effort, and the whole request and response. Stella on Anthropic sends its calls through the proxy once enrollment points it there. Stella on another provider does not, so its runs have no model, no effort, and only each turn's last message.
Stella's hooks do not say who sent a prompt yet, so a Stella prompt records no sender.
Cursor sends the model and effort setting chosen in its model picker with each hook, not with each model call. Oxagen records a change at the next hook. Cursor does not say who sent a prompt either. The one exception is a follow-up, the prompt a stop hook sends to keep the agent working. The stop that ends the follow-up's turn shows it, so Oxagen records that turn as sent by the system. The record notes that Oxagen worked this out from the stop.
Claude Code hands a message you type while the agent works to the agent at its next step, or starts the next turn with it. Oxagen records both, and notes that the agent was working when you sent it. Codex writes such a message to its session file, and Oxagen does not read prompts from that file yet. Cursor and Stella give their hooks no such message.
The recurring_runs finding uses the prompt sender to leave out runs a person started. On a harness that records no sender, it groups runs by their first prompt alone.
The repeated_instructions finding uses it the other way. It reads only a prompt the record says a person sent after the run's first prompt: a later turn's prompt, or a message sent while the agent worked. So only a person's repeated corrections become steering record suggestions, and stella checks each one before anyone sees it. A run's first prompt, a scheduled prompt, a stop hook's follow-up, a task notification, a message from another session, text that opens with a tag or a bracket, and a prompt with no recorded sender are all left out. On a harness that records no sender, the finding has no prompts to read.