Skip to main content

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_sessions in 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 answers 400. Reads may name another sessions dataset you can read.
  • The examples use run demo-run-1 and agents agent-a and agent-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_type is text, thinking, tool_use, tool_result or result. session_id and seq are required.
  • harness names the client. The plugin writes claude and the Codex hooks write codex. Another client may name its own.
  • paths is a JSON array, as text, of the files an edit touched. Write them relative to cwd where 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_name out: CtrlB fills it from the key. A row naming another user refuses the whole upload with 403, and nothing is written.
  • At most 500 rows per call (more is 400) and 4 MiB per call (more is 413).
  • 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 held is true.
  • A finished resource answers with finished_by instead of held_by.
  • Claiming a key you already hold renews it: the answer is held: true with "renewed": true and a new lease_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 with finished_by.
  • finished: false hands it back for another agent to claim.
  • released is 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 DELETE bodies, 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|all lists runs, newest started first. Pass next_cursor back as cursor for 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/settings returns the workspace's Relay settings. A key can read them. A key cannot change them: PUT /ai/relay/settings refuses an MCP key, because a key could otherwise turn enforce mode off. A person signed in to CtrlB with the relay.manage permission 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": "..."}:

StatusWhen
400A field fails validation, or an upload has more than 500 rows
401The request carries no user
403Your role or dataset grant does not allow the call, or a record names another user
404You see no row of the run
413An 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:

ValueWhat 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_INTERVAL is 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>" }
  • exporter is a table here, not a string.
  • endpoint and protocol are required. protocol is "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_ATTRIBUTES in Claude Code.
  • No thinking blocks. Records are per event, not per content block.
  • No paths column, so the duplicate edit detector does not see the edits.
  • No CtrlB Relay. The agent has no peers, post, claim or release tools, 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.