Wrap an agent
What wrapping means, how Claude Code, Codex, Cursor, and Stella get wrapped in one enrollment, and how to wrap a custom agent by calling tacho hook around its steps.
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 client-attested. The process owner can remove the hooks, so the hook tier does not enforce against that person. Observe mode records activity without enforcing it.
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.
Supported harness adapters include Claude Code, Codex, Cursor, and Stella. A custom agent can call tacho hook around its own steps. The table below describes all four hook adapters.
Wrap Claude Code, Codex, Cursor, or Stella
Name the harness on enroll or reassign:
tacho 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, and never removes a harness on a re-apply; reassign --harness <list> with no --workspace is the one way to drop one. See Account setup for CLI authentication. You can also use oxagen agent enroll with the one-time token issued during agent registration.
| 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 |
| 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; PostToolUse, SubagentStart, SubagentStop, PreCompact, PostCompact, SessionEnd, Interrupt as telemetry | same shape as Claude Code; no PermissionRequest as its own event, 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, and stop |
| Hook command | tacho hook --enrollment tch_… | tacho hook --enrollment tch_… --harness codex | tacho hook --enrollment tch_… --harness stella | tacho hook --enrollment tch_… --harness cursor |
| Session boundary | one hooked Claude Code session | one hooked Codex session | 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 |
| 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 | tacho verify --harness claude-code | tacho verify --harness codex | tacho verify --harness stella runs stella run "<prompt>" | tacho verify --harness cursor runs Cursor CLI with -p |
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 remain client-attested and depend on the installed hooks.
Stella itself is not required before you enroll: 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 | shWrap a custom agent
Any agent you run yourself, in a script, a CI job, or your own loop, wraps by piping one JSON payload into tacho hook --agent <name> before and after each step. There is no settings file to merge into, because there is no fixed harness: you own the call sites.
tacho 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): tacho hook refuses those, so a custom agent is never filed as a built-in one. The machine still has to be enrolled first (tacho enroll) so tacho hook can read ~/.config/oxagen/tacho/host.json itself; your agent never sees a credential.
For a complete wrapped agent with tests, see oxagenai/oxagen-wrapped-agents. Its Stripe refund agent sends every step through tacho hook --agent stripe-refunds, fails closed when tacho does not answer, and shows an operator pause denying the next refund before it reaches Stripe.
The call sequence
Call tacho hook --agent <name> 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, and 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.
Skip a call and that step is invisible to Oxagen. A PreToolUse you skip is a tool call nobody gated; see Limits below.
Payload
The payload is the same JSON shape Claude Code sends on stdin. Fields you don't have, omit; unrecognized fields are kept, not dropped.
| Field | Type | Carries |
| --- | --- | --- | --- |
| session_id | string | your agent's own id for this 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
tacho 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, tacho hook decides from the last signed policy bundle it cached and spools the event for the daemon to replay once it is back. 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, so a run that skips the call is the failure mode, not a run that gets a deny.
A runnable example
Python, using subprocess:
import json
import subprocess
def tacho(agent: str, event: str, **fields) -> dict:
payload = {"session_id": SESSION_ID, "hook_event_name": event, **fields}
result = subprocess.run(
["tacho", "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"
tacho("my-agent", "SessionStart", cwd="/work")
tacho("my-agent", "UserPromptSubmit", prompt="fix the failing test")
answer = tacho(
"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 …
tacho(
"my-agent",
"PostToolUse",
tool_name="run_shell",
tool_use_id="call-1",
tool_response={"exit_code": 0},
)
tacho("my-agent", "Stop")
tacho("my-agent", "SessionEnd")TypeScript, using node:child_process:
import { spawnSync } from "node:child_process";
function tacho(agent: string, event: string, fields: Record<string, unknown> = {}) {
const payload = JSON.stringify({ session_id: SESSION_ID, hook_event_name: event, ...fields });
const result = spawnSync("tacho", ["hook", "--agent", agent], { input: payload, encoding: "utf8" });
return JSON.parse(result.stdout || "{}");
}
const SESSION_ID = "my-run-42";
tacho("my-agent", "SessionStart", { cwd: "/work" });
tacho("my-agent", "UserPromptSubmit", { prompt: "fix the failing test" });
const answer = tacho("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 …
tacho("my-agent", "PostToolUse", {
tool_name: "run_shell",
tool_use_id: "call-1",
tool_response: { exit_code: 0 },
});
tacho("my-agent", "Stop");
tacho("my-agent", "SessionEnd");A shell one-liner for a quick check:
echo '{"session_id":"probe-1","hook_event_name":"SessionStart","cwd":"'"$PWD"'"}' \
| tacho hook --agent my-agentConfirm it works
Three places agree, because they all read the same record:
-
tacho statuson the machine: shows the enrollment, each wrapped harness, and whether hooks are present. -
The app's Wrapped agents panel: lists Claude Code, Codex, Cursor, Stella, and every custom agent the collector has seen, each with a health state:
State Means healthy runs recorded and delivered to Oxagen pending recorded, waiting to send degraded hooks missing, Oxagen refused or was unreachable, or something has been pending more than 10 minutes down the collector is not answering idle wrapped, no runs yet not wrapped the harness or agent name has never called the hook -
The operator console: Open Oxagen opens this workspace in the browser, where the run lands in the workspace's fleet record once the collector ships it.
Limits
Wrapping is client_attested: the hook call is voluntary, so a custom agent that skips it is not gated and not recorded. 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. Enrollment is per machine, not per agent, so every harness and every custom agent on an enrolled machine share one host identity and one key.