Connect over HTTP and OpenTelemetry
Any agent or script can use the session store and CtrlB Relay over HTTP (Hypertext Transfer Protocol). These are the calls the Claude Code plugin and the Codex hooks make. Replace <your CtrlB URL> and <your key> and each command runs as written.
If you only want the harness's own event log, and not the session store or Relay, see OpenTelemetry only below.
Before you start
- Generate your MCP (Model Context Protocol) key on the Connect screen under AI Observability. The HTTP tab shows a shell profile with your CtrlB URL filled in, and the post, claim, release and peers calls.
- Every call sends the key as
Authorization: Bearer <your key>. The key is accepted in that header only, not as a query parameter. It acts as you, with your roles and dataset grants. - Bodies and answers are JSON. Times in answers are microseconds since the epoch.
agent_sessionsin the paths is the sessions dataset, the workspace default. Relay rows for it are kept in_ctrlb_relay_agent_sessions, which CtrlB creates on the first write. Writes (records, posts, claims, releases and checks) are accepted only for the workspace's sessions dataset; any other dataset answers400. Reads may name another sessions dataset you can read.- The examples use run
demo-run-1and agentsagent-aandagent-b. A run is any set of agents that use the same run id. An agent id is any name unique in the run.
Upload session records
curl -sS -X POST "<your CtrlB URL>/ai/sessions/agent_sessions/records" \
-H "Authorization: Bearer <your key>" \
-H "Content-Type: application/json" \
-d '[{"_timestamp": 1793000000000000, "host": "build-01", "harness": "script",
"session_id": "sess-1", "parent_session": "", "agent_id": "", "run_id": "demo-run-1", "agent": "agent-a",
"seq": 1, "block_idx": 0, "role": "assistant", "block_type": "tool_use", "tool_name": "Edit",
"tool_use_id": "call-1", "command": "", "paths": "[\"src/app.py\"]", "body": "{\"file_path\": \"src/app.py\"}",
"is_error": false, "is_summary": false, "cwd": "/work/app", "git_branch": "main",
"cost_usd": 0.0, "input_tokens": 0.0, "output_tokens": 0.0}]'
Answer: 202 {"accepted": 1}.
- One row per content block.
block_typeistext,thinking,tool_use,tool_resultorresult.session_idandseqare required. harnessnames the client. The plugin writesclaudeand the Codex hooks writecodex. Another client may name its own.pathsis a JSON array, as text, of the files an edit touched. Write them relative tocwdwhere you can, so they match the keys agents claim.- Write numbers as floats with a decimal point (
0.0) and flags as booleans from the first row. The dataset fixes a column's type from the first value it sees. - Leave
user_nameout: CtrlB fills it from the key. A row naming another user refuses the whole upload with403, and nothing is written. - At most 500 rows per call (more is
400) and 4 MiB per call (more is413). - The plugin replaces secrets with
[REDACTED]before it uploads. Over HTTP, that is up to you.
Search session records
curl -sS -G "<your CtrlB URL>/ai/sessions/agent_sessions/search" \
-H "Authorization: Bearer <your key>" \
--data-urlencode "term=src/app.py" \
--data-urlencode "order=newest"
Whole-word, case-insensitive search of the text and shell command of your rows, with 300 characters around each hit. The other session routes are GET /ai/sessions/<dataset>/<session id>/records, GET /ai/sessions/<dataset>/commands and GET /ai/sessions/<dataset>/<session id>/next. They answer what the matching MCP tools answer. You see your own rows unless your role has the relay.read_all permission.
Post a note
curl -sS -X POST "<your CtrlB URL>/ai/relay/agent_sessions/runs/demo-run-1/posts" \
-H "Authorization: Bearer <your key>" \
-H "Content-Type: application/json" \
-d '{"agent": "agent-a", "text": "Taking the parser tests in src/parser"}'
Answer: 201 {"ok": true}. Text is cut at 2,000 characters.
Read the run's posts and claims, newest first:
curl -sS "<your CtrlB URL>/ai/relay/agent_sessions/runs/demo-run-1/peers?limit=40" \
-H "Authorization: Bearer <your key>"
Claim a resource
curl -sS -X POST "<your CtrlB URL>/ai/relay/agent_sessions/runs/demo-run-1/claims" \
-H "Authorization: Bearer <your key>" \
-H "Content-Type: application/json" \
-d '{"agent": "agent-a", "resource": "src/app.py", "cwd": "/work/app"}'
The answer is always 200. When the claim is yours:
{"held": true, "resource": "src/app.py", "key": "path:src/app.py", "lease_until": 1793000600000000}
When another agent holds the key:
{"held": false, "resource": "src/app.py", "key": "path:src/app.py", "held_by": "agent-b", "message": "src/app.py is already held by agent-b; pick something else"}
- A refusal is a normal answer. Work on the resource only when
heldis true. - A finished resource answers with
finished_byinstead ofheld_by. - Claiming a key you already hold renews it: the answer is
held: truewith"renewed": trueand a newlease_until. - The claim lasts for the lease, 10 minutes by default, unless it is released.
Release a resource
curl -sS -X DELETE "<your CtrlB URL>/ai/relay/agent_sessions/runs/demo-run-1/claims" \
-H "Authorization: Bearer <your key>" \
-H "Content-Type: application/json" \
-d '{"agent": "agent-a", "resource": "src/app.py", "finished": true}'
Answer: {"released": 1, "finished": true, "resource": "src/app.py", "key": "path:src/app.py"}.
finished: true(the default) marks the key done. Later claims on it are refused withfinished_by.finished: falsehands it back for another agent to claim.releasedis 0 when the agent held no live claim on the key, for example after its lease ran out. Then nothing is written.- If your HTTP client drops
DELETEbodies, send the same fields as query parameters.
Check paths before an edit
curl -sS -X POST "<your CtrlB URL>/ai/relay/agent_sessions/runs/demo-run-1/check" \
-H "Authorization: Bearer <your key>" \
-H "Content-Type: application/json" \
-d '{"agent": "agent-b", "paths": ["src/app.py"], "cwd": "/work/app"}'
{"mode": "advisory", "allowed": true,
"conflicts": [{"path": "src/app.py", "key": "path:src/app.py", "held_by": "agent-a", "lease_until": 1793000600000000}]}
mode is the workspace's enforce mode: off, advisory or enforce. allowed is false only in enforce mode with a conflict. A path the calling agent holds is not a conflict. At most 64 paths per call.
Runs and settings
GET /ai/relay/<dataset>/runs?status=live|done|alllists runs, newest started first. Passnext_cursorback ascursorfor the next page.GET /ai/relay/<dataset>/runs/<run id>returns one run: its agents, events, resources and detectors. This is what the Runs page shows.GET /ai/relay/settingsreturns the workspace's Relay settings. A key can read them. A key cannot change them:PUT /ai/relay/settingsrefuses an MCP key, because a key could otherwise turn enforce mode off. A person signed in to CtrlB with therelay.managepermission changes them.
Run and agent from headers
The agent may be left out of a body when the request carries an X-CtrlB-Agent header. The run is always the one in the path. A header that is empty, or that starts with ${ because a client config did not expand an unset variable, counts as absent.
Errors
Errors answer {"error": "..."}:
| Status | When |
|---|---|
400 | A field fails validation, or an upload has more than 500 rows |
401 | The request carries no user |
403 | Your role or dataset grant does not allow the call, or a record names another user |
404 | You see no row of the run |
413 | An upload body is larger than 4 MiB |
A key CtrlB refuses also answers with a code: MCP_INVALID_KEY, MCP_KEY_EXPIRED, MCP_KEY_STALE, MCP_KEY_REVOKED, MCP_WORKSPACE_MISMATCH, MCP_FORBIDDEN_ROLE or MCP_AUTH_UNAVAILABLE. Generate a new key on the Connect screen for any of the first four.
OpenTelemetry only
Claude Code and Codex can both export their own events as OpenTelemetry (OTEL) logs over OTLP (the OpenTelemetry Protocol). CtrlB accepts OTLP logs on each dataset's ingest endpoint, so the harness can send straight to CtrlB with no collector in between. Nothing from the ctrlb-agent repository is installed.
This path uses your workspace's ingest credentials, the ones your services already send telemetry with. It does not use your MCP key. You need two values:
| Value | What it is |
|---|---|
<your OTLP logs endpoint> | The OTLP logs URL of the dataset to write to. It ends in /_otel/v1/logs. |
<your ingest credentials> | The Authorization header value for that endpoint, Basic <API_TOKEN>. |
Prerequisites explains where both come from.
Claude Code
Add these lines to your shell profile, or put them in front of one claude command:
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=http/json
export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT="<your OTLP logs endpoint>"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=<your ingest credentials>"
export OTEL_LOG_USER_PROMPTS=1
export OTEL_LOG_ASSISTANT_RESPONSES=1
export OTEL_LOG_TOOL_DETAILS=1
export OTEL_LOGS_EXPORT_INTERVAL=5000
- The three
OTEL_LOG_*lines add the prompt text, the response text and the tool arguments to the records. Without them, the records carry event names, tool names, timings, token counts and cost, but no text. OTEL_LOGS_EXPORT_INTERVALis how often, in milliseconds, a batch is sent. Records arrive in CtrlB about that long after the event.
OTEL records carry no run id. To tag every record of a run, add a resource attribute:
OTEL_RESOURCE_ATTRIBUTES=ctrlb.run=nightly-refactor-1 claude -p "Fix the failing tests in src/parser"
Codex
Codex reads its OTEL settings from config.toml. The ctrlb-agent installer writes them inside the same marked block as the MCP server and the hooks:
cd ~/ctrlb-agent
python3 -I install.py --codex --otel \
--otel-endpoint "<your OTLP logs endpoint>" \
--otel-header "Authorization=<your ingest credentials>" \
--dry-run
Drop --dry-run to write it. The header value is stored in config.toml as given, so keep the file's mode at 0600. The installer refuses to write your MCP key into an OTEL header.
To add the export without the hooks or the MCP server, write the tables yourself:
[otel]
log_user_prompt = true
log_agent_responses = true
[otel.exporter.otlp-http]
endpoint = "<your OTLP logs endpoint>"
protocol = "json"
headers = { "Authorization" = "<your ingest credentials>" }
exporteris a table here, not a string.endpointandprotocolare required.protocolis"json"or"binary".tool_result = { max_bytes = 65536 }under[otel]caps the tool output in each record.
Run codex mcp list after editing. Codex loads the whole file and names any key it rejects.
What OpenTelemetry export does not carry
The export is the harness's own event log. Compared with the plugin and the Codex hooks, it has:
- No session store rows. The session tools, the Runs page and the session drawer read session store rows. They do not read OTEL records.
- No run id or agent id columns. A run is only what you tag with
OTEL_RESOURCE_ATTRIBUTESin Claude Code. - No thinking blocks. Records are per event, not per content block.
- No
pathscolumn, so the duplicate edit detector does not see the edits. - No CtrlB Relay. The agent has no
peers,post,claimorreleasetools, and edits are not checked. - No redaction. Prompt and response text is exported as written.
- No past sessions. Only events after the variables are set are exported.