diff --git a/VERSION b/VERSION
index 5aee134..ae02209 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.89.0
+0.90.0
diff --git a/adapters/claude_code_otel.py b/adapters/claude_code_otel.py
new file mode 100644
index 0000000..0ffbba4
--- /dev/null
+++ b/adapters/claude_code_otel.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+"""Build the Claude Code settings env needed for structural OWG telemetry only."""
+
+
+def env_settings(*, token: str, base_url: str) -> dict[str, str]:
+ base = str(base_url or "").rstrip("/")
+ return {
+ "CLAUDE_CODE_ENABLE_TELEMETRY": "1",
+ "OTEL_LOGS_EXPORTER": "otlp",
+ "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL": "http/json",
+ "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT": f"{base}/agent-ingest/v1/claude-otel",
+ "OTEL_EXPORTER_OTLP_LOGS_HEADERS": f"Authorization=Bearer {token}",
+ # Claude leaves these content surfaces off by default. OWG writes the
+ # explicit zeroes as defense in depth; the ingest adapter independently
+ # allowlists structural attributes and would discard content anyway.
+ "OTEL_LOG_USER_PROMPTS": "0",
+ "OTEL_LOG_ASSISTANT_RESPONSES": "0",
+ "OTEL_LOG_TOOL_DETAILS": "0",
+ "OTEL_LOG_TOOL_CONTENT": "0",
+ "OTEL_LOG_RAW_API_BODIES": "0",
+ }
+
+
+def settings_fragment(*, token: str, base_url: str) -> dict[str, dict[str, str]]:
+ return {"env": env_settings(token=token, base_url=base_url)}
+
+
+__all__ = ["env_settings", "settings_fragment"]
diff --git a/adapters/codex_config.py b/adapters/codex_config.py
index c0e24ad..3d56e6f 100644
--- a/adapters/codex_config.py
+++ b/adapters/codex_config.py
@@ -1,10 +1,11 @@
from __future__ import annotations
-"""Print a Codex OTLP trace-exporter snippet for OpenWorkGraph.
+"""Print a privacy-safe Codex OTLP logs + trace exporter snippet for OWG.
-This helper never edits ~/.codex/config.toml. With --with-token it intentionally
-prints the least-privilege write token so an administrator can paste a complete
-local-only configuration.
+Codex's business events (API requests, completed tools, approval decisions and
+multi-agent communication) are emitted on its OTLP log surface, while native
+span hierarchy is emitted on the trace surface. OWG points both at the same
+write-only structural endpoint and keeps all content-bearing opt-ins disabled.
"""
import argparse
@@ -20,21 +21,29 @@ def _toml_string(value: str) -> str:
return json.dumps(value)
+def _exporter(endpoint: str, authorization: str) -> str:
+ return (
+ "{ otlp-http = { endpoint = "
+ + _toml_string(endpoint)
+ + ", headers = { Authorization = "
+ + _toml_string(authorization)
+ + " }, protocol = \"json\" } }"
+ )
+
+
def config_snippet(*, token: str, base_url: str | None = None) -> str:
endpoint = (base_url or _base_url()).rstrip("/") + "/agent-ingest/v1/codex-otel"
authorization = f"Bearer {token}"
+ exporter = _exporter(endpoint, authorization)
return "\n".join([
"[otel]",
+ # Keep every Codex content-bearing opt-in disabled. Structural business
+ # events still export and are then strict-allowlisted again server-side.
"log_user_prompt = false",
"log_agent_responses = false",
"log_guardian_assessments = false",
- (
- "trace_exporter = { otlp-http = { endpoint = "
- + _toml_string(endpoint)
- + ", headers = { Authorization = "
- + _toml_string(authorization)
- + " }, protocol = \"json\" } }"
- ),
+ f"exporter = {exporter}",
+ f"trace_exporter = {exporter}",
])
diff --git a/adapters/openai_agents.py b/adapters/openai_agents.py
index a8dbb49..fa59a88 100644
--- a/adapters/openai_agents.py
+++ b/adapters/openai_agents.py
@@ -28,6 +28,7 @@ class _TracingProcessor: # type: ignore[no-redef]
_SAFE_LABEL = re.compile(r"^[A-Za-z][A-Za-z0-9_.:/-]{0,199}$")
_USAGE_KEYS = frozenset({"input_tokens", "output_tokens", "cached_input_tokens", "total_tokens"})
+_SENSOR_ID = "agent:openai-agents"
def _now_iso() -> str:
@@ -86,21 +87,34 @@ def _duration_seconds(span: Any) -> float:
def _usage(value: Any) -> dict[str, int]:
- if not isinstance(value, dict):
+ """Read only numeric token counters from dict- or object-shaped SDK usage."""
+ if value is None:
return {}
out: dict[str, int] = {}
for key in _USAGE_KEYS:
- if key not in value:
+ if isinstance(value, dict):
+ raw = value.get(key)
+ else:
+ raw = getattr(value, key, None)
+ if raw is None:
continue
try:
- amount = int(value[key])
+ amount = int(raw)
except Exception:
continue
if 0 <= amount <= 1_000_000_000:
out[key] = amount
+ if "total_tokens" not in out and ("input_tokens" in out or "output_tokens" in out):
+ out["total_tokens"] = out.get("input_tokens", 0) + out.get("output_tokens", 0)
return out
+def _response_model(data: Any) -> str:
+ """Read only the response model identifier, never response content."""
+ response = getattr(data, "response", None)
+ return _safe_label(getattr(response, "model", ""), limit=200) if response is not None else ""
+
+
def _tool_category(name: str, *, is_mcp: bool = False) -> str:
if is_mcp:
return "mcp"
@@ -212,6 +226,7 @@ def on_trace_start(self, trace: Any) -> None:
self._emit({
"event_id": _event_id("trace-start", raw_trace),
"observed_at": _now_iso(),
+ "sensor_id": _SENSOR_ID,
"agent_name": "OpenAI-Agents-SDK",
"provider": "openai",
"framework": "openai-agents-python",
@@ -232,6 +247,7 @@ def on_trace_end(self, trace: Any) -> None:
self._emit({
"event_id": _event_id("trace-end", raw_trace),
"observed_at": _now_iso(),
+ "sensor_id": _SENSOR_ID,
"agent_name": "OpenAI-Agents-SDK",
"provider": "openai",
"framework": "openai-agents-python",
@@ -271,6 +287,7 @@ def on_span_end(self, span: Any) -> None:
base: dict[str, Any] = {
"event_id": _event_id(span_type, raw_trace, raw_span),
"observed_at": observed_at,
+ "sensor_id": _SENSOR_ID,
"agent_name": agent_name,
"provider": "openai",
"framework": "openai-agents-python",
@@ -285,10 +302,21 @@ def on_span_end(self, span: Any) -> None:
if span_type in {"generation", "response", "transcription", "speech"}:
base["operation"] = "model_call"
- if span_type != "response":
- base["model"] = _safe_label(getattr(data, "model", ""), limit=200)
- if span_type == "generation":
- base["usage"] = _usage(getattr(data, "usage", None))
+ if span_type == "response":
+ model = _response_model(data)
+ if model:
+ base["model"] = model
+ usage = _usage(getattr(data, "usage", None))
+ if usage:
+ base["usage"] = usage
+ else:
+ model = _safe_label(getattr(data, "model", ""), limit=200)
+ if model:
+ base["model"] = model
+ if span_type == "generation":
+ usage = _usage(getattr(data, "usage", None))
+ if usage:
+ base["usage"] = usage
self._emit(base)
return
diff --git a/dashboard/agent_control_plane.js b/dashboard/agent_control_plane.js
index b661ffa..3789e02 100644
--- a/dashboard/agent_control_plane.js
+++ b/dashboard/agent_control_plane.js
@@ -43,13 +43,13 @@
const section=document.createElement('div');
section.id='agent-observation-setup';section.className='card connection-section';
section.innerHTML=`
-
Observe an agent
Instrument an agent's native lifecycle or trace surface so OpenWorkGraph can measure structural execution: runs, tools, handoffs, approvals, failures, timings and coverage. Prompts, responses, reasoning, tool arguments and tool results are not collected.
+
Observe an agent
Instrument an agent's native lifecycle or telemetry surface so OpenWorkGraph can measure structural execution: runs, models, tools, handoffs, approvals, failures, timings and coverage. Prompts, responses, reasoning, tool arguments and tool results are not collected.
Status means telemetry observed. The green badge appears only when evidence actually arrives. Connect adds OpenWorkGraph's entries to that agent's own settings file (a backup is written first and your other settings are kept); Disconnect removes only what OpenWorkGraph added.
- ${setupCard('claude_code','Native hooks','Claude Code','Observe lifecycle, tool use, failures, approval requests and subagent handoffs without capturing prompts or tool contents.')}
- ${setupCard('codex','OTel trace','Codex','Use Codex trace export only. OpenWorkGraph does not enable the richer diagnostic log stream.')}
- ${setupCard('openai_agents','Tracing processor','OpenAI Agents SDK','Register OpenWorkGraph as an additional tracing processor; existing SDK tracing remains active.')}
- ${setupCard('otel','Provider-neutral','OpenTelemetry / custom','Send OTLP/HTTP JSON traces or canonical structural events from another agent runtime.')}
+ ${setupCard('claude_code','Hooks + OTel logs','Claude Code','Observe per-request model calls and tokens, tool use, approval requests/decisions, failures and subagent handoffs without capturing prompt or tool content.')}
+ ${setupCard('codex','OTel logs + trace','Codex','Observe structural API, tool, approval and multi-agent events plus trace hierarchy. Content-bearing log options stay disabled.')}
+ ${setupCard('openai_agents','Tracing processor','OpenAI Agents SDK','Register OpenWorkGraph as an additional tracing processor for model, tool, handoff, hierarchy, usage and timing signals.')}
+ ${setupCard('otel','Provider-neutral','OpenTelemetry / custom','Send portable GenAI OTLP/HTTP JSON traces or canonical structural events from another agent runtime.')}
Hooks are asynchronous and fail-open. OTel content logging is explicitly disabled; the OWG ingest path independently strict-allowlists structural fields.
Prefer the Connect button, which adds this for you unless you already have your own [otel] settings. To do it by hand, merge these keys into your existing [otel] section. The dashboard includes only the dedicated local write-only telemetry credential; it does not grant access to your OWG history.
OpenWorkGraph enables trace export only. User prompts, agent responses and guardian assessments remain disabled.
`;
+ body=`
Prefer the Connect button, which adds this for you unless you already have your own [otel] settings. To do it by hand, merge these keys into your existing [otel] section. The dashboard includes only the dedicated local write-only telemetry credential; it does not grant access to your OWG history.
OpenWorkGraph enables structural Codex log events and trace export. User prompts, agent responses and guardian assessments remain disabled, and content-bearing fields are discarded server-side.
`;
+ }
+ table.classList.add('owg-capability-aware');
+ }
+
+ function patchNote(){
+ const note=document.querySelector('#agentCoverageNote');
+ if(!note)return;
+ const base=String(note.textContent||'').replace(/\s*0 = observed zero\..*$/,'').trim();
+ note.textContent=`${base}${base?' ':''}0 = observed zero. — = this integration cannot observe that signal; it does not mean zero runtime activity.`;
+ }
+
+ function patch(){
+ patchScheduled=false;
+ if(!latest)return;
+ const runs=Array.isArray(latest.executions)?latest.executions:[];
+ patchMetrics(runs);patchReports(runs);patchRunRows(runs);patchNote();
+ }
+
+ function schedulePatch(){
+ if(patchScheduled)return;patchScheduled=true;requestAnimationFrame(patch);
+ }
+
+ async function refresh(force=false){
+ if(loading)return;
+ const active=document.querySelector('[role="tab"][aria-selected="true"]')?.dataset.tab;
+ if(!force&&active!=='agents')return;
+ loading=true;
+ try{
+ await window.__owgAuthReady;
+ const response=await fetch('/v1/agent-execution-traces?limit=50&evidence_limit=25000&max_events_per_execution=100',{cache:'no-store'});
+ if(response.ok){latest=await response.json();schedulePatch();}
+ }finally{loading=false;}
+ }
+
+ function install(){
+ document.querySelector('#tab-agents')?.addEventListener('click',()=>setTimeout(()=>refresh(true),0));
+ document.querySelector('#agentRefreshButton')?.addEventListener('click',()=>setTimeout(()=>refresh(true),30));
+ const observer=new MutationObserver(()=>{
+ if(!latest)return;
+ const report=document.querySelector('#agentReportTable');
+ const table=document.querySelector('#agentRunsTable table');
+ if((report&&!report.querySelector('.owg-rich-agent-report'))||(table&&!table.classList.contains('owg-capability-aware')))schedulePatch();
+ });
+ observer.observe(document.body,{childList:true,subtree:true});
+ refresh(false);
+ setInterval(()=>{if(!document.hidden)refresh(false);},5000);
+ }
+
+ if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',install);else install();
+})();
diff --git a/docs/NATIVE_AGENT_ADAPTERS.md b/docs/NATIVE_AGENT_ADAPTERS.md
index 796343a..2761424 100644
--- a/docs/NATIVE_AGENT_ADAPTERS.md
+++ b/docs/NATIVE_AGENT_ADAPTERS.md
@@ -1,42 +1,40 @@
# Native agent adapters
-OpenWorkGraph can observe supported agent runtimes through their native lifecycle/telemetry surfaces and project only structural workflow evidence into the same canonical event store used by desktop and browser capture.
+OpenWorkGraph can observe supported agent runtimes through their native lifecycle and telemetry surfaces, then project only structural execution evidence into the canonical event store used by desktop and browser capture.
-The native adapters are **optional**. Existing OpenWorkGraph behavior is unchanged until an agent runtime is explicitly configured to send events.
+Native adapters are **optional**. Existing OpenWorkGraph behavior is unchanged until an agent runtime is explicitly configured to send events.
## Privacy model
-Native agent payloads are treated as untrusted, potentially content-bearing input.
+Native agent payloads are treated as untrusted, potentially content-bearing input. OpenWorkGraph stores only structural facts such as runtime identity, run/turn linkage, tool name/category, model identifier, token counters, status, duration, approval provenance when available, and parent/child execution relationships.
-OpenWorkGraph stores structural facts such as:
+The adapters deliberately do **not** persist prompts, assistant messages, reasoning/chain-of-thought, tool arguments, tool results/output, transcript paths, working-directory paths, account identifiers, arbitrary OpenTelemetry attributes, arbitrary span names, or raw log bodies.
-- agent/runtime identity;
-- run/conversation ID;
-- tool name and coarse tool category;
-- success/failure status;
-- duration;
-- model identifier when safely available;
-- approval outcome only when native evidence establishes its provenance;
-- trace/run relationships.
+Native integrations reuse the dedicated write-only `.agent_ingest_token`. That token can submit evidence but cannot read work history, exports, summaries, agent reports, or MCP context.
-The adapters deliberately do **not** persist:
+## Capability semantics
-- prompts or assistant messages;
-- chain-of-thought/reasoning content;
-- tool arguments;
-- tool results/output;
-- transcript paths;
-- working-directory paths;
-- account email/account IDs;
-- arbitrary OpenTelemetry attributes or log bodies.
+Agent reports separate **observed count** from **adapter capability**:
-Native integrations reuse the dedicated write-only `.agent_ingest_token`. That token can submit agent evidence but cannot read `/v1/events`, exports, summaries, `/v1/agent-workflows`, or agent execution reports.
+- `observable`: the active adapter is designed to expose the signal; `0` means zero matching events were observed in this evidence window.
+- `partial`: the signal is exposed only on some runtime/span paths.
+- `not_observable`: the integration does not expose that signal; the dashboard renders `—`, never a misleading zero.
+- `unknown`: the integration has not declared a portable capability for that signal.
+
+Missing evidence is never treated as proof that an underlying agent action did not occur, and hidden model reasoning is never claimed as observable.
## Claude Code
-Claude Code exposes lifecycle hooks such as `SessionStart`, `PostToolUse`, `PermissionRequest`, `SubagentStart`, and `SessionEnd`. Command hooks receive their native JSON payload on stdin.
+### Rich observation: hooks + stable OpenTelemetry logs
+
+OpenWorkGraph combines two Claude Code surfaces:
+
+1. **Lifecycle hooks** for session boundaries, completed tools, permission requests, failures, and subagent start/stop.
+2. **Claude Code OpenTelemetry log events** for per-prompt model calls, token usage, model identity, completed tools, permission decisions, retries and subagent completion.
-OpenWorkGraph registers only lifecycle points that provide useful structural evidence:
+The hook surface remains useful as a fail-open lifecycle/fallback channel. The OTel log surface supplies the signals hooks do not expose, especially model calls and token usage.
+
+OpenWorkGraph registers only content-safe hook points:
```text
SessionStart
@@ -50,117 +48,122 @@ SubagentStop
StopFailure
```
-Content-heavy events such as `UserPromptSubmit`, `PreToolUse`, `MessageDisplay`, and `Stop` are intentionally not registered.
-
-`PermissionRequest` is represented as `human_approval_requested` because the hook fires as Claude Code is about to ask the user for permission and the OpenWorkGraph hook itself never supplies a decision. `PermissionDenied` is different: current Claude Code emits it for an automatic permission denial in auto mode, so OpenWorkGraph records it as a denied execution/error rather than falsely claiming that a human denied the action.
+Content-heavy hooks such as `UserPromptSubmit`, `PreToolUse`, `MessageDisplay`, and `Stop` are intentionally not registered.
-### One-click setup
+The Claude OTel adapter recognizes these stable structural events:
-On the dashboard's **Connect** tab, click **Connect** on the Claude Code card. OpenWorkGraph adds its hooks to `~/.claude/settings.json`: it writes a timestamped `settings.json.owg-backup-*` copy first, keeps every other setting and hook, replaces (never duplicates) earlier OpenWorkGraph hooks, and refuses to touch a file that is not valid JSON. **Disconnect** removes only OpenWorkGraph's handlers. Changes apply to new Claude Code sessions. The card still turns green only when telemetry actually arrives.
+```text
+claude_code.user_prompt
+claude_code.api_request
+claude_code.api_error
+claude_code.api_refusal
+claude_code.tool_result
+claude_code.tool_decision
+claude_code.api_retries_exhausted
+claude_code.subagent_completed
+```
-### Generate the settings fragment manually
+`prompt.id` from OTel and `prompt_id` from current Claude Code hooks are used as a structural turn correlation key. This lets a single user request become one observed execution containing its model/tool/handoff sequence instead of collapsing an entire terminal session into one run. Older hook payloads without `prompt_id` safely fall back to the Claude session boundary.
-From the same OpenWorkGraph installation/interpreter that you normally use:
+`SubagentStart` produces a parent `handoff` event plus a child run boundary. `subagent_completed` enriches the parent execution from native telemetry. When hooks and OTel report the same completed tool, the read-side report prefers the richer OTel event so the tool is not double-counted.
-```bash
-python -m adapters.claude_code_hook --print-settings
-```
+`PermissionRequest` is `human_approval_requested`. A Claude OTel `tool_decision` becomes `human_approval_received` only when its decision source is explicitly human (`user_temporary`, `user_permanent`, `user_reject`, or `user_abort`). Automated config/hook decisions are not attributed to a person.
-Merge the printed `hooks` object into either:
+### One-click setup
-```text
-~/.claude/settings.json
-```
+On **Connect → Claude Code**, click **Connect**. OpenWorkGraph adds:
-for your local user, or the relevant project `.claude/settings.json` if you intentionally want project-scoped configuration.
+- its safe asynchronous hooks; and
+- logs-only OTel settings pointing at the local write-only endpoint.
-OpenWorkGraph edits Claude Code settings only when you click **Connect** or **Disconnect** on the dashboard; it never does so in the background.
+It writes a timestamped backup first, preserves every unrelated setting/hook, replaces rather than duplicates older OWG hooks, and refuses to overwrite an existing conflicting telemetry destination. Disconnect removes only OWG hooks and telemetry values that still exactly match what OWG wrote; if the user changed a value afterwards, OWG leaves it alone.
-The generated hook is a single shell command that first `cd`s into the OpenWorkGraph installation directory (the `adapters` package is not installed into the venv, and Claude Code runs hooks from the session's project directory) and then runs the interpreter with `-m adapters.claude_code_hook`. Both paths are shell-quoted, so directories containing spaces such as `Application Support` work. It also sets `"async": true`. OpenWorkGraph is observational and never returns a Claude Code control decision, so the bridge runs in the background rather than adding local HTTP latency to the triggering tool or lifecycle event.
+The managed Claude settings explicitly keep content logging disabled:
-### Failure behavior
+```text
+OTEL_LOG_USER_PROMPTS=0
+OTEL_LOG_ASSISTANT_RESPONSES=0
+OTEL_LOG_TOOL_DETAILS=0
+OTEL_LOG_TOOL_CONTENT=0
+OTEL_LOG_RAW_API_BODIES=0
+```
-The hook bridge is fail-open by design. Invalid JSON, an unavailable OpenWorkGraph server, authentication failure, or an unexpected adapter exception cannot block or approve Claude Code tool execution. Because the generated command hook is asynchronous, Claude Code proceeds without waiting for the OpenWorkGraph bridge to finish.
+This is defense in depth. The server-side Claude adapter independently uses a strict allowlist and never copies content-bearing attributes even if a user changes upstream logging settings later.
-Set:
+The dedicated local endpoint is:
```text
-OWG_AGENT_ADAPTER_DEBUG=1
+POST /agent-ingest/v1/claude-otel
```
-only when debugging. Even in debug mode the hook prints a fixed notice rather than exception details or native payload content.
+The integration uses Claude's logs exporter only. OWG v0.90 does **not** require Claude's beta detailed-trace exporter.
+
+### Hook failure behavior
+
+Hooks remain asynchronous and fail-open. Invalid JSON, an unavailable OpenWorkGraph server, authentication failure, or an adapter exception cannot block or approve Claude Code execution. `OWG_AGENT_ADAPTER_DEBUG=1` prints only a fixed diagnostic notice, never native exception/payload content.
## Codex
-Current Codex builds support OpenTelemetry log, trace, and metrics exporters. OpenWorkGraph's recommended integration uses the **trace exporter only** because Codex's diagnostic log stream may contain richer content such as tool arguments/results, whereas its trace-safe events expose the structural information OpenWorkGraph needs.
+OpenWorkGraph's Codex integration uses **both Codex's structural OTLP log exporter and trace exporter**. The log layer is required for Codex business events such as API requests, completed tools, approval decisions and multi-agent communication; the trace layer supplies native span hierarchy. Content-bearing opt-ins stay disabled, and the server independently strict-allowlists every accepted field.
-OpenWorkGraph currently maps these Codex structural events:
+The adapter maps structural evidence for:
```text
-codex.conversation_starts -> run_started
-codex.tool_result -> tool_call
-codex.api_request -> model_call
+conversation start -> run_started
+API request -> model_call
+completed tool -> tool_call
+user-sourced decision -> human_approval_received
+multi-agent spawn/send -> handoff
```
-The parser also understands `codex.tool_decision` if a compatible relay explicitly sends such a record, but the default configuration below does not enable Codex log export merely to obtain approval events. A tool decision is represented as `human_approval_received` only when Codex explicitly marks its decision source as `user`; decisions resolved by an automated reviewer, or records with no decision provenance, are ignored rather than attributed to a person.
-
-### One-click setup
-
-On the dashboard's **Connect** tab, click **Connect** on the Codex card. OpenWorkGraph appends a clearly marked, managed `[otel]` block to `~/.codex/config.toml` (or `$CODEX_HOME/config.toml`) after writing a timestamped backup, validates the result as TOML, and restarts nothing: Codex picks it up on its next start. If the file already has its own `[otel]` settings, or is not valid TOML, OpenWorkGraph changes nothing and asks you to merge manually. **Disconnect** removes only the managed block.
+Where available, Codex turn IDs become run boundaries and parent span attributes contribute model identity and token counters. Multi-agent communication is conservative: only a structural `spawn` sent to another agent is treated as a handoff; ordinary inter-agent message/result content is ignored.
-### Generate a safe trace-exporter snippet manually
+Some Codex builds have emitted tracing call-site names in `event.name` rather than the semantic event name. The adapter can infer the supported event class from a small set of low-cardinality structural fields, never from record bodies, arguments, output, message content, or arbitrary span names.
-Preview a snippet with a placeholder credential:
+### One-click setup
-```bash
-python -m adapters.codex_config
-```
+**Connect → Codex** appends a clearly marked managed `[otel]` block to `~/.codex/config.toml` (or `$CODEX_HOME/config.toml`) after writing a backup. If the file already has its own `[otel]` settings or invalid TOML, OWG changes nothing and requests manual merge. Disconnect removes only the managed block.
-Or explicitly print a complete snippet containing the local **write-only** agent token:
+The generated configuration explicitly keeps prompt, agent-response and guardian-rationale logging disabled while pointing both the structural log exporter and trace exporter at the same local write-only endpoint:
-```bash
-python -m adapters.codex_config --with-token
+```text
+POST /agent-ingest/v1/codex-otel
```
-Merge those keys into `~/.codex/config.toml`.
+## OpenAI Agents SDK
-If your Codex config already has an `[otel]` section, merge the generated keys into that existing section rather than adding a second `[otel]` table.
+For Python applications using the OpenAI Agents SDK, OWG registers an **additional tracing processor** rather than replacing the application's existing tracing.
-The generated configuration is equivalent to:
+The processor observes structural trace/run boundaries plus:
-```toml
-[otel]
-log_user_prompt = false
-log_agent_responses = false
-log_guardian_assessments = false
-trace_exporter = { otlp-http = { endpoint = "http://127.0.0.1:8787/agent-ingest/v1/codex-otel", headers = { Authorization = "Bearer WRITE_ONLY_TOKEN" }, protocol = "json" } }
-```
+- generation/response/transcription/speech spans as `model_call`;
+- function/MCP spans as `tool_call`;
+- native handoff spans as `handoff`;
+- parent-child span linkage;
+- duration and error state;
+- model identity and token usage where the SDK span exposes them.
-No log exporter or metrics exporter is added by OpenWorkGraph.
+Response span usage/model fields are read directly without serializing response content. Approval request/decision events are currently marked `not_observable` for this adapter rather than shown as zero.
-### Codex ingestion behavior
+Install in the agent application:
-The dedicated endpoint is:
+```python
+from adapters.openai_agents import install_openai_agents_processor
-```text
-POST /agent-ingest/v1/codex-otel
+install_openai_agents_processor()
```
-It accepts OTLP/HTTP JSON under the same 2 MB request bound as the generic agent-ingest path and processes at most 1,000 log/span-event records per request.
-
-The adapter uses an allowlist. OTLP `body`, prompt content, tool arguments/output, account identifiers, arbitrary span attributes, and arbitrary span names are ignored. Stable native request/call identifiers may be hashed solely to make repeated log/trace copies idempotent; the original identifiers are not added as content fields.
+## Generic OpenTelemetry
-## Generic OpenTelemetry trace export
-
-For runtimes that expose ordinary OTLP traces rather than a dedicated OpenWorkGraph adapter, the generic structural endpoint is:
+For runtimes that expose ordinary GenAI OTLP traces rather than a dedicated OWG adapter:
```text
POST /agent-ingest/v1/otel
```
-It currently accepts **OTLP/HTTP JSON only**. It does not accept protobuf bodies and OpenWorkGraph does not currently expose the conventional `/v1/traces` alias.
+OWG accepts OTLP/HTTP JSON and projects a strict portable subset of GenAI semantic conventions. Portable model calls, tool calls, usage, model identity and timings can be observed when emitted. Provider-specific handoffs and human approvals are **not** guessed; those capabilities remain unavailable unless a native adapter provides them.
-For an OpenTelemetry SDK/exporter that supports `http/json`, use the signal-specific standard variables so the endpoint path is used exactly as written:
+Example:
```bash
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="http://127.0.0.1:8787/agent-ingest/v1/otel"
@@ -168,36 +171,16 @@ export OTEL_EXPORTER_OTLP_TRACES_PROTOCOL="http/json"
export OTEL_EXPORTER_OTLP_TRACES_HEADERS="Authorization=Bearer WRITE_ONLY_AGENT_TOKEN"
```
-Use the actual local `.agent_ingest_token` value in place of `WRITE_ONLY_AGENT_TOKEN`.
-
-Do **not** set only `OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:8787`: standard OTLP/HTTP exporters construct a signal path such as `/v1/traces` from the generic base endpoint, and that route is not an OpenWorkGraph ingest route. The trace-specific endpoint above is used as-is by compliant exporters.
-
-`http/json` support is optional across OpenTelemetry SDKs. If a particular runtime supports only `http/protobuf` or gRPC, do not send that binary payload to this JSON endpoint; use a JSON-capable exporter/relay or a native adapter instead.
+Use the signal-specific endpoint exactly as shown. The generic endpoint accepts OTLP/HTTP JSON and **does not accept protobuf bodies**. It also **does not currently expose the conventional `/v1/traces` alias**; use `/agent-ingest/v1/otel` exactly.
## Local-first boundary
-The Claude bridge defaults to:
-
-```text
-http://127.0.0.1:8787
-```
-
-and refuses a non-loopback OpenWorkGraph API URL unless `OWG_AGENT_ALLOW_REMOTE=1` is explicitly set. It can only call `/agent-ingest/*` write routes.
-
-The normal OpenWorkGraph launcher binds the local API to `127.0.0.1`, so the native integrations remain local by default.
+The normal OWG launcher binds the observer API to `127.0.0.1`. Native integrations receive only the write-only agent-ingest credential and remain local by default.
When organization Gateway synchronization is enabled, agent evidence remains local unless the endpoint owner separately opts in with `gateway.local_policy.allow_agent_events: true`; see `docs/AGENT_GATEWAY_SHARING.md`.
-## What this does not do
-
-These adapters do not:
+## What these adapters do not do
-- scrape terminal output or transcript files;
-- alter agent prompts or instructions;
-- inject context into an agent;
-- proxy tool calls;
-- change Claude Code/Codex approval decisions;
-- make OpenWorkGraph a dependency for agent execution;
-- claim to observe internal model reasoning.
+They do not scrape terminal output or transcripts, alter agent prompts/instructions, inject context into an agent, proxy tool calls, change approval decisions, make OWG a dependency for agent execution, or claim access to internal model reasoning.
-They provide structural execution evidence. Retrieval of learned organizational workflow context back into agents remains a separate layer built on top of the shared OpenWorkGraph evidence store.
+They provide provider-neutral **structural execution evidence**. Retrieval of learned work context back into agents is a separate layer over the same canonical OpenWorkGraph store.
diff --git a/mcp_server/agent_tools.py b/mcp_server/agent_tools.py
index 3e43789..492ba78 100644
--- a/mcp_server/agent_tools.py
+++ b/mcp_server/agent_tools.py
@@ -32,7 +32,11 @@ def _run_summary(execution: dict[str, Any]) -> dict[str, Any]:
"run_finish_observed",
"complete_boundary_observed",
"event_count_total",
+ # operation_counts remains for backward compatibility. New clients
+ # should prefer observed_operation_counts because it applies native
+ # adapter de-duplication (for example Claude hooks + OTel).
"operation_counts",
+ "observed_operation_counts",
"tool_category_counts",
"structural_steps",
"structural_steps_truncated",
@@ -40,6 +44,11 @@ def _run_summary(execution: dict[str, Any]) -> dict[str, Any]:
"approval_received_count",
"task_context_linkage_status",
"observed_coverage",
+ "signal_capabilities",
+ "telemetry_sources",
+ "telemetry_depth",
+ "models_observed",
+ "usage_totals",
"derived",
"authoritative",
)
@@ -65,9 +74,10 @@ def get_agent_runs(
) -> dict[str, Any]:
"""Return compact privacy-safe summaries of observed agent executions.
- Results report only structural signals actually present in canonical
- evidence. Missing coverage means not observed, not proof that the runtime
- did not perform the underlying action. Native run/trace/span identifiers,
+ Results report observed structural counts separately from the active
+ adapter's capability to observe each signal. A numeric zero is meaningful
+ only when that signal is observable; not_observable/unknown must not be
+ interpreted as zero underlying activity. Native run/trace/span IDs,
prompts, model-response content, tool arguments/results and hidden
reasoning are not exposed.
"""
@@ -86,6 +96,7 @@ def get_agent_runs(
**{key: value for key, value in result.items() if key != "executions"},
"executions": [_run_summary(item) for item in list(result.get("executions") or [])],
"events_omitted_from_list_view": True,
+ "count_semantics": "prefer observed_operation_counts; interpret counts together with signal_capabilities",
"detail_tool": "get_agent_execution_trace",
}
return core._finish(name, compact)
diff --git a/mcpb/manifest.json b/mcpb/manifest.json
index f763aaa..96affe8 100644
--- a/mcpb/manifest.json
+++ b/mcpb/manifest.json
@@ -2,7 +2,7 @@
"manifest_version": "0.3",
"name": "openworkgraph-local",
"display_name": "OpenWorkGraph",
- "version": "0.89.0",
+ "version": "0.90.0",
"description": "Connect Claude Desktop to the compact local OpenWorkGraph context surface.",
"long_description": "Uses the OpenWorkGraph installation already running on this computer. New connections expose a compact read-oriented tool surface while the legacy 24-tool stdio entrypoint remains available for existing configurations. Workflow evidence remains in the local OpenWorkGraph store until Claude requests it through MCP. AI access can be turned off instantly from the OpenWorkGraph dashboard.",
"author": {
diff --git a/pyproject.toml b/pyproject.toml
index 61cf833..b69b534 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "workflow-observer"
-version = "0.89.0"
+version = "0.90.0"
description = "Local-first work evidence, self-hosted organizational context gateway, REST API, and MCP access."
requires-python = ">=3.11"
license = {file = "LICENSE"}
diff --git a/server/agent_config_writer.py b/server/agent_config_writer.py
index 2f21f8e..8c8751e 100644
--- a/server/agent_config_writer.py
+++ b/server/agent_config_writer.py
@@ -70,7 +70,7 @@ def _atomic_write(path: Path, text: str) -> None:
raise
-# --- Claude Code: hooks in ~/.claude/settings.json ---------------------------
+# --- Claude Code: hooks + logs-only OTel in ~/.claude/settings.json ----------
def _load_claude(path: Path) -> dict[str, Any]:
if not path.exists():
@@ -87,6 +87,9 @@ def _load_claude(path: Path) -> dict[str, Any]:
hooks = data.get("hooks")
if hooks is not None and not isinstance(hooks, dict):
raise ConfigConflict(f"'hooks' in {path} has an unexpected shape; use manual setup")
+ env = data.get("env")
+ if env is not None and not isinstance(env, dict):
+ raise ConfigConflict(f"'env' in {path} has an unexpected shape; use manual setup")
return data
@@ -125,24 +128,92 @@ def _strip_owg_hooks(data: dict[str, Any]) -> int:
return removed
-def claude_status() -> dict[str, Any]:
- path = claude_settings_path()
- try:
- data = _load_claude(path)
- except ConfigConflict as exc:
- return {"configured": False, "path": str(path), "error": str(exc)}
- configured = any(
+def _claude_hooks_configured(data: dict[str, Any]) -> bool:
+ return any(
_is_owg_handler(h)
for groups in (data.get("hooks") or {}).values() if isinstance(groups, list)
for group in groups if isinstance(group, dict)
for h in (group.get("hooks") or []) if isinstance(group.get("hooks"), list)
)
- return {"configured": configured, "path": str(path)}
-def claude_connect(fragment_factory: Callable[[], dict]) -> dict[str, Any]:
+def _claude_env_matches(data: dict[str, Any], managed_env: dict[str, str] | None) -> bool:
+ if not managed_env:
+ return True
+ existing = data.get("env") if isinstance(data.get("env"), dict) else {}
+ return all(str(existing.get(key) or "") == str(value) for key, value in managed_env.items())
+
+
+def _validate_claude_env_conflicts(data: dict[str, Any], managed_env: dict[str, str]) -> None:
+ existing = data.get("env") if isinstance(data.get("env"), dict) else {}
+ conflicts = [
+ key for key, desired in managed_env.items()
+ if key in existing and str(existing.get(key)) != str(desired)
+ ]
+ if conflicts:
+ joined = ", ".join(sorted(conflicts))
+ raise ConfigConflict(
+ f"Claude Code already has different telemetry settings for {joined}. "
+ "OpenWorkGraph will not overwrite them; use manual setup or an OTLP collector/tee."
+ )
+
+
+def _merge_claude_env(data: dict[str, Any], managed_env: dict[str, str]) -> None:
+ if not managed_env:
+ return
+ env = data.setdefault("env", {})
+ if not isinstance(env, dict):
+ raise ConfigConflict("Claude Code 'env' has an unexpected shape; use manual setup")
+ for key, value in managed_env.items():
+ env[key] = str(value)
+
+
+def _strip_matching_claude_env(data: dict[str, Any], managed_env: dict[str, str]) -> int:
+ """Remove only OWG values that still exactly match what OWG would write.
+
+ If the user changed a value after connecting, leave it untouched. This makes
+ Disconnect reversible without claiming ownership of unrelated telemetry keys.
+ """
+ env = data.get("env")
+ if not isinstance(env, dict):
+ return 0
+ removed = 0
+ for key, expected in managed_env.items():
+ if key in env and str(env.get(key)) == str(expected):
+ del env[key]
+ removed += 1
+ if not env:
+ data.pop("env", None)
+ return removed
+
+
+def claude_status(managed_env: dict[str, str] | None = None) -> dict[str, Any]:
+ path = claude_settings_path()
+ try:
+ data = _load_claude(path)
+ except ConfigConflict as exc:
+ return {"configured": False, "path": str(path), "error": str(exc)}
+ hooks_configured = _claude_hooks_configured(data)
+ telemetry_configured = _claude_env_matches(data, managed_env)
+ return {
+ "configured": hooks_configured and telemetry_configured,
+ "hooks_configured": hooks_configured,
+ "telemetry_configured": telemetry_configured,
+ "path": str(path),
+ }
+
+
+def claude_connect(
+ fragment_factory: Callable[[], dict],
+ managed_env: dict[str, str] | None = None,
+) -> dict[str, Any]:
path = claude_settings_path()
data = _load_claude(path)
+ desired_env = dict(managed_env or {})
+ # Fail before changing hooks if a user already owns a conflicting telemetry
+ # destination or privacy flag.
+ _validate_claude_env_conflicts(data, desired_env)
+
# Replace, never duplicate: older OpenWorkGraph handlers (e.g. the pre-0.89
# command/args form) are removed before the current ones are added.
_strip_owg_hooks(data)
@@ -152,22 +223,38 @@ def claude_connect(fragment_factory: Callable[[], dict]) -> dict[str, Any]:
if not isinstance(existing, list):
raise ConfigConflict(f"hooks.{event} in {path} has an unexpected shape; use manual setup")
existing.extend(groups)
+ _merge_claude_env(data, desired_env)
+
backup = _backup(path)
_atomic_write(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
- return {"configured": True, "path": str(path), "backup": backup,
- "note": "Takes effect in new Claude Code sessions."}
+ return {
+ "configured": True,
+ "hooks_configured": True,
+ "telemetry_configured": bool(desired_env),
+ "path": str(path),
+ "backup": backup,
+ "note": "Takes effect in new Claude Code sessions.",
+ }
-def claude_disconnect() -> dict[str, Any]:
+def claude_disconnect(managed_env: dict[str, str] | None = None) -> dict[str, Any]:
path = claude_settings_path()
if not path.exists():
return {"configured": False, "path": str(path), "backup": None}
data = _load_claude(path)
- if not _strip_owg_hooks(data):
+ removed_hooks = _strip_owg_hooks(data)
+ removed_env = _strip_matching_claude_env(data, dict(managed_env or {}))
+ if not (removed_hooks or removed_env):
return {"configured": False, "path": str(path), "backup": None}
backup = _backup(path)
_atomic_write(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n")
- return {"configured": False, "path": str(path), "backup": backup}
+ return {
+ "configured": False,
+ "hooks_configured": False,
+ "telemetry_configured": False,
+ "path": str(path),
+ "backup": backup,
+ }
# --- Codex: [otel] in ~/.codex/config.toml ------------------------------------
@@ -198,7 +285,26 @@ def _parse_toml(text: str, path: Path) -> dict[str, Any]:
def codex_status() -> dict[str, Any]:
path = codex_config_path()
text = path.read_text(encoding="utf-8") if path.exists() else ""
- return {"configured": CODEX_BLOCK_START in text, "path": str(path)}
+ managed = CODEX_BLOCK_START in text
+ if not managed:
+ return {"configured": False, "partial_configured": False, "path": str(path)}
+ try:
+ otel = _parse_toml(text, path).get("otel") or {}
+ except ConfigConflict as exc:
+ return {"configured": False, "partial_configured": True, "path": str(path), "error": str(exc)}
+ rich = (
+ isinstance(otel, dict)
+ and "exporter" in otel
+ and "trace_exporter" in otel
+ and otel.get("log_user_prompt") is False
+ and otel.get("log_agent_responses") is False
+ and otel.get("log_guardian_assessments") is False
+ )
+ return {
+ "configured": bool(rich),
+ "partial_configured": not bool(rich),
+ "path": str(path),
+ }
def codex_connect(snippet: str) -> dict[str, Any]:
@@ -214,12 +320,18 @@ def codex_connect(snippet: str) -> dict[str, Any]:
body = remainder.rstrip("\n")
updated = (body + "\n\n" if body else "") + block
parsed = _parse_toml(updated, path)
- if "trace_exporter" not in (parsed.get("otel") or {}):
+ otel = parsed.get("otel") or {}
+ if not isinstance(otel, dict) or "exporter" not in otel or "trace_exporter" not in otel:
raise ConfigConflict("Generated Codex configuration did not validate; use manual setup")
backup = _backup(path)
_atomic_write(path, updated)
- return {"configured": True, "path": str(path), "backup": backup,
- "note": "Takes effect the next time Codex starts."}
+ return {
+ "configured": True,
+ "partial_configured": False,
+ "path": str(path),
+ "backup": backup,
+ "note": "Takes effect the next time Codex starts.",
+ }
def codex_disconnect() -> dict[str, Any]:
@@ -233,4 +345,4 @@ def codex_disconnect() -> dict[str, Any]:
_parse_toml(remainder, path)
backup = _backup(path)
_atomic_write(path, remainder)
- return {"configured": False, "path": str(path), "backup": backup}
+ return {"configured": False, "partial_configured": False, "path": str(path), "backup": backup}
diff --git a/server/agent_dashboard_control_plane.py b/server/agent_dashboard_control_plane.py
index 6b74d1c..5a3fbd7 100644
--- a/server/agent_dashboard_control_plane.py
+++ b/server/agent_dashboard_control_plane.py
@@ -10,16 +10,15 @@
treats an integration as active only when structural telemetry is observed.
"""
-import json
import shlex
import sys
-from pathlib import Path
from typing import Any
from fastapi import Request
from fastapi.responses import HTMLResponse, JSONResponse, Response
-from adapters.claude_code_hook import settings_fragment
+from adapters.claude_code_hook import settings_fragment as claude_hook_settings
+from adapters.claude_code_otel import env_settings as claude_otel_env
from adapters.codex_config import config_snippet
from server import agent_config_writer as writer
from server.agent_auth import ensure_agent_ingest_token
@@ -28,13 +27,28 @@
DASHBOARD_SCRIPT = ROOT / "dashboard" / "agent_control_plane.js"
+OBSERVABILITY_SCRIPT = ROOT / "dashboard" / "agent_observability_v090.js"
_SCRIPT_MARKER = ''
+_OBSERVABILITY_MARKER = ''
def _base_url(request: Request) -> str:
return str(request.base_url).rstrip("/")
+def _claude_env(request: Request) -> dict[str, str]:
+ return claude_otel_env(
+ token=ensure_agent_ingest_token(),
+ base_url=_base_url(request),
+ )
+
+
+def _claude_settings(request: Request) -> dict[str, Any]:
+ settings = dict(claude_hook_settings())
+ settings["env"] = _claude_env(request)
+ return settings
+
+
def agent_setup_payload(request: Request) -> dict[str, Any]:
"""Return reviewable setup material for the local dashboard owner.
@@ -78,10 +92,12 @@ def agent_setup_payload(request: Request) -> dict[str, Any]:
"integrations": {
"claude_code": {
"label": "Claude Code",
- "method": "native_lifecycle_hooks",
+ "method": "native_hooks_plus_otel_logs",
"command": f"cd {shlex.quote(str(ROOT))} && {shlex.quote(sys.executable)} -m adapters.claude_code_hook --print-settings",
"one_click": True,
- "settings": settings_fragment(),
+ "settings": _claude_settings(request),
+ "otel_endpoint": f"{base_url}/agent-ingest/v1/claude-otel",
+ "telemetry_depth": "rich_structural",
"events": [
"SessionStart",
"SessionEnd",
@@ -92,26 +108,39 @@ def agent_setup_payload(request: Request) -> dict[str, Any]:
"SubagentStart",
"SubagentStop",
"StopFailure",
+ "claude_code.user_prompt",
+ "claude_code.api_request",
+ "claude_code.api_error",
+ "claude_code.api_refusal",
+ "claude_code.tool_result",
+ "claude_code.tool_decision",
+ "claude_code.api_retries_exhausted",
+ "claude_code.subagent_completed",
],
- "instructions": "Click Connect to add these hooks to ~/.claude/settings.json (other settings are preserved and a backup is written), or merge the hooks object manually into ~/.claude/settings.json or an intentional project-scoped .claude/settings.json.",
+ "instructions": "Click Connect to add OpenWorkGraph's hooks and logs-only OpenTelemetry settings to ~/.claude/settings.json. Other settings are preserved, conflicting existing telemetry is never overwritten, and a backup is written. Manual setup can merge the shown hooks and env objects instead.",
"failure_mode": "fail_open_async",
+ "content_logging_enabled": False,
},
"codex": {
"label": "Codex",
- "method": "otel_http_json_trace_export",
+ "method": "otel_http_json_logs_and_trace_export",
"command": f"cd {shlex.quote(str(ROOT))} && {shlex.quote(sys.executable)} -m adapters.codex_config --with-token",
"one_click": True,
"config": config_snippet(token=token, base_url=base_url),
"endpoint": f"{base_url}/agent-ingest/v1/codex-otel",
- "instructions": "Click Connect to add a managed [otel] block to ~/.codex/config.toml (refused if you already have your own [otel] settings), or merge these keys manually into the existing [otel] section. Do not create a second [otel] table.",
- "logs_enabled_by_owg": False,
+ "instructions": "Click Connect to add a managed [otel] block that sends structural Codex log events and traces to the local write-only endpoint (refused if you already have your own [otel] settings). User-prompt, agent-response and guardian-rationale content logging remains disabled.",
+ "logs_enabled_by_owg": True,
+ "structural_logs_enabled_by_owg": True,
+ "content_logging_enabled": False,
+ "telemetry_depth": "rich_native_events_plus_trace",
},
"openai_agents": {
"label": "OpenAI Agents SDK",
"method": "additional_tracing_processor",
"python": "from adapters.openai_agents import install_openai_agents_processor\n\ninstall_openai_agents_processor()",
- "instructions": "Register OpenWorkGraph as an additional tracing processor in the agent application. Existing SDK tracing remains in place.",
+ "instructions": "Register OpenWorkGraph as an additional tracing processor in the agent application. Existing SDK tracing remains in place. OWG projects model spans, tools, handoffs, hierarchy, usage when the SDK exposes it, timings and errors without serializing span content.",
"one_click": False,
+ "telemetry_depth": "native_trace",
},
"otel": {
"label": "Generic OpenTelemetry",
@@ -119,7 +148,8 @@ def agent_setup_payload(request: Request) -> dict[str, Any]:
"endpoint": otel_endpoint,
"posix": posix_otel,
"powershell": powershell_otel,
- "instructions": "Use the signal-specific traces endpoint exactly as shown. OpenWorkGraph currently accepts OTLP/HTTP JSON here, not protobuf or gRPC.",
+ "instructions": "Use the signal-specific traces endpoint exactly as shown. OpenWorkGraph currently accepts OTLP/HTTP JSON here, not protobuf or gRPC. Portable GenAI model/tool spans are supported; provider-specific handoff or approval signals require a native adapter.",
+ "telemetry_depth": "portable_genai",
},
"custom": {
"label": "Custom structural agent",
@@ -140,22 +170,22 @@ def get_agent_setup(request: Request) -> JSONResponse:
_ONE_CLICK = {
"claude_code": {
- "status": writer.claude_status,
- "connect": lambda request: writer.claude_connect(settings_fragment),
- "disconnect": writer.claude_disconnect,
+ "status": lambda request: writer.claude_status(_claude_env(request)),
+ "connect": lambda request: writer.claude_connect(claude_hook_settings, _claude_env(request)),
+ "disconnect": lambda request: writer.claude_disconnect(_claude_env(request)),
},
"codex": {
- "status": writer.codex_status,
+ "status": lambda request: writer.codex_status(),
"connect": lambda request: writer.codex_connect(
config_snippet(token=ensure_agent_ingest_token(), base_url=_base_url(request))
),
- "disconnect": writer.codex_disconnect,
+ "disconnect": lambda request: writer.codex_disconnect(),
},
}
-def get_agent_config_status() -> JSONResponse:
- status = {kind: ops["status"]() for kind, ops in _ONE_CLICK.items()}
+def get_agent_config_status(request: Request) -> JSONResponse:
+ status = {kind: ops["status"](request) for kind, ops in _ONE_CLICK.items()}
return JSONResponse({"integrations": status}, headers={"Cache-Control": "no-store"})
@@ -170,7 +200,7 @@ async def change_agent_config(request: Request) -> JSONResponse:
if ops is None or action not in {"connect", "disconnect"}:
return JSONResponse({"detail": "unsupported agent or action"}, status_code=400)
try:
- result = ops["connect"](request) if action == "connect" else ops["disconnect"]()
+ result = ops[action](request)
except writer.ConfigConflict as exc:
return JSONResponse({"detail": str(exc), "manual_setup_required": True}, status_code=409)
except OSError:
@@ -185,6 +215,10 @@ def agent_control_plane_script() -> Response:
return Response(DASHBOARD_SCRIPT.read_text(encoding="utf-8"), media_type="application/javascript")
+def agent_observability_script() -> Response:
+ return Response(OBSERVABILITY_SCRIPT.read_text(encoding="utf-8"), media_type="application/javascript")
+
+
async def _inject_agent_control_plane(request: Request, call_next):
response = await call_next(request)
if request.method.upper() != "GET" or request.url.path != "/" or response.status_code != 200:
@@ -202,6 +236,8 @@ async def _inject_agent_control_plane(request: Request, call_next):
return response
if _SCRIPT_MARKER not in text:
text = text.replace("