# Evigauge — complete developer documentation Source: https://evigauge.com/docs · Index: https://evigauge.com/llms.txt The product is Evigauge. Every identifier in code stays `opexia` (`opexia-trace`, `x-opexia-api-key`, `opexia.*` span attributes) — the rename was a brand change only. --- # SDK Instrument an agent so Evigauge can reconstruct why it did what it did. # Evigauge SDK Reference Instrument your agents so Evigauge can reconstruct **why** they did what they did. This document covers the Python SDK (`opexia-trace`), the TypeScript / OpenTelemetry route, framework adapters, and the `opexia` CLI. **Companion documents:** [REST API Reference](./evigauge-rest-api.md) · [MCP & Claude Code Plugin](./evigauge-mcp-and-plugin.md) --- ## Naming: Evigauge vs `opexia` The product is **Evigauge** (formerly **OpexIA**). The rename is a brand change only. **Every identifier in code stays `opexia`.** Renaming any of these breaks installation, imports, auth, or span validation: | Surface | Value — do not rename | |---|---| | PyPI package | `opexia-trace` | | Python import | `import opexia.trace` | | Console scripts | `opexia`, `pxcore-mcp`, `pxcore-proxy` | | Auth header | `x-opexia-api-key` | | Ingest host | `ingest.opexia.dev` | | Span attributes | `opexia.*` | | Env vars | `OPEXIA_API_KEY`, `OPEXIA_TRANSPORT`, … | | Local WAL | `.opexia-wal/spans.jsonl` | | Config files | `.opexia/shipcheck.yml`, `.opexia/agentmap.lock` | --- ## Language support at a glance | | Python | TypeScript / JavaScript | |---|---|---| | **Package** | `pip install opexia-trace` | *No Evigauge npm package* — use OpenTelemetry JS | | **Setup** | `opexia.trace.init(...)` | OTLP/JSON exporter + attribute helpers | | **Decorator / wrapper** | `@observe`, `ReasoningTrace` | `withOpexiaTrace()` | | **Auto-instrumentation** | ✅ patches LLM clients | ➖ manual, or OTel auto-instrumentation | | **Crash-safe WAL** | ✅ built in | ➖ rely on `BatchSpanProcessor` | | **CLI (`shipcheck`, `audit`, `live`)** | ✅ | ✅ (the CLI is language-agnostic) | > **There is no `@opexia/trace` npm package**, and none is planned — TypeScript > instruments through native OpenTelemetry JS exporting OTLP/JSON. The helper > module in [TypeScript setup](#typescript--javascript) does the rest. # Python SDK ## Installation ```bash pip install opexia-trace ``` ### Optional extras | Extra | Install | Pulls | Use when | |---|---|---|---| | `live` | `pip install 'opexia-trace[live]'` | `rich` | You want the `opexia live` terminal dashboard | | `shipcheck` | `pip install 'opexia-trace[shipcheck]'` | `pyyaml` | Your Ship Check policy is YAML (JSON needs no extra) | | `litellm` | `pip install 'opexia-trace[litellm]'` | `litellm` | You route models through LiteLLM | | `openllmetry` | `pip install 'opexia-trace[openllmetry]'` | `traceloop-sdk` | You already use OpenLLMetry | | `adapters-langchain` | `pip install 'opexia-trace[adapters-langchain]'` | `langchain-core` | You use LangChain | | `adapters-microsoft` | `pip install 'opexia-trace[adapters-microsoft]'` | *(none)* | You use Microsoft agent frameworks | Core dependencies installed always: `opentelemetry-api`/`-sdk`/`-exporter-otlp` (1.x), `pydantic` 2.x, `tiktoken`, `httpx`. --- ## `init()` Call once at process startup, before anything you want traced. ```python import opexia.trace opexia.trace.init( org_id="acme", workspace_id="3f2b1c8e-9a4d-4f11-b7c2-1e5a8d3f6b90", project_id="checkout-agent", backend_url="https://ingest.opexia.dev", api_key="opx_live_xxxxxxxxxxxxxxxxxxxx", transport="direct", ) ``` ### Parameters All parameters are **keyword-only**. | Name | Type | Required | Default | Description | |---|---|---|---|---| | `org_id` | `str` | ✅ | — | Organization identifier | | `workspace_id` | `str` | ✅ | — | Workspace **UUID** the dashboard reads | | `project_id` | `str` | ✅ | — | Logical project/service name; groups traces | | `backend_url` | `str` | ✅ | — | Ingest base URL — `https://ingest.opexia.dev` | | `api_key` | `str` | ✅ | — | Workspace API key (`opx_live_…`) | | `collector_endpoint` | `str` | ➖ | `http://localhost:4317` | OTLP/gRPC collector. Used only when `transport="collector"` | | `wal_path` | `str` | ➖ | `.opexia-wal/spans.jsonl` | Crash-recovery write-ahead log | | `auto_instrument` | `bool` | ➖ | `True` | Patch known LLM client methods automatically | | `fail_open` | `bool` | ➖ | `False` | If `True`, init failures log instead of raising | | `sampler_rate` | `float` | ➖ | `1.0` | Fraction of traces sampled, `0.0`–`1.0` | | `transport` | `str \| None` | ➖ | `"collector"` | `"collector"` or `"direct"` — see below | ### Choosing a transport | | `transport="direct"` | `transport="collector"` (default) | |---|---|---| | **Path** | Your process → `ingest.opexia.dev` over HTTP/JSON | Your process → local collector (gRPC) → Evigauge | | **Needs a collector** | No | Yes | | **Uses `collector_endpoint`** | No — ignored | Yes | | **Best for** | Serverless, containers, quick starts | Existing OTel infrastructure, local buffering, fan-out | Override without a code change: ```bash export OPEXIA_TRANSPORT=direct ``` The explicit `transport=` argument always wins over the environment variable. > **Collector users:** your `otlphttp` exporter **must** set `encoding: json`. > The protobuf default returns `415` from Evigauge ingest, and it fails silently > from the collector's point of view — the single most common cause of "no spans > arriving". > > ```yaml > exporters: > otlphttp: > endpoint: https://ingest.opexia.dev > encoding: json # REQUIRED > headers: > x-opexia-api-key: opx_live_xxxxxxxxxxxxxxxxxxxx > ``` ### What `init()` does 1. Stores config, reachable via `get_config()`. 2. Fetches the workspace `capture_text` flag from `GET /v1/workspaces/{ws}/sdk-config`. **Fail-closed** — if the lookup fails, `capture_text` is `False` and no text bodies are sent. 3. Builds the exporter for the chosen transport. 4. **Replays any WAL from a previous crashed process**, then installs `DurableBatchSpanProcessor`. 5. Registers auto-instrumentation when `auto_instrument=True`. Steps 3–5 are wrapped by `fail_open`. `set_config()` runs first and *outside* that guard, so `get_config()` works even in a degraded state. ### `fail_open` | Value | Behaviour on init failure | |---|---| | `False` (default) | Raises. Your process will not start mis-instrumented. | | `True` | Logs the exception and continues, untraced. | Use `fail_open=True` in production when telemetry must never take down the service; keep `False` in development so misconfiguration is loud. ### Environment-driven setup ```python import os, opexia.trace opexia.trace.init( org_id=os.environ["OPEXIA_ORG_ID"], workspace_id=os.environ["OPEXIA_WORKSPACE_ID"], project_id=os.environ.get("OPEXIA_PROJECT_ID", "default"), backend_url=os.environ.get("OPEXIA_INGEST_URL", "https://ingest.opexia.dev"), api_key=os.environ["OPEXIA_API_KEY"], transport=os.environ.get("OPEXIA_TRANSPORT", "direct"), fail_open=os.environ.get("ENV") == "production", ) ``` ```bash # .env OPEXIA_ORG_ID=acme OPEXIA_WORKSPACE_ID=3f2b1c8e-9a4d-4f11-b7c2-1e5a8d3f6b90 OPEXIA_PROJECT_ID=checkout-agent OPEXIA_INGEST_URL=https://ingest.opexia.dev OPEXIA_API_KEY=opx_live_xxxxxxxxxxxxxxxxxxxx OPEXIA_TRANSPORT=direct ``` --- ## `@observe` Decorate a function to emit one span per call. Works on both sync and async functions — the decorator detects which and wraps accordingly. ```python from opexia.trace import observe @observe(reasoning_role="retrieval", node_type="retrieval") def fetch_order(order_id: str) -> dict: return db.get(order_id) @observe(reasoning_role="synthesis", node_type="agent", name="compose_answer") async def compose(ctx: dict) -> str: return await llm.complete(ctx) ``` ### Parameters Keyword-only. | Name | Type | Required | Default | Description | |---|---|---|---|---| | `reasoning_role` | `str \| None` | ➖ | `None` | What this step is *for* — see [enums](#enumerations) | | `node_type` | `str \| None` | ➖ | `None` | What kind of component it is — see [enums](#enumerations) | | `end_user` | `str \| None` | ➖ | `None` | End-user identifier for per-user attribution | | `name` | `str \| None` | ➖ | `fn.__qualname__` | Span name override | ### Error handling An exception inside the wrapped function is recorded on the span (`record_exception`), the span status is set to `ERROR`, and **the exception is re-raised unchanged**. Instrumentation never swallows your errors. --- ## `ReasoningTrace` A context manager for a logical unit of reasoning, giving you a node object to attach structured evidence to. This is what makes reconstruction possible — plain spans record *what* happened; these records capture *why*. ```python from opexia.trace import ReasoningTrace with ReasoningTrace("answer_customer", reasoning_role="synthesis", node_type="agent", end_user="user_8412") as node: node.record_decision( selected="lookup_order", scores={"lookup_order": 0.88, "search_kb": 0.31}, alternatives=[{"name": "search_kb", "why_not": "no order id in message"}], rules_fired=["order_id_present"], ) node.record_sources( consulted=["https://docs.internal/returns", "https://docs.internal/shipping"], used=["https://docs.internal/returns"], dropped=["https://docs.internal/shipping"], scores={"https://docs.internal/returns": 0.93}, ) node.record_cost( model="claude-opus-4-6", input_tokens=2_140, output_tokens=310, ) ``` ### Constructor | Name | Type | Required | Default | Description | |---|---|---|---|---| | `name` | `str` | ✅ | — | Span name (positional) | | `domain` | `str \| None` | ➖ | `None` | Free-form label. **Not indexed** — not queryable server-side. | | `reasoning_role` | `str \| None` | ➖ | `None` | See [enums](#enumerations) | | `node_type` | `str \| None` | ➖ | `None` | See [enums](#enumerations) | | `end_user` | `str \| None` | ➖ | `None` | End-user identifier | ### Node methods #### `record_decision(*, selected, scores=None, alternatives=None, rules_fired=None)` | Param | Type | Required | Description | |---|---|---|---| | `selected` | `str` | ✅ | The option chosen | | `scores` | `dict[str, float]` | ➖ | Score per option | | `alternatives` | `list[dict]` | ➖ | Options not taken, with rationale | | `rules_fired` | `list[str]` | ➖ | Deterministic rules that applied | Feeds the [decision-trace](./evigauge-rest-api.md#trace-decision-trace) engine. #### `record_sources(*, consulted, used=None, dropped=None, scores=None)` | Param | Type | Required | Description | |---|---|---|---| | `consulted` | `list[str]` | ✅ | Every source retrieved — **string IDs or URLs, not objects** | | `used` | `list[str]` | ➖ | The subset actually cited. Drives `source_authority`. | | `dropped` | `list[str]` | ➖ | Retrieved and deliberately discarded | | `scores` | `dict[str, float]` | ➖ | Relevance per source, `0`–`1` | Feeds the [sources matrix](./evigauge-rest-api.md#trace-sources-matrix). The `consulted` vs `used` split is what surfaces "retrieved a good source and ignored it". #### `record_cost(*, model, input_tokens, output_tokens, ...)` | Param | Type | Required | Default | Description | |---|---|---|---|---| | `model` | `str` | ✅ | — | Model identifier | | `input_tokens` | `int` | ✅ | — | Prompt tokens | | `output_tokens` | `int` | ✅ | — | Completion tokens | | `reasoning_tokens` | `int` | ➖ | `0` | Extended-thinking tokens | | `tool_call_count` | `int` | ➖ | `0` | Tool invocations | | `injected_context_tokens` | `int` | ➖ | `0` | Tokens placed into context | | `used_context_tokens` | `int` | ➖ | `0` | Tokens the model actually drew on | USD and the pricing version are computed for you. The `injected` vs `used` context split powers context-optimization recommendations. #### `record_reliability_inputs(**kwargs)` Free-form signals for the reliability scorer. #### `record_plan(plan)` Records the agent's plan; feeds the [decomposition](./evigauge-rest-api.md#trace-decomposition) engine. #### `record_end_user(end_user)` Sets `opexia.end_user` after construction. #### `subnode(...)` Creates a nested node under the current one. --- ## Cost estimation ```python from opexia.trace import estimate_cost_usd, pricing_version usd = estimate_cost_usd(model="claude-opus-4-6", input_tokens=2140, output_tokens=310) print(f"${usd:.4f} (pricing {pricing_version()})") ``` Use this for local pre-flight estimates. Server-side cost on stored traces is computed independently, so a stored figure never shifts because your SDK version changed. --- ## Enumerations Both are **server-validated** — any other value fails validation and the span is dead-lettered. **`reasoning_role`** — what the step is *for*: `decomposer` · `research` · `analysis` · `critique` · `synthesis` · `arbiter` · `retrieval` · `guardrail` · `post_process` **`node_type`** — what kind of component it is: `decomposer` · `classifier` · `agent` · `guardrail` · `post_process` · `retrieval` --- ## Span attribute reference The wire contract. Violations are **silently dead-lettered at ingest** — the span disappears rather than erroring, so get these right. ### Required envelope | Attribute | Type | Description | |---|---|---| | `opexia.schema_version` | string | Currently `"1.0"` | | `opexia.org_id` | string | Organization identifier | | `opexia.workspace_id` | string | Workspace UUID | | `opexia.project_id` | string | Project/service name | | `opexia.trace_id` | string | Must equal the OTel trace ID | ### Optional attributes | Attribute | Type | Description | |---|---|---| | `opexia.user_id` | string | Internal user identifier | | `opexia.end_user` | string | End-user identifier for per-user attribution | | `opexia.reasoning_role` | enum | See above | | `opexia.node_type` | enum | See above | | `opexia.parent_reasoning_id` | string | Parent reasoning node | | `opexia.decision` | **JSON string** | `{rules_fired, scores, selected, alternatives}` | | `opexia.sources` | **JSON string** | `{consulted, used, dropped, scores}` | | `opexia.query_text` | **plain string** | The user query / the prompt. ~16 KB cap. | | `opexia.outcome_text` | **plain string** | The final answer. ~16 KB cap. | | `opexia.prompt_id` | string | Pins prompt identity for Ship Check | | `opexia.prompt_label` | string | Display name | | `opexia.prompt_version` | string | Client-computed version | | `opexia.cost.usd` | float | Flat scalar | | `opexia.cost.model_pricing_version` | string | Pricing table version | Standard OTel GenAI semantic conventions are also read: `gen_ai.system`, `gen_ai.request.model`, `gen_ai.operation.name`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.response.finish_reason`. ### The four rules 1. **Compound fields are JSON strings.** `opexia.decision` and `opexia.sources` must be `json.dumps(...)`. A nested object becomes an OTLP `kvlistValue` and kills the whole span. 2. **`consulted` / `used` / `dropped` are `string[]`** — URLs or stable IDs, never `{id, title}` objects. 3. **`query_text` / `outcome_text` are plain strings.** Never `json.dumps` them. 4. **Only documented `opexia.*` keys exist** (the schema is `extra="forbid"`). Any other `opexia.*` key kills the span. Note the keys are **flat**: `opexia.prompt_id`, *not* `opexia.prompt.id`. > **Where to put text.** Engines take the **first non-empty** `query_text` and the > **last non-empty** `outcome_text` in a trace. Put the query on the earliest span > and the outcome on the latest. > **On an LLM span, `query_text` is the prompt** — it is what Evigauge versions, > evaluates, and measures a cacheable prefix from. When rendering messages into > it, **keep role boundaries**: > > ```python > # ✅ Correct — role boundaries preserved > query_text = "\n\n".join(f"{m['role']}:\n{m['content']}" for m in messages) > > # ❌ Wrong — no boundary, so every call hashes as its own "prompt" and the > # Prompts page fills with thousands of one-call rows. > query_text = " ".join(m["content"] for m in messages) > ``` --- ## Durability ### Write-ahead log Spans are written to `wal_path` (default `.opexia-wal/spans.jsonl`) before export. If the process crashes, the next `init()` replays the WAL and logs how many entries it recovered. Replay happens **before** the span processor is constructed — required on Windows, where renaming a file with an open handle raises `PermissionError` (WinError 32). Mount `.opexia-wal/` on a writable volume in containers. On a fully read-only filesystem, point `wal_path` at a writable temp directory. ### Sampling ```python opexia.trace.init(..., sampler_rate=0.1) # 10% of traces ``` Sampling is **per trace**, not per span — a sampled trace keeps all its spans, so reconstruction still works. Sampling a fraction of spans would produce broken traces. --- ## Complete Python example ```python """Minimal end-to-end instrumented agent.""" import os import opexia.trace from opexia.trace import ReasoningTrace, observe opexia.trace.init( org_id=os.environ["OPEXIA_ORG_ID"], workspace_id=os.environ["OPEXIA_WORKSPACE_ID"], project_id="checkout-agent", backend_url="https://ingest.opexia.dev", api_key=os.environ["OPEXIA_API_KEY"], transport="direct", fail_open=os.environ.get("ENV") == "production", ) @observe(reasoning_role="retrieval", node_type="retrieval") def retrieve(query: str) -> list[str]: return ["https://docs.internal/returns", "https://docs.internal/shipping"] def handle(query: str, user_id: str) -> str: # One ReasoningTrace per logical request = one trace. with ReasoningTrace("handle_request", reasoning_role="synthesis", node_type="agent", end_user=user_id) as node: # Put the query on the EARLIEST span of the trace. node._span.set_attribute("opexia.query_text", query) docs = retrieve(query) node.record_decision( selected="answer_from_kb", scores={"answer_from_kb": 0.91, "escalate": 0.12}, rules_fired=["kb_hit"], ) node.record_sources(consulted=docs, used=docs[:1], dropped=docs[1:]) answer = f"Returns are accepted within 30 days. ({len(docs)} sources)" node.record_cost(model="claude-opus-4-6", input_tokens=2140, output_tokens=310) # Put the outcome on the LATEST span of the trace. node._span.set_attribute("opexia.outcome_text", answer) return answer if __name__ == "__main__": print(handle("Can I return this?", user_id="user_8412")) ``` --- ## Verifying a span landed Run this after wiring up instrumentation. It emits one span and confirms it arrived — the definitive check that the whole path works. ```python """verify_span.py — emit one span and confirm it landed.""" import os, time, uuid, httpx import opexia.trace from opexia.trace import ReasoningTrace ORG = os.environ["OPEXIA_ORG"] # slug, for the read API WS = os.environ["OPEXIA_WORKSPACE"] # slug, for the read API WS_ID = os.environ["OPEXIA_WORKSPACE_ID"] # UUID, for the SDK KEY = os.environ["OPEXIA_API_KEY"] opexia.trace.init( org_id=ORG, workspace_id=WS_ID, project_id="verify", backend_url="https://ingest.opexia.dev", api_key=KEY, transport="direct", ) marker = f"verify-{uuid.uuid4().hex[:8]}" with ReasoningTrace("verify_span", reasoning_role="analysis", node_type="agent") as n: n._span.set_attribute("opexia.query_text", marker) n.record_cost(model="claude-opus-4-6", input_tokens=10, output_tokens=5) # Force the batch out, then let ingestion settle. from opentelemetry import trace as ot ot.get_tracer_provider().force_flush() print(f"emitted {marker}; waiting for ingestion…") time.sleep(20) # 1. Did anything dead-letter? health = httpx.get( f"https://api.opexia.dev/v1/observ/orgs/{ORG}/workspaces/{WS}/health/ingestion", headers={"x-opexia-api-key": KEY}, timeout=30, ).json() print("dead-lettered last hour:", health.get("dead_letter_count_last_hour")) # 2. Is the trace queryable? traces = httpx.get( f"https://api.opexia.dev/v1/observ/orgs/{ORG}/workspaces/{WS}/traces", headers={"x-opexia-api-key": KEY}, params={"page_size": 20, "include_empty": True}, timeout=30, ).json() # Payload is under "data", not "items". print("recent traces:", len(traces["data"])) assert traces["data"], "No traces — check key, workspace UUID, and ingest URL." print("✅ span landed") ``` **If nothing lands, work through this in order:** | Check | How | |---|---| | Key valid and pointed at the right workspace? | `GET https://ingest.opexia.dev/v1/auth/whoami` | | Spans arriving at all? | `spans_last_hour` on [ingestion health](./evigauge-rest-api.md#ingestion-health) | | Arriving but rejected? | `dead_letter_count_last_hour > 0` → an attribute breaks the [four rules](#the-four-rules) | | Using a collector? | Its `otlphttp` exporter must set `encoding: json` — protobuf returns `415` | | Process exiting immediately? | Call `force_flush()` before exit, or spans die in the batch queue | # TypeScript / JavaScript There is **no Evigauge npm package**. TypeScript instruments through native OpenTelemetry JS, exporting OTLP/JSON, with a small helper module that sets the `opexia.*` attributes correctly. ## Installation ```bash npm i @opentelemetry/sdk-trace-node \ @opentelemetry/sdk-trace-base \ @opentelemetry/exporter-trace-otlp-http \ @opentelemetry/resources \ @opentelemetry/semantic-conventions \ @opentelemetry/api ``` > Use `@opentelemetry/exporter-trace-otlp-http` — the **http/json** exporter. > The protobuf exporter returns `415` from Evigauge ingest. ## Bootstrap Import this module **first**, before anything you want traced — via `node -r ./otel.js` or as the very first import in your entrypoint. ```ts // otel.ts import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; import { resourceFromAttributes } from "@opentelemetry/resources"; import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; const base = (process.env.OPEXIA_INGEST_URL ?? "https://ingest.opexia.dev") .replace(/\/$/, ""); const exporter = new OTLPTraceExporter({ url: `${base}/v1/traces`, headers: { "x-opexia-api-key": process.env.OPEXIA_API_KEY ?? "" }, }); const provider = new NodeTracerProvider({ resource: resourceFromAttributes({ // service.name falls back to opexia.project_id at ingest. [ATTR_SERVICE_NAME]: process.env.OPEXIA_PROJECT_ID || "default", }), spanProcessors: [new BatchSpanProcessor(exporter)], }); provider.register(); // Flush on shutdown so the last batch is not lost. process.on("SIGTERM", () => provider.shutdown().catch(() => {})); ``` ## Attribute helpers ```ts // opexia-attributes.ts import { trace, Span, SpanStatusCode, Tracer } from "@opentelemetry/api"; // Server-validated enums — anything else fails validation. export type ReasoningRole = | "decomposer" | "research" | "analysis" | "critique" | "synthesis" | "arbiter" | "retrieval" | "guardrail" | "post_process"; export type NodeType = | "decomposer" | "classifier" | "agent" | "guardrail" | "post_process" | "retrieval"; export interface OpexiaEnvelope { orgId: string; workspaceId: string; // workspace UUID the dashboard reads projectId: string; userId?: string; } /** Required tenancy envelope — set on EVERY span. */ export function setOpexiaEnvelope(span: Span, env: OpexiaEnvelope): void { span.setAttribute("opexia.schema_version", "1.0"); span.setAttribute("opexia.org_id", env.orgId); span.setAttribute("opexia.workspace_id", env.workspaceId); span.setAttribute("opexia.project_id", env.projectId); if (env.userId) span.setAttribute("opexia.user_id", env.userId); // opexia.trace_id must equal the OTel trace id. span.setAttribute("opexia.trace_id", span.spanContext().traceId); } export interface OpexiaSources { consulted?: string[]; // string ids/URLs, NOT objects used?: string[]; // subset actually cited dropped?: string[]; scores?: Record; // 0..1 per id } /** opexia.sources as a JSON STRING with ONLY the 4 allowed keys. */ export function setOpexiaSources(span: Span, src: OpexiaSources): void { span.setAttribute("opexia.sources", JSON.stringify({ consulted: src.consulted ?? [], used: src.used ?? [], dropped: src.dropped ?? [], scores: src.scores ?? {}, })); } export interface OpexiaDecision { selected?: string; rulesFired?: string[]; scores?: Record; alternatives?: Record[]; } /** opexia.decision as a JSON STRING with ONLY the 4 allowed keys. */ export function setOpexiaDecision(span: Span, dec: OpexiaDecision): void { span.setAttribute("opexia.decision", JSON.stringify({ rules_fired: dec.rulesFired ?? [], scores: dec.scores ?? {}, selected: dec.selected ?? null, alternatives: dec.alternatives ?? [], })); } export function setOpexiaReasoning( span: Span, opts: { role?: ReasoningRole; nodeType?: NodeType; parentReasoningId?: string }, ): void { if (opts.role) span.setAttribute("opexia.reasoning_role", opts.role); if (opts.nodeType) span.setAttribute("opexia.node_type", opts.nodeType); if (opts.parentReasoningId) span.setAttribute("opexia.parent_reasoning_id", opts.parentReasoningId); } /** Evigauge truncates text at 16 KB; slicing here keeps sent == stored. */ const TEXT_CAP = 16384; /** * Query + final answer — PLAIN strings, never JSON. * Put `query` on the EARLIEST span and `outcome` on the LATEST span of the trace. */ export function setOpexiaText(span: Span, t: { query?: string; outcome?: string }): void { if (t.query) span.setAttribute("opexia.query_text", t.query.slice(0, TEXT_CAP)); if (t.outcome) span.setAttribute("opexia.outcome_text", t.outcome.slice(0, TEXT_CAP)); } export interface ChatMessage { role: string; content: string } /** * Render an LLM call's messages into opexia.query_text. * * DO NOT join content with a space. Without a role boundary there is nothing * separating the authored system prompt from per-call user content, so every * call hashes as its own "prompt" and the Prompts page fills with thousands of * one-call rows instead of one prompt with a version history. */ export function renderPrompt(messages: ChatMessage[]): string { return messages.map(m => `${m.role ?? "user"}:\n${m.content ?? ""}`).join("\n\n"); } /** Optional — pin prompt identity instead of letting Evigauge derive it. */ export function setOpexiaPrompt( span: Span, p: { id?: string; label?: string; version?: string }, ): void { // Keys are FLAT: opexia.prompt_id, NOT opexia.prompt.id. if (p.id) span.setAttribute("opexia.prompt_id", p.id); if (p.label) span.setAttribute("opexia.prompt_label", p.label); if (p.version) span.setAttribute("opexia.prompt_version", p.version); } /** gen_ai.* — standard OTel GenAI semconv. Cost is inferred server-side if omitted. */ export function setGenAiUsage( span: Span, u: { system?: string; model?: string; operation?: string; inputTokens?: number; outputTokens?: number; finishReason?: string; costUsd?: number; pricingVersion?: string; }, ): void { if (u.system) span.setAttribute("gen_ai.system", u.system); if (u.model) span.setAttribute("gen_ai.request.model", u.model); if (u.operation) span.setAttribute("gen_ai.operation.name", u.operation); if (u.inputTokens != null) span.setAttribute("gen_ai.usage.input_tokens", u.inputTokens); if (u.outputTokens != null) span.setAttribute("gen_ai.usage.output_tokens", u.outputTokens); if (u.finishReason) span.setAttribute("gen_ai.response.finish_reason", u.finishReason); if (u.costUsd != null) span.setAttribute("opexia.cost.usd", u.costUsd); if (u.pricingVersion) span.setAttribute("opexia.cost.model_pricing_version", u.pricingVersion); } // --- One-trace-per-request wrapper ------------------------------------------ let _tracer: Tracer | null = null; let _env: OpexiaEnvelope | null = null; export function initOpexia(env: OpexiaEnvelope, tracerName = "opexia"): void { _env = env; _tracer = trace.getTracer(tracerName); } /** Wrap one logical request (= one trace); the root span gets the envelope. */ export async function withOpexiaTrace( name: string, fn: (span: Span) => Promise, opts?: { role?: ReasoningRole; nodeType?: NodeType }, ): Promise { if (!_tracer || !_env) throw new Error("call initOpexia() first"); const tracer = _tracer, env = _env; return tracer.startActiveSpan(name, async (span) => { setOpexiaEnvelope(span, env); if (opts) setOpexiaReasoning(span, opts); try { const out = await fn(span); span.setStatus({ code: SpanStatusCode.OK }); return out; } catch (err) { span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) }); throw err; } finally { span.end(); } }); } ``` ## Complete TypeScript example ```ts // agent.ts — import ./otel first! import "./otel"; import { initOpexia, withOpexiaTrace, setOpexiaSources, setOpexiaDecision, setOpexiaText, setGenAiUsage, renderPrompt, } from "./opexia-attributes"; initOpexia({ orgId: process.env.OPEXIA_ORG_ID!, workspaceId: process.env.OPEXIA_WORKSPACE_ID!, // UUID projectId: "checkout-agent", }); export async function handle(query: string): Promise { return withOpexiaTrace("handle_request", async (span) => { // Query goes on the EARLIEST span. setOpexiaText(span, { query }); const docs = [ "https://docs.internal/returns", "https://docs.internal/shipping", ]; setOpexiaDecision(span, { selected: "answer_from_kb", scores: { answer_from_kb: 0.91, escalate: 0.12 }, rulesFired: ["kb_hit"], }); setOpexiaSources(span, { consulted: docs, used: docs.slice(0, 1), dropped: docs.slice(1), }); const messages = [ { role: "system", content: "You are a support agent." }, { role: "user", content: query }, ]; // On an LLM span, query_text IS the prompt — keep role boundaries. setOpexiaText(span, { query: renderPrompt(messages) }); const answer = "Returns are accepted within 30 days."; setGenAiUsage(span, { system: "anthropic", model: "claude-opus-4-6", inputTokens: 2140, outputTokens: 310, }); // Outcome goes on the LATEST span. setOpexiaText(span, { outcome: answer }); return answer; }, { role: "synthesis", nodeType: "agent" }); } ``` ## Verifying a span landed (TypeScript) ```ts // verify-span.ts import "./otel"; import { initOpexia, withOpexiaTrace, setOpexiaText } from "./opexia-attributes"; import { trace } from "@opentelemetry/api"; const ORG = process.env.OPEXIA_ORG!; // slug const WS = process.env.OPEXIA_WORKSPACE!; // slug const KEY = process.env.OPEXIA_API_KEY!; initOpexia({ orgId: ORG, workspaceId: process.env.OPEXIA_WORKSPACE_ID!, // UUID projectId: "verify", }); const marker = `verify-${Math.random().toString(16).slice(2, 10)}`; await withOpexiaTrace("verify_span", async (span) => { setOpexiaText(span, { query: marker, outcome: "ok" }); }, { role: "analysis", nodeType: "agent" }); // Force the batch out, then let ingestion settle. await (trace.getTracerProvider() as any).forceFlush?.(); console.log(`emitted ${marker}; waiting for ingestion…`); await new Promise(r => setTimeout(r, 20_000)); const h = await (await fetch( `https://api.opexia.dev/v1/observ/orgs/${ORG}/workspaces/${WS}/health/ingestion`, { headers: { "x-opexia-api-key": KEY } }, )).json(); console.log("dead-lettered last hour:", h.dead_letter_count_last_hour); const t = await (await fetch( `https://api.opexia.dev/v1/observ/orgs/${ORG}/workspaces/${WS}/traces?page_size=20&include_empty=true`, { headers: { "x-opexia-api-key": KEY } }, )).json(); // Payload is under "data", not "items". if (!t.data.length) throw new Error("No traces — check key, workspace UUID, ingest URL."); console.log("✅ span landed"); ``` ## Next.js Use `instrumentation.ts` at the project root — Next.js loads it before the application. ```ts // instrumentation.ts export async function register() { // Node runtime only — the edge runtime cannot use the Node OTel SDK. if (process.env.NEXT_RUNTIME === "nodejs") { await import("./otel"); } } ``` ```js // next.config.js — required on Next.js 14 and earlier module.exports = { experimental: { instrumentationHook: true } }; ``` > Serverless functions can be frozen immediately after a response, killing the > batch queue. On short-lived serverless, either flush explicitly before > returning, or use a smaller `BatchSpanProcessor` scheduled delay. # Framework adapters Adapters instrument a framework's own callbacks so you do not hand-write spans. They are **additive** — no schema change, and they are import-safe when the framework is absent. ## LangChain ```bash pip install 'opexia-trace[adapters-langchain]' ``` ```python import opexia.trace from opexia.trace.adapters.langchain import instrument_langchain, get_langchain_handler opexia.trace.init(...) # Option A — instrument globally. instrument_langchain() # Option B — attach the handler to specific runs. handler = get_langchain_handler() chain.invoke({"question": "..."}, config={"callbacks": [handler]}) ``` The adapter extracts token usage from LLM responses and maps retrieved `Document`s into `opexia.sources` automatically. ## CrewAI ```python import opexia.trace from opexia.trace.adapters.crewai import instrument_crewai opexia.trace.init(...) crew = Crew(agents=[...], tasks=[...]) n = instrument_crewai(crew) # returns how many methods were wrapped crew.kickoff() ``` Wraps `kickoff`, per-agent `execute_task`, and tool calls, producing a fleet-shaped trace consumable by [Fleet Monitor](./evigauge-rest-api.md#fleet-monitor). > **Set `CREWAI_DISABLE_TELEMETRY=true`** before importing CrewAI. Its own > telemetry otherwise competes with the adapter's provider. ```bash export CREWAI_DISABLE_TELEMETRY=true ``` ## Microsoft agent frameworks ```bash pip install 'opexia-trace[adapters-microsoft]' ``` ```python from opexia.trace.adapters.microsoft import ... ``` # The opexia CLI Installed with the package as the `opexia` console script. ``` usage: opexia [options] commands: shipcheck pre-merge check for prompt/model/config changes (no LLM, no re-run) audit local, zero-egress security audit + relational map of an agentic app live live terminal dashboard of pxcore token/$ savings (local, zero-egress) Run `opexia --help` for its options. ``` ## `opexia shipcheck` A pre-merge gate for prompt, model, and config changes. **Runs no LLM and re-runs nothing** — it compares the candidate against recorded baseline telemetry, so it is fast and deterministic enough for every pull request. ```bash opexia shipcheck --help ``` Policy is read from `.opexia/shipcheck.yml` (needs the `shipcheck` extra for YAML) or an equivalent JSON file, which needs no extra. ```yaml # .opexia/shipcheck.yml gates: cost_increase_pct: 15 # fail if projected cost rises more than 15% latency_increase_pct: 20 grade_regression: true # fail on any reliability grade regression ``` **GitHub Actions:** ```yaml name: Ship Check on: pull_request jobs: shipcheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: { python-version: "3.12" } - run: pip install 'opexia-trace[shipcheck]' - run: opexia shipcheck env: OPEXIA_API_KEY: ${{ secrets.OPEXIA_API_KEY }} ``` Ship Check's Gate 3 runs `opexia audit` automatically — see below. The underlying REST endpoint is [`POST /v1/observ/shipcheck/candidate`](./evigauge-rest-api.md#submit-a-candidate-prompt). ## `opexia audit` A local security audit and relational map of an agentic application. **100% local, zero egress.** No network call, no LLM call, no process spawned — and nothing it finds ever leaves the machine. Findings are a disclosure, so they must not travel. ```bash opexia audit --help opexia audit --tree # terminal tree instead of the HTML map ``` It reads the repo's declarations — `.mcp.json` and other MCP configs, `.claude/agents`, skills, hooks, in-code tool definitions — reconstructs the agent topology plus a relational layer (capabilities, datastores, external endpoints, secrets by name), and audits it against the NSA MCP *Security Design Considerations* CSI. **What it checks:** - Tool-description injection, including hidden Unicode - Blanket OAuth scopes - Unpinned `npx` / `uvx` boot-time code execution - Shell-spawning servers - Tool-name collisions - Cleartext credentials — **shape only, the value is never emitted** - Source → sink exfiltration paths **The headline output is `.opexia/agentmap.lock`** — commit it. It turns a silent capability change (a "rug pull") into a reviewable diff in a pull request. It also renders a self-contained local HTML map that loads nothing from the network. > When run inside `shipcheck`, only the verdict and finding **categories** reach > the shared PR comment. Evidence stays on stdout and in the local run. ## `opexia live` A live terminal dashboard of `pxcore` token and dollar savings. Local and zero-egress by default, with an opt-in backend plane. ```bash pip install 'opexia-trace[live]' opexia live ``` Requires the `live` extra (`rich`). Falls back to ASCII on terminals without Unicode box-drawing support. # Configuration reference ## Environment variables | Variable | Used by | Description | |---|---|---| | `OPEXIA_API_KEY` | SDK, CLI | Workspace API key | | `OPEXIA_TRANSPORT` | SDK | `collector` or `direct`; the `transport=` argument wins | | `OPEXIA_INGEST_URL` | TS bootstrap | Ingest base URL | | `OPEXIA_PROJECT_ID` | TS bootstrap | Project name → `service.name` | | `OPEXIA_ORG_ID` / `OPEXIA_WORKSPACE_ID` | your own bootstrap | Passed into `init()` | | `CREWAI_DISABLE_TELEMETRY` | CrewAI adapter | Set to `true` before importing CrewAI | | `PXCORE_MODEL` | pxcore | Active model for calibration gating | > `OPEXIA_ORG_ID` and `OPEXIA_WORKSPACE_ID` are read by *your* bootstrap code, not > by `init()` itself — `init()` takes them as explicit arguments. ## Files the SDK reads or writes | Path | Purpose | |---|---| | `.opexia-wal/spans.jsonl` | Write-ahead log; replayed on next `init()` | | `.opexia/shipcheck.yml` | Ship Check policy | | `.opexia/agentmap.lock` | Committed agent-topology lockfile from `opexia audit` | Add `.opexia-wal/` to `.gitignore`. **Commit `.opexia/agentmap.lock`** — its whole value is being diffable in review. --- ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| | `init()` raises at startup | Bad collector endpoint or WAL path | Fix the config, or set `fail_open=True` | | No spans, no errors | Protobuf OTLP | Use the http/json exporter; collectors need `encoding: json` | | `202` on ingest but no traces | Spans dead-lettering | Check the [four rules](#the-four-rules) and [ingestion health](./evigauge-rest-api.md#ingestion-health) | | A whole span vanishes | Nested object in `opexia.decision` / `opexia.sources` | `json.dumps` them — they are JSON strings | | A whole span vanishes | Undocumented `opexia.*` key | The schema is `extra="forbid"`; remove it | | Prompts page has thousands of one-call rows | `query_text` joined without role boundaries | Use `renderPrompt()` / the `role:\ncontent` join | | `PermissionError` (WinError 32) on Windows | WAL renamed with an open handle | Fixed in current versions — upgrade `opexia-trace` | | Spans lost on exit | Process died before the batch flushed | Call `force_flush()` before exit | | No `end_user` data on the dashboard | `opexia.end_user` never set | Pass `end_user=` or call `record_end_user()` | | Empty engine panels, traces present | Engine off, or no LLM credential | Check [engine settings](./evigauge-rest-api.md#get-engine-settings) and [LLM credentials](./evigauge-rest-api.md#get-llm-credentials) | | No prompt/completion text stored | `capture_text` is off (the default) | See [capture text](./evigauge-rest-api.md#capture-text) | --- ## See also - **[REST API Reference](./evigauge-rest-api.md)** — all 100 endpoints (93 Read API + 9 Ingest, 2 shared). - **[MCP & Claude Code Plugin](./evigauge-mcp-and-plugin.md)** — the `pxcore` MCP server and the `/opexia:*` Claude Code skills. --- # REST API Every endpoint, with auth, roles, payloads and pagination. # Evigauge REST API Reference Complete reference for the Evigauge observability API. Every endpoint below is generated from the live OpenAPI schema of the shipped services — methods, paths, parameters, defaults, request bodies and role gates are verbatim from the code. **Companion documents:** [SDK Reference](./evigauge-sdk.md) · [MCP & Claude Code Plugin](./evigauge-mcp-and-plugin.md) --- ## Naming: Evigauge vs `opexia` The product is **Evigauge** (formerly **OpexIA**). The rename is a brand change only. **Every identifier on the wire is still `opexia` / `pxcore`. Use them exactly as written — renaming them produces `401`, `404`, or a silently dropped span.** | Surface | Value — do not rename | |---|---| | Auth header | `x-opexia-api-key` | | Workspace hint header | `x-opexia-workspace-id` | | API host | `api.opexia.dev` | | Ingest host | `ingest.opexia.dev` | | API path prefix | `/v1/observ/*` | | API key prefixes | `opx_live_`, `opx_test_`, `dr_live_`, `dr_test_` | | Python package | `opexia-trace` (`import opexia.trace`) | | Span attributes | `opexia.*` | --- ## Base URLs There are **two independently deployed services** behind two hostnames. Sending a read query to the ingest host (or a span batch to the API host) returns `404`. | Service | Base URL | Purpose | |---|---|---| | **Read API** | `https://api.opexia.dev` | Everything in this document except the Ingest section — traces, usage, cost, fleet, settings, org/workspace management. | | **Ingest API** | `https://ingest.opexia.dev` | OTLP span ingestion and SDK bootstrap only. High-volume write path. | The Read API serves four disjoint path families, all on `api.opexia.dev`: | Path family | Contains | |---|---| | `/v1/observ/*` | Observability data and per-workspace configuration | | `/v1/orgs/*` | Organizations, members, invitations | | `/v1/workspaces/*` | Workspaces and API keys | | `/v1/auth/*`, `/v1/onboarding/*` | Identity and first-run setup | **Interactive schema:** `https://api.opexia.dev/v1/observ/openapi.json` · Swagger UI at `https://api.opexia.dev/v1/observ/docs`. The OpenAPI document is served under the `/v1/observ` prefix rather than at the root because the load balancer path-routes that prefix to the Read API. --- ## Authentication Authentication is unified: **one of three credentials** satisfies every authenticated endpoint. They are tried in this exact order, first match wins. ### 1. `dr_*` bearer key ```http Authorization: Bearer dr_live_xxxxxxxxxxxxxxxxxxxx ``` ### 2. `opx_*` header key ```http x-opexia-api-key: opx_live_xxxxxxxxxxxxxxxxxxxx ``` ### 3. Better-Auth JWT (dashboard sessions) ```http Authorization: Bearer x-opexia-workspace-id: 3f2b1c8e-9a4d-4f11-b7c2-1e5a8d3f6b90 ``` RS256, verified against the configured issuer's JWKS. Use this for browser/dashboard traffic. `x-opexia-workspace-id` selects which workspace the session acts on; omit it and the user's first available membership is used. > **Key vs JWT.** An API key is bound to exactly one workspace, so it cannot > reach another workspace's data regardless of the slug in the URL. A JWT carries > a user identity whose reach is determined by their memberships. Use keys for > servers and CI; use JWTs for interactive dashboard sessions. ### Environments `live` and `test` are separate key environments (`opx_live_` / `opx_test_`). Both address the same hosts — the key itself determines which environment the call is attributed to. ### Minting a key Keys are created through the API or the dashboard — see [`POST /v1/workspaces/{ws_id}/keys`](#create-an-api-key). **The secret is returned exactly once, at creation.** It is stored hashed and cannot be recovered; if lost, revoke it and mint a new one. --- ## Roles and permissions Four roles, ordered by privilege: `viewer` < `dev` < `admin` < `owner`. Each endpoint lists a **Role** — the *minimum* required. Higher roles always pass. | Gate shown on endpoints | Satisfied by | |---|---| | `viewer` | `viewer`, `dev`, `admin`, `owner` | | `dev` | `dev`, `admin`, `owner` | | `admin` | `admin`, `owner` | | `owner` | `owner` | Read access to observability data is deliberately broad (`viewer`); configuration that changes cost or engine behaviour requires `dev`; anything structural or destructive — retention periods, workspace deletion, ownership transfer — requires `admin` or `owner`. --- ## Conventions ### Path parameters: slugs vs UUIDs This is the most common integration mistake. **The two path families use different identifier types for the same objects.** | Family | Params | Type | Example | |---|---|---|---| | `/v1/observ/orgs/{org}/workspaces/{ws}/...` | `{org}`, `{ws}` | **human slug** | `/v1/observ/orgs/acme/workspaces/prod/traces` | | `/v1/orgs/{org_id}/...`, `/v1/workspaces/{ws_id}/...` | `{org_id}`, `{ws_id}` | **UUID** | `/v1/workspaces/3f2b1c8e-9a4d-4f11-b7c2-1e5a8d3f6b90/keys` | A slug where a UUID is expected returns `404`, not `400`. Resolve slugs to UUIDs with [`GET /v1/orgs/{org_id}/workspaces`](#list-workspaces). ### Response envelope Most `/v1/observ/*` endpoints wrap their payload: ```json { "data": ..., "meta": { "request_id": "…", "schema_version": "1.0", "scorer_version": "…" } } ``` **The payload is under `data`, not `items`.** `meta` carries the request ID (quote it in support requests) and the scorer/schema versions the response was produced under. ### Cursor pagination Paginated endpoints (`/traces`, `/audit-log`) use opaque cursors rather than offsets — offsets skip rows when new data arrives mid-scan. ``` GET .../traces?page_size=50 → { "data": [...], "next_cursor": "eyJ0cyI6...", "meta": {...} } GET .../traces?page_size=50&cursor=eyJ0cyI6... → { "data": [...], "next_cursor": null, "meta": {...} } # null = last page ``` Treat the cursor as an opaque string. Stop when `next_cursor` is `null`. > **`next_cursor` sits in two different places.** On `/traces` it is at the **top > level**; on `/audit-log` it is nested inside **`meta`**. Read it as > `body.next_cursor ?? body.meta?.next_cursor` — the client helpers below do > exactly that. ### The `period` parameter Rollup endpoints take `period`, accepting `day` | `week` | `month`. Defaults vary per endpoint and are stated explicitly in each table. ### Timestamps All timestamps are **ISO 8601 UTC** (`2026-09-01T14:23:05Z`). Time-valued query parameters (`start_time`, `end_time`) accept the same format. ### Response shapes Endpoints backed by a Pydantic model have a stable, typed response. Most analytics endpoints instead return a computed JSON object that FastAPI does not model; those are marked ***not schema-modelled***. > **Read this before typing your frontend against a response.** > > For *not schema-modelled* endpoints, the **envelope** (`data` / `meta` / > `next_cursor`) and any field this document calls out in a table are taken from > the handler code and are reliable. The remaining **field names inside the JSON > examples are illustrative** — they show the shape and units, not a verified > contract. > > Confirm the exact keys against one live response before generating types. > `/traces` is the exception: its row keys are ClickHouse SELECT aliases, listed > explicitly and stable. Treat unlisted keys as additive — new keys may appear without a breaking change, so parse defensively. ### Errors Standard HTTP status codes with a JSON body. | Status | Meaning | Typical cause | |---|---|---| | `400` | Bad request | Malformed cursor, invalid enum, unparseable body | | `401` | Unauthenticated | Missing, malformed, or revoked credential | | `403` | Forbidden | Authenticated but role too low, or key bound to another workspace | | `404` | Not found | Unknown org/workspace slug, wrong id type, or trace outside retention | | `409` | Conflict | Slug already taken, or a stale `confirm_token` | | `415` | Unsupported media type | Protobuf sent to the OTLP endpoint (JSON only) | | `422` | Validation error | Request body failed schema validation | | `429` | Rate limited | Per-org limit exceeded — back off and retry | | `503` | Unavailable | Downstream store unreachable | `422` bodies carry FastAPI's field-level detail: ```json { "detail": [ { "loc": ["body", "hours"], "msg": "field required", "type": "value_error.missing" } ] } ``` All other errors use a flat shape: ```json { "detail": "workspace not found" } ``` ### Rate limits Limits apply **per organization, per minute**. Exceeding one returns `429`. Retry with exponential backoff and jitter — the client helpers below do this for you. --- ## Quickstart ### Install ```bash # Python — nothing required for REST; httpx is recommended pip install httpx # TypeScript — fetch is built in on Node 18+, no dependency needed ``` ### Your first call Fetch the most recent traces in a workspace. **Python** ```python import httpx BASE = "https://api.opexia.dev" API_KEY = "opx_live_xxxxxxxxxxxxxxxxxxxx" ORG, WS = "acme", "prod" r = httpx.get( f"{BASE}/v1/observ/orgs/{ORG}/workspaces/{WS}/traces", headers={"x-opexia-api-key": API_KEY}, params={"page_size": 10}, timeout=30, ) r.raise_for_status() # Payload is under "data", not "items". for t in r.json()["data"]: print(t["trace_id"], t.get("grade"), t.get("cost_usd_total")) ``` **TypeScript** ```ts const BASE = "https://api.opexia.dev"; const API_KEY = process.env.OPEXIA_API_KEY!; const ORG = "acme", WS = "prod"; const res = await fetch( `${BASE}/v1/observ/orgs/${ORG}/workspaces/${WS}/traces?page_size=10`, { headers: { "x-opexia-api-key": API_KEY } }, ); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const { data } = await res.json(); // payload is under "data", not "items" for (const t of data) console.log(t.trace_id, t.grade, t.cost_usd_total); ``` --- ## Client helpers Every example in this reference uses one of the two clients below. Copy the one for your language once, then each endpoint example becomes a single line. **Python** ```python # evigauge.py import os, time, random, httpx class Evigauge: def __init__(self, api_key=None, org=None, ws=None, base="https://api.opexia.dev", timeout=30.0): self.key = api_key or os.environ["OPEXIA_API_KEY"] self.org = org or os.environ.get("OPEXIA_ORG") self.ws = ws or os.environ.get("OPEXIA_WORKSPACE") self.base = base.rstrip("/") self._c = httpx.Client(timeout=timeout, headers={"x-opexia-api-key": self.key}) def request(self, method, path, *, retries=3, **kw): """Call any endpoint. `path` may contain {org}/{ws} placeholders.""" url = self.base + path.format(org=self.org, ws=self.ws) for attempt in range(retries + 1): r = self._c.request(method, url, **kw) # 429 and 5xx are transient — back off and retry. if r.status_code in (429, 502, 503, 504) and attempt < retries: time.sleep((2 ** attempt) + random.random()) continue if r.status_code >= 400: raise httpx.HTTPStatusError( f"{r.status_code} {r.text}", request=r.request, response=r) return r.json() if r.content else None raise RuntimeError("unreachable") def get(self, p, **kw): return self.request("GET", p, **kw) def post(self, p, **kw): return self.request("POST", p, **kw) def put(self, p, **kw): return self.request("PUT", p, **kw) def patch(self, p, **kw): return self.request("PATCH", p, **kw) def delete(self, p, **kw): return self.request("DELETE", p, **kw) def paginate(self, path, **params): """Yield every row across all pages of a cursor-paginated endpoint.""" cursor = None while True: extra = {"cursor": cursor} if cursor else {} page = self.get(path, params={**params, **extra}) # Payload is under "data", not "items". yield from (page.get("data") or []) # /traces puts next_cursor at the top level; /audit-log nests it in meta. cursor = page.get("next_cursor") or (page.get("meta") or {}).get("next_cursor") if not cursor: return evigauge = Evigauge() ``` **TypeScript** ```ts // evigauge.ts export interface EvigaugeOptions { apiKey?: string; org?: string; ws?: string; base?: string; timeoutMs?: number; } export class Evigauge { private key: string; private org: string; private ws: string; private base: string; private timeoutMs: number; constructor(o: EvigaugeOptions = {}) { this.key = o.apiKey ?? process.env.OPEXIA_API_KEY!; this.org = o.org ?? process.env.OPEXIA_ORG!; this.ws = o.ws ?? process.env.OPEXIA_WORKSPACE!; this.base = (o.base ?? "https://api.opexia.dev").replace(/\/$/, ""); this.timeoutMs = o.timeoutMs ?? 30_000; } async request( method: string, path: string, opts: { query?: Record; body?: unknown; retries?: number } = {}, ): Promise { const { query, body, retries = 3 } = opts; const resolved = path.replace("{org}", this.org).replace("{ws}", this.ws); const url = new URL(this.base + resolved); for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined && v !== null) url.searchParams.set(k, String(v)); for (let attempt = 0; ; attempt++) { const res = await fetch(url, { method, signal: AbortSignal.timeout(this.timeoutMs), headers: { "x-opexia-api-key": this.key, ...(body ? { "content-type": "application/json" } : {}), }, ...(body ? { body: JSON.stringify(body) } : {}), }); // 429 and 5xx are transient — back off and retry. if ([429, 502, 503, 504].includes(res.status) && attempt < retries) { await new Promise(r => setTimeout(r, 2 ** attempt * 1000 + Math.random() * 1000)); continue; } if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); return res.status === 204 ? (undefined as T) : await res.json(); } } get = (p: string, query?: Record) => this.request("GET", p, { query }); post = (p: string, body?: unknown, query?: Record) => this.request("POST", p, { body, query }); put = (p: string, body?: unknown) => this.request("PUT", p, { body }); patch = (p: string, body?: unknown) => this.request("PATCH", p, { body }); del = (p: string) => this.request("DELETE", p); /** Async-iterate every row across all pages of a cursor-paginated endpoint. */ async *paginate(path: string, query: Record = {}) { let cursor: string | null = null; do { const page = await this.get(path, { ...query, ...(cursor ? { cursor } : {}) }); // Payload is under "data", not "items". yield* ((page.data ?? []) as T[]); // /traces puts next_cursor at the top level; /audit-log nests it in meta. cursor = page.next_cursor ?? page.meta?.next_cursor ?? null; } while (cursor); } } export const evigauge = new Evigauge(); ``` Set the environment once: ```bash export OPEXIA_API_KEY="opx_live_xxxxxxxxxxxxxxxxxxxx" export OPEXIA_ORG="acme" export OPEXIA_WORKSPACE="prod" ``` --- ## API index | Category | Endpoints | |---|---| | [Traces & Reconstruction](#traces--reconstruction) | 7 | | [Usage, Latency & Cost](#usage-latency--cost) | 4 | | [Cost Budgets](#cost-budgets) | 2 | | [Savings & Optimization](#savings--optimization) | 5 | | [Sources & Drift](#sources--drift) | 3 | | [Alignment](#alignment) | 6 | | [Fleet Monitor](#fleet-monitor) | 7 + WebSocket | | [Ship Check](#ship-check) | 7 | | [Claude Code Analytics](#claude-code-analytics) | 3 | | [Workspace Settings](#workspace-settings) | 6 | | [Engine Settings](#engine-settings) | 2 | | [Data Retention](#data-retention) | 3 | | [Redaction](#redaction) | 2 | | [LLM & Search Credentials](#llm--search-credentials) | 7 | | [Aggregate & Spend](#aggregate--spend) | 3 | | [Audit Log](#audit-log) | 1 | | [Health & Monitoring](#health--monitoring) | 3 | | [Organizations & Members](#organizations--members) | 6 | | [Invitations & Onboarding](#invitations--onboarding) | 6 | | [Workspaces](#workspaces) | 6 | | [API Keys](#api-keys) | 3 | | [Platform Auth (Enterprise SSO)](#platform-auth-enterprise-sso) | 1 | | [Ingest API (OTLP)](#ingest-api-otlp) | 7 | # Traces & Reconstruction The core of Evigauge. A **trace** is one end-to-end agent execution. Evigauge reconstructs from the raw spans *why* the agent did what it did, not merely *what* it did — each sub-resource below is one reconstruction engine's output. Reconstruction runs asynchronously after ingestion. A trace appears in [`GET /traces`](#list-traces) within seconds, but its engine sub-resources populate over the following seconds to minutes. A `null` or empty engine payload means *not yet computed*, not *failed*. --- ## List traces ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/traces ``` Cursor-paginated list of traces, newest first. The primary entry point for any trace browser. **Auth:** API key or JWT · **Role:** `viewer` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `org` | string | ✅ | Organization **slug** | | `ws` | string | ✅ | Workspace **slug** | ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `page_size` | integer | ➖ | `50` | Items per page | | `cursor` | string \| null | ➖ | `null` | Opaque cursor from the previous response's `next_cursor` | | `start_time` | string \| null | ➖ | `null` | ISO 8601 UTC lower bound (inclusive) | | `end_time` | string \| null | ➖ | `null` | ISO 8601 UTC upper bound (exclusive) | | `grade_min` | string \| null | ➖ | `null` | Minimum reliability grade — see below | | `project` | string \| null | ➖ | `null` | Filter to one `project_id` | | `include_empty` | boolean | ➖ | `false` | Include traces that carry no LLM content | #### `grade_min` values Grades are ranked; `grade_min` returns every trace at that rank **or better**. | Grade | `D` | `C` | `C+` | `B-` | `B` | `B+` | `A-` | `A` | `A+` | |---|---|---|---|---|---|---|---|---|---| | Rank | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | So `grade_min=B` returns `B`, `B+`, `A-`, `A`, `A+`. Any other value returns `400`, validated before the query runs. > **Unscored traces are excluded whenever `grade_min` is set** — they rank `-1`, > below every real grade. That is the intended semantic ("only show me traces I > have a grade for"), but it means adding `grade_min` can drop traces that are > merely still being scored, not bad. ### Response `200` — *not schema-modelled* Row keys are the ClickHouse SELECT aliases, so they are stable: ```json { "data": [ { "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "ws_uuid_out": "3f2b1c8e-9a4d-4f11-b7c2-1e5a8d3f6b90", "project_id_out": "checkout-agent", "trace_start": "2026-09-01T14:23:05Z", "trace_end": "2026-09-01T14:23:13Z", "cost_usd_total": 0.0847, "input_tokens": 14100, "output_tokens": 4350, "grade": "B", "score": 82, "scorer_version": "2.1.0", "span_count": 37 } ], "next_cursor": "eyJ0cyI6MTc1...", "meta": { "workspace_id": "3f2b1c8e-9a4d-4f11-b7c2-1e5a8d3f6b90", "scorer_version": "2.1.0", "schema_version": "1.0", "request_id": "9f2c4b1e8a3d4f11b7c21e5a8d3f6b90" } } ``` | Field | Meaning | |---|---| | `trace_start` / `trace_end` | `min(start_time)` / `max(end_time)` across the trace. Compute duration from these — there is no `duration_ms`. | | `cost_usd_total` | Summed across **all** spans in the trace, not just the root | | `input_tokens` / `output_tokens` | Summed from the `gen_ai` child spans | | `grade` | Letter grade — latest scored value | | `score` | Numeric reliability score, `0`–`100` | | `scorer_version` | Distinguishes *scored but ungradeable* (`grade: ""`, version set) from *never scored* (`grade: ""`, version `""`) | > Rows are ordered `trace_start DESC, trace_id DESC`. ### Examples **Python** ```python # One page page = evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/traces", params={"page_size": 50, "grade_min": "C"}, ) print(page["data"], page["next_cursor"]) # Every trace in a window, transparently paginated for t in evigauge.paginate( "/v1/observ/orgs/{org}/workspaces/{ws}/traces", start_time="2026-09-01T00:00:00Z", end_time="2026-09-02T00:00:00Z", page_size=100, ): print(t["trace_id"], t["cost_usd_total"]) ``` **TypeScript** ```ts // One page const page = await evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/traces", { page_size: 50, grade_min: "C" }, ); console.log(page.data, page.next_cursor); // Every trace in a window, transparently paginated for await (const t of evigauge.paginate( "/v1/observ/orgs/{org}/workspaces/{ws}/traces", { start_time: "2026-09-01T00:00:00Z", end_time: "2026-09-02T00:00:00Z", page_size: 100 }, )) { console.log(t.trace_id, t.cost_usd_total); } ``` ### Errors | Status | Cause | |---|---| | `400` | Malformed `cursor`, or unparseable `start_time` / `end_time` | | `403` | API key bound to a different workspace | | `404` | Unknown org or workspace slug | --- ## Get a trace ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/traces/{trace_id} ``` Full detail for one trace, including its span tree. **Auth:** API key or JWT · **Role:** `viewer` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `org` | string | ✅ | Organization slug | | `ws` | string | ✅ | Workspace slug | | `trace_id` | string | ✅ | Trace ID (32-char hex, as emitted by OpenTelemetry) | ### Response `200` — *not schema-modelled* ```json { "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "project_id": "checkout-agent", "start_time": "2026-09-01T14:23:05Z", "duration_ms": 8420, "total_cost_usd": 0.0847, "grade": "B", "spans": [ { "span_id": "00f067aa0ba902b7", "parent_span_id": null, "name": "agent.run", "kind": "SERVER", "start_time": "2026-09-01T14:23:05Z", "duration_ms": 8420, "status_code": "OK", "attributes": { "opexia.project_id": "checkout-agent" } } ] } ``` ### Examples **Python** ```python trace = evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/traces/4bf92f3577b34da6a3ce929d0e0e4736" ) for s in trace["spans"]: indent = " " if s["parent_span_id"] else "" print(f"{indent}{s['name']:30} {s['duration_ms']}ms {s['status_code']}") ``` **TypeScript** ```ts const trace = await evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/traces/4bf92f3577b34da6a3ce929d0e0e4736", ); for (const s of trace.spans) { const indent = s.parent_span_id ? " " : ""; console.log(`${indent}${s.name.padEnd(30)} ${s.duration_ms}ms ${s.status_code}`); } ``` ### Errors | Status | Cause | |---|---| | `404` | Unknown trace, or the trace has aged out of the workspace's retention window | --- ## Trace content reliability ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/traces/{trace_id}/content-reliability ``` Per-claim hallucination assessment for the trace's output. Each claim the agent made is extracted and scored against the context that was actually available to it. **Auth:** API key or JWT · **Role:** `viewer` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `org` | string | ✅ | Organization slug | | `ws` | string | ✅ | Workspace slug | | `trace_id` | string | ✅ | Trace ID | ### Response `200` — *not schema-modelled* ```json { "trace_id": "4bf92f...", "grade": "B", "score": 0.82, "claims": [ { "claim": "The customer's last order shipped on 2026-08-14.", "supported": true, "confidence": 0.94, "evidence_span_id": "00f067aa0ba902b7" }, { "claim": "Their loyalty tier is Gold.", "supported": false, "confidence": 0.31, "evidence_span_id": null } ] } ``` An unsupported claim with no `evidence_span_id` is the signal to investigate: the agent asserted something no retrieved context backed. ### Examples **Python** ```python rel = evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/traces/{}/content-reliability".format(trace_id) ) unsupported = [c for c in rel["claims"] if not c["supported"]] print(f"grade={rel['grade']} {len(unsupported)} unsupported claim(s)") for c in unsupported: print(" ⚠", c["claim"]) ``` **TypeScript** ```ts const rel = await evigauge.get( `/v1/observ/orgs/{org}/workspaces/{ws}/traces/${traceId}/content-reliability`, ); const unsupported = rel.claims.filter((c: any) => !c.supported); console.log(`grade=${rel.grade} ${unsupported.length} unsupported claim(s)`); unsupported.forEach((c: any) => console.log(" ⚠", c.claim)); ``` --- ## Trace decision trace ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/traces/{trace_id}/decision-trace ``` The reconstructed reasoning DAG — every decision point the agent passed through, the options it had, what it chose, and the basis for that choice. This answers *why* an agent branched the way it did. **Auth:** API key or JWT · **Role:** `viewer` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `org` | string | ✅ | Organization slug | | `ws` | string | ✅ | Workspace slug | | `trace_id` | string | ✅ | Trace ID | ### Response `200` — *not schema-modelled* ```json { "trace_id": "4bf92f...", "nodes": [ { "id": "d1", "span_id": "00f067aa0ba902b7", "decision": "route_to_tool", "chosen": "lookup_order", "alternatives": ["search_kb", "ask_user"], "basis": "user message contained an order number", "confidence": 0.88 } ], "edges": [{ "from": "d1", "to": "d2" }] } ``` ### Examples **Python** ```python dt = evigauge.get( f"/v1/observ/orgs/{{org}}/workspaces/{{ws}}/traces/{trace_id}/decision-trace" ) for n in dt["nodes"]: alts = ", ".join(n["alternatives"]) print(f"{n['decision']}: chose {n['chosen']} over [{alts}] — {n['basis']}") ``` **TypeScript** ```ts const dt = await evigauge.get( `/v1/observ/orgs/{org}/workspaces/{ws}/traces/${traceId}/decision-trace`, ); for (const n of dt.nodes) { console.log(`${n.decision}: chose ${n.chosen} over [${n.alternatives.join(", ")}] — ${n.basis}`); } ``` --- ## Trace decomposition ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/traces/{trace_id}/decomposition ``` How the agent broke the incoming request into sub-tasks, and how each sub-task resolved. Use this to find requests that were mis-decomposed at the first step — a common root cause of an otherwise inexplicable bad answer. **Auth:** API key or JWT · **Role:** `viewer` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `org` | string | ✅ | Organization slug | | `ws` | string | ✅ | Workspace slug | | `trace_id` | string | ✅ | Trace ID | ### Response `200` — *not schema-modelled* ```json { "trace_id": "4bf92f...", "original_query": "Where is my order and can I still change the address?", "sub_tasks": [ { "id": "s1", "text": "locate order status", "resolved": true, "span_ids": ["00f0..."] }, { "id": "s2", "text": "check address mutability", "resolved": false, "span_ids": [] } ], "coverage": 0.5 } ``` `coverage` below `1.0` means part of the user's request was never addressed. ### Examples **Python** ```python d = evigauge.get( f"/v1/observ/orgs/{{org}}/workspaces/{{ws}}/traces/{trace_id}/decomposition" ) if d["coverage"] < 1.0: missed = [s["text"] for s in d["sub_tasks"] if not s["resolved"]] print("Unaddressed:", missed) ``` **TypeScript** ```ts const d = await evigauge.get( `/v1/observ/orgs/{org}/workspaces/{ws}/traces/${traceId}/decomposition`, ); if (d.coverage < 1.0) { console.log("Unaddressed:", d.sub_tasks.filter((s: any) => !s.resolved).map((s: any) => s.text)); } ``` --- ## Trace sources matrix ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/traces/{trace_id}/sources-matrix ``` Every source the agent consulted, scored for authority and actual influence on the final answer, with a per-source explainer. Reveals the case where an agent retrieved good sources and then ignored them. **Auth:** API key or JWT · **Role:** `viewer` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `org` | string | ✅ | Organization slug | | `ws` | string | ✅ | Workspace slug | | `trace_id` | string | ✅ | Trace ID | ### Response `200` — *not schema-modelled* ```json { "trace_id": "4bf92f...", "sources": [ { "url": "https://docs.internal/returns-policy", "kind": "internal_kb", "authority_score": 0.91, "influence_score": 0.12, "verification_level": "attribution", "explainer": "High-authority internal policy doc retrieved but barely reflected in the answer." } ] } ``` ### Examples **Python** ```python sm = evigauge.get( f"/v1/observ/orgs/{{org}}/workspaces/{{ws}}/traces/{trace_id}/sources-matrix" ) # High authority but low influence = retrieved and then ignored. for s in sm["sources"]: if s["authority_score"] > 0.8 and s["influence_score"] < 0.3: print("Ignored authoritative source:", s["url"]) ``` **TypeScript** ```ts const sm = await evigauge.get( `/v1/observ/orgs/{org}/workspaces/{ws}/traces/${traceId}/sources-matrix`, ); sm.sources .filter((s: any) => s.authority_score > 0.8 && s.influence_score < 0.3) .forEach((s: any) => console.log("Ignored authoritative source:", s.url)); ``` --- ## Trace optimization ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/traces/{trace_id}/optimization ``` Per-trace optimization findings from the correlation engine — where this specific execution spent more model, tokens, or latency than the outcome required. **Auth:** API key or JWT · **Role:** `viewer` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `org` | string | ✅ | Organization slug | | `ws` | string | ✅ | Workspace slug | | `trace_id` | string | ✅ | Trace ID | ### Response `200` — *not schema-modelled* ```json { "trace_id": "4bf92f...", "findings": [ { "kind": "model_downgrade", "span_id": "00f067aa0ba902b7", "current_model": "claude-opus-4-6", "suggested_model": "claude-sonnet-4-6", "rationale": "Deterministic extraction step; output matched on replay.", "estimated_savings_usd": 0.031 } ], "total_estimated_savings_usd": 0.031 } ``` ### Examples **Python** ```python opt = evigauge.get( f"/v1/observ/orgs/{{org}}/workspaces/{{ws}}/traces/{trace_id}/optimization" ) print(f"Potential saving: ${opt['total_estimated_savings_usd']:.4f}") for f in opt["findings"]: print(f" {f['kind']}: {f['current_model']} → {f['suggested_model']}") ``` **TypeScript** ```ts const opt = await evigauge.get( `/v1/observ/orgs/{org}/workspaces/{ws}/traces/${traceId}/optimization`, ); console.log(`Potential saving: $${opt.total_estimated_savings_usd.toFixed(4)}`); opt.findings.forEach((f: any) => console.log(` ${f.kind}: ${f.current_model} → ${f.suggested_model}`)); ``` # Usage, Latency & Cost Workspace-level rollups. These power the cost and speed dashboard. --- ## Usage summary ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/usage/summary ``` Headline totals for one period — traces, tokens, cost, error rate. **Auth:** API key or JWT · **Role:** `viewer` ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `period` | string | ➖ | `day` | `day` \| `week` \| `month` | ### Response `200` — *not schema-modelled* The payload is `data`, containing `totals` plus two breakdowns: ```json { "data": { "period": "day", "workspace_id": "3f2b1c8e-9a4d-4f11-b7c2-1e5a8d3f6b90", "totals": { "trace_count": 12480, "cost_usd": 412.87 }, "by_model": [ { "model": "claude-opus-4-6", "cost_usd": 311.02 } ], "by_project": [ { "project_id": "checkout-agent", "cost_usd": 208.44 } ] }, "meta": { "request_id": "9f2c4b1e…", "scorer_version": "2.1.0" } } ``` > Exact keys **inside** `totals`, `by_model`, and `by_project` are computed and > not schema-modelled — inspect one live response before typing against them. ### Examples **Python** ```python r = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/usage/summary", params={"period": "week"}) d = r["data"] print(d["totals"]) for row in d["by_model"]: print(" ", row) ``` **TypeScript** ```ts const r = await evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/usage/summary", { period: "week" }); const d = r.data; console.log(d.totals); d.by_model.forEach((row: any) => console.log(" ", row)); ``` --- ## Usage timeseries ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/usage/timeseries ``` Bucketed usage over time, for charting. **Auth:** API key or JWT · **Role:** `viewer` ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `period` | string | ➖ | `day` | **`day` only.** v1 emits one row per calendar day; `week` / `month` return `400`. | | `days` | integer | ➖ | `30` | How many days back to cover | > Unlike the other rollup endpoints, this one accepts **only `period=day`**. > Aggregate to weeks or months client-side from the daily buckets. ### Response `200` — *not schema-modelled* ```json { "period": "day", "days": 30, "points": [ { "ts": "2026-08-03T00:00:00Z", "trace_count": 401, "total_tokens": 610233, "total_cost_usd": 13.94 } ] } ``` ### Examples **Python** ```python ts = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/usage/timeseries", params={"period": "day", "days": 14}) for p in ts["points"]: print(p["ts"][:10], f"${p['total_cost_usd']:.2f}") ``` **TypeScript** ```ts const ts = await evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/usage/timeseries", { period: "day", days: 14 }); ts.points.forEach((p: any) => console.log(p.ts.slice(0, 10), `$${p.total_cost_usd.toFixed(2)}`)); ``` --- ## Latency by phase ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/usage/latency ``` Latency percentiles broken down by execution phase. Tells you *which stage* is slow, not just that the whole trace is slow. **Auth:** API key or JWT · **Role:** `viewer` ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `period` | string | ➖ | `day` | `day` \| `week` \| `month` | ### Response `200` — *not schema-modelled* ```json { "period": "day", "phases": [ { "phase": "retrieval", "p50_ms": 210, "p95_ms": 890, "p99_ms": 1400, "call_count": 12480, "error_count": 12 }, { "phase": "llm_call", "p50_ms": 940, "p95_ms": 5200, "p99_ms": 9100, "call_count": 51203, "error_count": 340 } ] } ``` ### Examples **Python** ```python lat = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/usage/latency", params={"period": "day"}) worst = max(lat["phases"], key=lambda p: p["p95_ms"]) print(f"Slowest phase: {worst['phase']} @ p95 {worst['p95_ms']}ms") ``` **TypeScript** ```ts const lat = await evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/usage/latency", { period: "day" }); const worst = lat.phases.reduce((a: any, b: any) => (b.p95_ms > a.p95_ms ? b : a)); console.log(`Slowest phase: ${worst.phase} @ p95 ${worst.p95_ms}ms`); ``` --- ## Phase trace drill-down ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/usage/latency/phase/traces ``` The individual traces behind one phase's percentile or error count. This is the drill-down from a slow bar in the latency chart to the actual traces that caused it. **Auth:** API key or JWT · **Role:** `viewer` ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `phase` | string | ✅ | — | Phase name, exactly as returned by `/usage/latency` | | `metric` | string | ✅ | — | `errors` (a span in the phase errored) or `slow` (span duration ≥ the phase's live percentile) | | `period` | string | ➖ | `day` | `day` \| `week` \| `month` | | `percentile` | string | ➖ | `p95` | `p95` \| `p99`. **Applies only when `metric=slow`.** | | `limit` | integer | ➖ | `100` | Max traces returned, `1`–`500` | > The values are **`errors`** and **`slow`** — not `error` / `latency`. An > invalid `metric`, `period`, or `percentile` returns `400` with the accepted > list in `detail`. > `threshold_ms` is computed over the **same population** as the latency panel, > so the number matches what the chart showed. An empty `traces` array is a valid > healthy state, not an error. ### Response `200` — *not schema-modelled* ```json { "phase": "llm_call", "metric": "slow", "percentile": "p95", "threshold_ms": 5200, "traces": [ { "trace_id": "4bf92f...", "duration_ms": 9840, "start_time": "2026-09-01T14:23:05Z", "status": "ok" } ] } ``` ### Examples **Python** ```python slow = evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/usage/latency/phase/traces", params={"phase": "llm_call", "metric": "slow", "percentile": "p99", "limit": 20}, ) print(f"Traces above {slow['threshold_ms']}ms:") for t in slow["traces"]: print(" ", t["trace_id"], t["duration_ms"]) ``` **TypeScript** ```ts const slow = await evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/usage/latency/phase/traces", { phase: "llm_call", metric: "slow", percentile: "p99", limit: 20 }, ); console.log(`Traces above ${slow.threshold_ms}ms:`); slow.traces.forEach((t: any) => console.log(" ", t.trace_id, t.duration_ms)); ``` ### Errors | Status | Cause | |---|---| | `400` | Unknown `phase`, or invalid `metric` / `period` / `percentile` value | | `422` | `phase` or `metric` omitted — both are required | # Cost Budgets Set daily and monthly spend caps per workspace. Breaching a cap raises an alert; budgets are advisory and never block traffic. --- ## Get cost budget ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/cost-budget ``` **Auth:** API key or JWT · **Role:** `viewer` ### Response `200` — *not schema-modelled* ```json { "budgets": { "day": { "cap_usd": 100.0, "warn_pct": 0.8 }, "month": { "cap_usd": 2500.0, "warn_pct": 0.9 } }, "current": { "day_spend_usd": 41.29, "month_spend_usd": 892.14 } } ``` ### Examples **Python** ```python b = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/cost-budget") day = b["budgets"]["day"] pct = b["current"]["day_spend_usd"] / day["cap_usd"] print(f"Day: {pct:.0%} of ${day['cap_usd']}", "⚠ WARN" if pct >= day["warn_pct"] else "") ``` **TypeScript** ```ts const b = await evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/cost-budget"); const day = b.budgets.day; const pct = b.current.day_spend_usd / day.cap_usd; console.log(`Day: ${(pct * 100).toFixed(0)}% of $${day.cap_usd}`, pct >= day.warn_pct ? "⚠ WARN" : ""); ``` --- ## Set cost budget ```http PUT /v1/observ/orgs/{org}/workspaces/{ws}/cost-budget ``` Replaces the whole budget object. Omit a period to clear its cap. **Auth:** API key or JWT · **Role:** `admin` ### Request body — `CostBudgetIn` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `budgets` | object | ✅ | — | Map of period name → `PeriodBudget`. Keys: `day`, `month` | **`PeriodBudget`** | Field | Type | Required | Default | Description | |---|---|---|---|---| | `cap_usd` | number | ✅ | — | Spend cap in USD for the period | | `warn_pct` | number | ➖ | `0.8` | Fraction of the cap at which to warn (`0.0`–`1.0`) | ```json { "budgets": { "day": { "cap_usd": 150.0, "warn_pct": 0.75 }, "month": { "cap_usd": 3000.0 } } } ``` ### Examples **Python** ```python evigauge.put("/v1/observ/orgs/{org}/workspaces/{ws}/cost-budget", json={ "budgets": { "day": {"cap_usd": 150.0, "warn_pct": 0.75}, "month": {"cap_usd": 3000.0}, # warn_pct defaults to 0.8 } }) ``` **TypeScript** ```ts await evigauge.put("/v1/observ/orgs/{org}/workspaces/{ws}/cost-budget", { budgets: { day: { cap_usd: 150.0, warn_pct: 0.75 }, month: { cap_usd: 3000.0 }, // warn_pct defaults to 0.8 }, }); ``` ### Errors | Status | Cause | |---|---| | `403` | Role below `admin` | | `422` | `warn_pct` outside `0.0`–`1.0`, or `cap_usd` not a number | # Savings & Optimization Recommendation engines that identify spend and latency you can remove without changing outcomes. **These are advisory only — Evigauge never actuates a change to your agents.** --- ## Savings recommendations ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/savings/recommendations ``` Workspace-wide savings opportunities, ranked by estimated monthly value. Covers prompt caching and context optimization. **Auth:** API key or JWT · **Role:** `viewer` ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `limit` | integer | ➖ | `50` | Max recommendations returned | ### Response `200` — *not schema-modelled* ```json { "recommendations": [ { "kind": "prompt_caching", "scope": "checkout-agent", "rationale": "A 4.1k-token system prompt is resent on every call; 82% of calls share it verbatim.", "estimated_monthly_savings_usd": 214.60, "affected_trace_count": 8120, "confidence": "high" } ], "total_estimated_monthly_savings_usd": 214.60 } ``` ### Examples **Python** ```python recs = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/savings/recommendations", params={"limit": 10}) print(f"Total opportunity: ${recs['total_estimated_monthly_savings_usd']:,.2f}/mo") for r in recs["recommendations"]: print(f" [{r['confidence']}] {r['kind']}: ${r['estimated_monthly_savings_usd']:.2f}/mo") ``` **TypeScript** ```ts const recs = await evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/savings/recommendations", { limit: 10 }); console.log(`Total opportunity: $${recs.total_estimated_monthly_savings_usd.toFixed(2)}/mo`); recs.recommendations.forEach((r: any) => console.log(` [${r.confidence}] ${r.kind}: $${r.estimated_monthly_savings_usd.toFixed(2)}/mo`)); ``` --- ## Savings for one trace ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/savings/trace/{trace_id} ``` The savings analysis narrowed to a single trace. **Auth:** API key or JWT · **Role:** `viewer` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `org` | string | ✅ | Organization slug | | `ws` | string | ✅ | Workspace slug | | `trace_id` | string | ✅ | Trace ID | ### Examples **Python** ```python s = evigauge.get(f"/v1/observ/orgs/{{org}}/workspaces/{{ws}}/savings/trace/{trace_id}") print(s) ``` **TypeScript** ```ts const s = await evigauge.get( `/v1/observ/orgs/{org}/workspaces/{ws}/savings/trace/${traceId}`); console.log(s); ``` --- ## Optimization flags ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/optimization/flags ``` Open optimization flags raised across the workspace. **Auth:** API key or JWT · **Role:** `viewer` ### Response `200` — *not schema-modelled* ```json { "flags": [ { "flag_id": "f_01J8...", "kind": "over_provisioned_model", "severity": "medium", "first_seen": "2026-08-28T09:14:00Z", "occurrence_count": 412, "summary": "Extraction step uses a frontier model for a deterministic task." } ] } ``` ### Examples **Python** ```python flags = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/optimization/flags") for f in flags["flags"]: print(f"[{f['severity']}] {f['kind']} ×{f['occurrence_count']}") ``` **TypeScript** ```ts const flags = await evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/optimization/flags"); flags.flags.forEach((f: any) => console.log(`[${f.severity}] ${f.kind} ×${f.occurrence_count}`)); ``` --- ## Over-provisioned traces ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/optimization/over-provisioned ``` Traces that used more model capability than the task required — the concrete evidence behind an `over_provisioned_model` flag. **Auth:** API key or JWT · **Role:** `viewer` ### Examples **Python** ```python op = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/optimization/over-provisioned") print(op) ``` **TypeScript** ```ts const op = await evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/optimization/over-provisioned"); console.log(op); ``` # Sources & Drift ## List sources ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/sources ``` Every source domain the workspace's agents have consulted, with the learned per-domain authority prior. The prior is self-learning: it updates as sources prove reliable or unreliable across traces. **Auth:** API key or JWT · **Role:** `viewer` ### Response `200` — *not schema-modelled* ```json { "sources": [ { "domain": "docs.internal", "kind": "internal_kb", "authority_prior": 0.93, "citation_count": 4120, "last_seen": "2026-09-01T14:23:05Z" } ] } ``` ### Examples **Python** ```python src = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/sources") for s in sorted(src["sources"], key=lambda s: -s["citation_count"])[:10]: print(f"{s['domain']:35} authority={s['authority_prior']:.2f} n={s['citation_count']}") ``` **TypeScript** ```ts const src = await evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/sources"); src.sources .sort((a: any, b: any) => b.citation_count - a.citation_count) .slice(0, 10) .forEach((s: any) => console.log(`${s.domain.padEnd(35)} authority=${s.authority_prior.toFixed(2)} n=${s.citation_count}`)); ``` --- ## Current drift ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/drift/current ``` The workspace's current drift posture — how far today's behaviour has moved from the established baseline. Drift is what catches silent degradation: the agent still returns 200s, but its answers have quietly changed. **Auth:** API key or JWT · **Role:** `viewer` ### Response `200` — *not schema-modelled* ```json { "as_of": "2026-09-01T00:00:00Z", "drift_score": 0.18, "status": "warn", "baseline_window_days": 14, "dimensions": [ { "name": "grade_distribution", "delta": 0.21, "direction": "worse" }, { "name": "cost_per_trace", "delta": 0.04, "direction": "worse" } ] } ``` `status` is one of `ok` | `warn` | `alert`. ### Examples **Python** ```python d = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/drift/current") if d["status"] != "ok": worst = max(d["dimensions"], key=lambda x: x["delta"]) print(f"⚠ drift {d['status']}: {worst['name']} moved {worst['delta']:.0%} {worst['direction']}") ``` **TypeScript** ```ts const d = await evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/drift/current"); if (d.status !== "ok") { const worst = d.dimensions.reduce((a: any, b: any) => (b.delta > a.delta ? b : a)); console.log(`⚠ drift ${d.status}: ${worst.name} moved ${(worst.delta * 100).toFixed(0)}% ${worst.direction}`); } ``` --- ## Drift history ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/drift/history ``` Drift score over time, for charting. **Auth:** API key or JWT · **Role:** `viewer` ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `days` | integer | ➖ | `30` | How many days of history to return | ### Examples **Python** ```python h = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/drift/history", params={"days": 60}) print(h) ``` **TypeScript** ```ts const h = await evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/drift/history", { days: 60 }); console.log(h); ``` # Alignment Alignment checks agent behaviour against *your* written policy. You upload the policy documents; the alignment engine flags where agent output diverged from them. --- ## Get alignment policy ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/alignment/policy ``` **Auth:** API key or JWT · **Role:** `viewer` ### Response `200` — *not schema-modelled* ```json { "enabled": true, "description": "Support agents must never promise a refund window shorter than 30 days." } ``` ### Examples **Python** ```python p = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/alignment/policy") print(p["enabled"], p["description"]) ``` **TypeScript** ```ts const p = await evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/alignment/policy"); console.log(p.enabled, p.description); ``` --- ## Set alignment policy ```http PUT /v1/observ/orgs/{org}/workspaces/{ws}/alignment/policy ``` **Auth:** API key or JWT · **Role:** `admin` ### Request body — `_PolicyBody` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `description` | string | ➖ | `""` | Natural-language statement of the policy | | `enabled` | boolean | ➖ | `false` | Whether the alignment engine runs for this workspace | ### Examples **Python** ```python evigauge.put("/v1/observ/orgs/{org}/workspaces/{ws}/alignment/policy", json={ "enabled": True, "description": "Never promise a refund window shorter than 30 days.", }) ``` **TypeScript** ```ts await evigauge.put("/v1/observ/orgs/{org}/workspaces/{ws}/alignment/policy", { enabled: true, description: "Never promise a refund window shorter than 30 days.", }); ``` --- ## Upload an alignment document ```http POST /v1/observ/orgs/{org}/workspaces/{ws}/alignment/docs ``` Upload a policy document. Ingestion is asynchronous — the document is chunked and embedded into the workspace's vector store before it influences flags. **Auth:** API key or JWT · **Role:** `admin` ### Request body — `multipart/form-data` | Field | Type | Required | Description | |---|---|---|---| | `file` | binary | ✅ | The document to upload | ### Examples **Python** ```python import httpx, os with open("refund-policy.pdf", "rb") as fh: r = httpx.post( "https://api.opexia.dev/v1/observ/orgs/acme/workspaces/prod/alignment/docs", headers={"x-opexia-api-key": os.environ["OPEXIA_API_KEY"]}, files={"file": ("refund-policy.pdf", fh, "application/pdf")}, timeout=120, ) r.raise_for_status() print(r.json()) ``` **TypeScript** ```ts import { readFile } from "node:fs/promises"; const form = new FormData(); form.append( "file", new Blob([await readFile("refund-policy.pdf")], { type: "application/pdf" }), "refund-policy.pdf", ); const res = await fetch( "https://api.opexia.dev/v1/observ/orgs/acme/workspaces/prod/alignment/docs", { method: "POST", headers: { "x-opexia-api-key": process.env.OPEXIA_API_KEY! }, body: form }, ); console.log(await res.json()); ``` > Do **not** set `content-type` manually on a multipart request. Both `httpx` and > `fetch` derive the boundary automatically; overriding it corrupts the body. --- ## Delete an alignment document ```http DELETE /v1/observ/orgs/{org}/workspaces/{ws}/alignment/docs/{doc_id} ``` Removes the document and its embeddings from the workspace store. **Auth:** API key or JWT · **Role:** `admin` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `org` | string | ✅ | Organization slug | | `ws` | string | ✅ | Workspace slug | | `doc_id` | string | ✅ | Document ID returned at upload | ### Examples **Python** ```python evigauge.delete(f"/v1/observ/orgs/{{org}}/workspaces/{{ws}}/alignment/docs/{doc_id}") ``` **TypeScript** ```ts await evigauge.del(`/v1/observ/orgs/{org}/workspaces/{ws}/alignment/docs/${docId}`); ``` --- ## List alignment flags ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/alignment/flags ``` Cases where agent output diverged from policy. **Auth:** API key or JWT · **Role:** `viewer` ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `limit` | integer | ➖ | `50` | Page size | | `offset` | integer | ➖ | `0` | Offset for paging | | `include_unclear` | boolean | ➖ | `false` | Include low-confidence flags the engine could not resolve | > This endpoint uses **offset paging**, not cursors — it is one of the few that does. ### Response `200` — *not schema-modelled* ```json { "flags": [ { "flag_id": "af_01J8...", "trace_id": "4bf92f...", "policy_excerpt": "Refunds are available for 30 days from delivery.", "agent_output": "You can request a refund within 14 days.", "severity": "high", "confidence": 0.91, "created_at": "2026-09-01T14:23:05Z" } ], "total": 1 } ``` ### Examples **Python** ```python f = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/alignment/flags", params={"limit": 25, "include_unclear": False}) for x in f["flags"]: print(f"[{x['severity']}] {x['trace_id']}: {x['agent_output'][:70]}") ``` **TypeScript** ```ts const f = await evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/alignment/flags", { limit: 25, include_unclear: false }, ); f.flags.forEach((x: any) => console.log(`[${x.severity}] ${x.trace_id}: ${x.agent_output.slice(0, 70)}`)); ``` --- ## Alignment by user ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/alignment/by-user ``` Alignment flags aggregated per end user (`opexia.end_user`), showing which users' sessions most often produce policy divergence. **Auth:** API key or JWT · **Role:** `viewer` ### Examples **Python** ```python bu = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/alignment/by-user") print(bu) ``` **TypeScript** ```ts const bu = await evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/alignment/by-user"); console.log(bu); ``` > Requires that your instrumentation sets the `opexia.end_user` span attribute. > Without it this endpoint returns an empty result rather than an error. # Fleet Monitor Live topology and health for a **fleet** — a group of agents running together. Unlike trace endpoints, which are historical, the fleet API is near-real-time and backed by a streaming WebSocket. --- ## List fleets ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/fleet/fleets ``` **Auth:** API key or JWT · **Role:** `viewer` ### Response `200` — *not schema-modelled* ```json { "fleets": [ { "fleet_id": "checkout-fleet", "agent_count": 12, "status": "active", "last_seen": "2026-09-01T14:23:05Z", "open_flag_count": 2 } ] } ``` ### Examples **Python** ```python fl = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/fleet/fleets") for f in fl["fleets"]: print(f"{f['fleet_id']:20} {f['agent_count']} agents {f['open_flag_count']} flags") ``` **TypeScript** ```ts const fl = await evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/fleet/fleets"); fl.fleets.forEach((f: any) => console.log(`${f.fleet_id.padEnd(20)} ${f.agent_count} agents ${f.open_flag_count} flags`)); ``` --- ## Fleet graph ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/fleet/{fleet_id}/graph ``` The fleet's agent topology — nodes and the call edges between them, with token and latency rollups per node. **Auth:** API key or JWT · **Role:** `viewer` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `org` | string | ✅ | Organization slug | | `ws` | string | ✅ | Workspace slug | | `fleet_id` | string | ✅ | Fleet identifier | ### Response `200` — *not schema-modelled* ```json { "fleet_id": "checkout-fleet", "nodes": [ { "id": "router", "kind": "agent", "call_count": 8120, "p95_latency_ms": 210, "total_tokens": 410233, "status": "ok" }, { "id": "pricing", "kind": "agent", "call_count": 6402, "p95_latency_ms": 4900, "total_tokens": 992010, "status": "flagged" } ], "edges": [{ "from": "router", "to": "pricing", "call_count": 6402 }] } ``` ### Examples **Python** ```python g = evigauge.get(f"/v1/observ/orgs/{{org}}/workspaces/{{ws}}/fleet/{fleet_id}/graph") for n in g["nodes"]: mark = "⚠" if n["status"] != "ok" else " " print(f"{mark} {n['id']:15} p95={n['p95_latency_ms']}ms tokens={n['total_tokens']:,}") ``` **TypeScript** ```ts const g = await evigauge.get( `/v1/observ/orgs/{org}/workspaces/{ws}/fleet/${fleetId}/graph`); g.nodes.forEach((n: any) => console.log(`${n.status !== "ok" ? "⚠" : " "} ${n.id.padEnd(15)} p95=${n.p95_latency_ms}ms tokens=${n.total_tokens}`)); ``` --- ## Fleet flags ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/fleet/{fleet_id}/flags ``` Health flags raised on the fleet. Flags **auto-resolve**: after 3 consecutive clean spans, a resolve event is emitted and the flag leaves the `active` set. **Auth:** API key or JWT · **Role:** `viewer` ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `status` | string | ➖ | `active` | `active` — one row per still-open flag episode; `all` — the raw append-only event log (fires **and** resolves), newest first | ### Examples **Python** ```python flags = evigauge.get( f"/v1/observ/orgs/{{org}}/workspaces/{{ws}}/fleet/{fleet_id}/flags", params={"status": "active"}, ) print(flags) ``` **TypeScript** ```ts const flags = await evigauge.get( `/v1/observ/orgs/{org}/workspaces/{ws}/fleet/${fleetId}/flags`, { status: "active" }); console.log(flags); ``` --- ## Pause / resume / clear a fleet ```http POST /v1/observ/orgs/{org}/workspaces/{ws}/fleet/{fleet_id}/pause POST /v1/observ/orgs/{org}/workspaces/{ws}/fleet/{fleet_id}/resume POST /v1/observ/orgs/{org}/workspaces/{ws}/fleet/{fleet_id}/clear ``` **Auth:** API key or JWT · **Role:** `admin` · **Request body:** none | Action | Effect | |---|---| | `pause` | Stops fleet monitoring and flag evaluation. Spans still ingest. | | `resume` | Resumes monitoring from live state. | | `clear` | Discards accumulated fleet state and starts a fresh topology. | > **`pause` does not stop your agents.** Evigauge never controls your runtime — it > pauses *monitoring* only. And `clear` is destructive to accumulated topology > state; the underlying spans are untouched. ### Examples **Python** ```python base = f"/v1/observ/orgs/{{org}}/workspaces/{{ws}}/fleet/{fleet_id}" evigauge.post(f"{base}/pause") evigauge.post(f"{base}/resume") evigauge.post(f"{base}/clear") # destructive: resets topology state ``` **TypeScript** ```ts const base = `/v1/observ/orgs/{org}/workspaces/{ws}/fleet/${fleetId}`; await evigauge.post(`${base}/pause`); await evigauge.post(`${base}/resume`); await evigauge.post(`${base}/clear`); // destructive: resets topology state ``` --- ## Mint a WebSocket ticket ```http POST /v1/observ/orgs/{org}/workspaces/{ws}/fleet/ws-ticket ``` Mints a **short-lived, single-use** ticket for the fleet WebSocket. Browsers cannot set an `Authorization` header on a native `WebSocket`, so the ticket rides the query string instead. **Auth:** API key or JWT · **Role:** `viewer` · **Request body:** none ### Response `200` — *not schema-modelled* ```json { "ticket": "wst_01J8XYZ...", "expires_in": 60 } ``` > The ticket is consumed on first use. Mint a fresh one for every connection — > including every reconnect after a drop. --- ## Fleet WebSocket stream ``` WSS wss://api.opexia.dev/ws/fleet/{workspace_id}?ticket={ticket} ``` Streams live fleet delta frames. **Note the parameter is `{workspace_id}` — a UUID, not the slug used elsewhere in the fleet API.** The ticket is validated *before* the connection is accepted. | Close code | Meaning | |---|---| | `1008` | Ticket missing, expired, already used, or bound to a different workspace | ### Examples **Python** ```python import json, httpx, websockets # pip install websockets def mint_ticket(): return evigauge.post("/v1/observ/orgs/{org}/workspaces/{ws}/fleet/ws-ticket")["ticket"] async def stream(workspace_uuid: str): # A ticket is single-use: mint a new one for every connect and reconnect. url = f"wss://api.opexia.dev/ws/fleet/{workspace_uuid}?ticket={mint_ticket()}" async with websockets.connect(url) as ws: async for raw in ws: frame = json.loads(raw) print(frame["type"], frame.get("nodes", [])) ``` **TypeScript** ```ts async function mintTicket(): Promise { const { ticket } = await evigauge.post( "/v1/observ/orgs/{org}/workspaces/{ws}/fleet/ws-ticket"); return ticket; } async function stream(workspaceUuid: string) { // A ticket is single-use: mint a new one for every connect and reconnect. const ws = new WebSocket( `wss://api.opexia.dev/ws/fleet/${workspaceUuid}?ticket=${await mintTicket()}`); ws.onmessage = (e) => { const frame = JSON.parse(e.data); console.log(frame.type, frame.nodes); }; ws.onclose = (e) => { if (e.code === 1008) console.error("Ticket rejected — mint a fresh one."); else setTimeout(() => stream(workspaceUuid), 1000); // reconnect with a new ticket }; } ``` # Ship Check Prompt versioning, typing, and evaluation — plus a zero-LLM PR gate. Ship Check treats prompts as versioned artifacts so a prompt change is reviewable like a code change, and catches regressions before they ship. --- ## List prompts ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/shipcheck/prompts ``` Every prompt key seen in the window, with its current version and usage. **Auth:** API key or JWT · **Role:** `viewer` ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `days` | integer | ➖ | `7` | Lookback window in days | ### Response `200` — *not schema-modelled* ```json { "prompts": [ { "prompt_key": "checkout.system", "current_version": "v7", "version_count": 7, "call_count": 8120, "total_cost_usd": 141.20, "last_seen": "2026-09-01T14:23:05Z" } ] } ``` ### Examples **Python** ```python p = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/shipcheck/prompts", params={"days": 30}) for x in p["prompts"]: print(f"{x['prompt_key']:30} {x['current_version']:6} ${x['total_cost_usd']:.2f}") ``` **TypeScript** ```ts const p = await evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/shipcheck/prompts", { days: 30 }); p.prompts.forEach((x: any) => console.log(`${x.prompt_key.padEnd(30)} ${x.current_version} $${x.total_cost_usd.toFixed(2)}`)); ``` --- ## List prompt versions ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/shipcheck/prompts/{prompt_key}/versions ``` **Auth:** API key or JWT · **Role:** `viewer` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `org` | string | ✅ | Organization slug | | `ws` | string | ✅ | Workspace slug | | `prompt_key` | string | ✅ | Prompt identifier, URL-encoded if it contains `/` | ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `days` | integer | ➖ | `30` | Lookback window in days | ### Examples **Python** ```python from urllib.parse import quote key = quote("checkout.system", safe="") v = evigauge.get( f"/v1/observ/orgs/{{org}}/workspaces/{{ws}}/shipcheck/prompts/{key}/versions", params={"days": 90}, ) print(v) ``` **TypeScript** ```ts const key = encodeURIComponent("checkout.system"); const v = await evigauge.get( `/v1/observ/orgs/{org}/workspaces/{ws}/shipcheck/prompts/${key}/versions`, { days: 90 }); console.log(v); ``` --- ## Get one prompt version ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/shipcheck/prompts/{prompt_key}/versions/{version} ``` **Auth:** API key or JWT · **Role:** `viewer` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `org`, `ws` | string | ✅ | Slugs | | `prompt_key` | string | ✅ | Prompt identifier | | `version` | string | ✅ | Version identifier, e.g. `v7` | ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `days` | integer | ➖ | `30` | Lookback window in days | ### Examples **Python** ```python d = evigauge.get( f"/v1/observ/orgs/{{org}}/workspaces/{{ws}}/shipcheck/prompts/{key}/versions/v7" ) print(d) ``` **TypeScript** ```ts const d = await evigauge.get( `/v1/observ/orgs/{org}/workspaces/{ws}/shipcheck/prompts/${key}/versions/v7`); console.log(d); ``` --- ## Diff two prompt versions ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/shipcheck/prompts/{prompt_key}/diff ``` Text diff plus the behavioural delta — cost, latency and grade change — between two versions. **Auth:** API key or JWT · **Role:** `viewer` ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `from_version` | string | ✅ | — | Baseline version | | `to_version` | string | ✅ | — | Comparison version | | `days` | integer | ➖ | `30` | Lookback window in days | ### Examples **Python** ```python diff = evigauge.get( f"/v1/observ/orgs/{{org}}/workspaces/{{ws}}/shipcheck/prompts/{key}/diff", params={"from_version": "v6", "to_version": "v7"}, ) print(diff) ``` **TypeScript** ```ts const diff = await evigauge.get( `/v1/observ/orgs/{org}/workspaces/{ws}/shipcheck/prompts/${key}/diff`, { from_version: "v6", to_version: "v7" }, ); console.log(diff); ``` ### Errors | Status | Cause | |---|---| | `422` | `from_version` or `to_version` omitted — both are required | | `404` | A named version does not exist inside the `days` window | --- ## Pipeline diff ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/shipcheck/pipeline/diff ``` What changed across the whole prompt pipeline in the window — every prompt whose version moved, and the aggregate effect. **Auth:** API key or JWT · **Role:** `viewer` ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `days` | integer | ➖ | `7` | Lookback window in days | ### Examples **Python** ```python pd = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/shipcheck/pipeline/diff", params={"days": 14}) print(pd) ``` **TypeScript** ```ts const pd = await evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/shipcheck/pipeline/diff", { days: 14 }); console.log(pd); ``` --- ## Reprice a prompt ```http POST /v1/observ/orgs/{org}/workspaces/{ws}/shipcheck/reprice ``` Recompute what a prompt's historical traffic **would have cost** on a different model. A pure what-if — it replays nothing and changes nothing. **Auth:** API key or JWT · **Role:** `viewer` ### Request body — `RepriceBody` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `prompt_key` | string | ✅ | — | Prompt to reprice | | `model` | string | ✅ | — | Target model id, e.g. `claude-sonnet-4-6` | | `provider` | string | ➖ | `""` | Provider hint when the model id is ambiguous | | `days` | integer | ➖ | `30` | Window of historical traffic to reprice | ### Examples **Python** ```python rp = evigauge.post("/v1/observ/orgs/{org}/workspaces/{ws}/shipcheck/reprice", json={ "prompt_key": "checkout.system", "model": "claude-sonnet-4-6", "days": 30, }) print(rp) ``` **TypeScript** ```ts const rp = await evigauge.post( "/v1/observ/orgs/{org}/workspaces/{ws}/shipcheck/reprice", { prompt_key: "checkout.system", model: "claude-sonnet-4-6", days: 30 }, ); console.log(rp); ``` --- ## Submit a candidate prompt ```http POST /v1/observ/shipcheck/candidate ``` Evaluate a **proposed** prompt against the live baseline before shipping it. This is the endpoint the CI gate calls on a pull request. **Auth:** API key only (`x-opexia-api-key` / `dr_*`) — this endpoint does **not** accept a JWT, because it is designed for CI. Workspace scope comes from the key, so there is no `{org}` / `{ws}` in the path. ### Request body — `CandidateBody` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `candidate_text` | string | ✅ | — | The proposed prompt text | | `prompt_key` | string \| null | ➖ | `null` | Existing prompt to compare against | | `name` | string \| null | ➖ | `null` | Human label for the candidate | | `model` | string | ➖ | `""` | Model the candidate targets | | `provider` | string | ➖ | `""` | Provider hint | | `days` | integer | ➖ | `7` | Baseline window in days | | `policy` | object | ➖ | `{}` | Gate policy overrides (thresholds) | ### Examples **Python** ```python import httpx, os, pathlib candidate = pathlib.Path("prompts/checkout.system.md").read_text() r = httpx.post( "https://api.opexia.dev/v1/observ/shipcheck/candidate", headers={"x-opexia-api-key": os.environ["OPEXIA_API_KEY"]}, json={ "candidate_text": candidate, "prompt_key": "checkout.system", "model": "claude-opus-4-6", "days": 7, }, timeout=120, ) r.raise_for_status() result = r.json() # Fail the CI job when the gate does not pass. if result.get("verdict") != "pass": raise SystemExit(f"Ship Check failed: {result}") ``` **TypeScript** ```ts import { readFile } from "node:fs/promises"; const candidate = await readFile("prompts/checkout.system.md", "utf8"); const res = await fetch("https://api.opexia.dev/v1/observ/shipcheck/candidate", { method: "POST", headers: { "x-opexia-api-key": process.env.OPEXIA_API_KEY!, "content-type": "application/json", }, body: JSON.stringify({ candidate_text: candidate, prompt_key: "checkout.system", model: "claude-opus-4-6", days: 7, }), }); const result = await res.json(); // Fail the CI job when the gate does not pass. if (result.verdict !== "pass") { console.error("Ship Check failed:", result); process.exit(1); } ``` > The `opexia shipcheck` CLI wraps this endpoint with policy loading and PR > comment rendering — see the [SDK reference](./evigauge-sdk.md#opexia-shipcheck). # Claude Code Analytics Engineering-productivity rollups derived from Claude Code telemetry ingested via the Claude Code OTLP endpoints. --- ## Claude Code usage ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/claude-code/usage ``` Per-employee usage rollup. **Auth:** API key or JWT · **Role:** `viewer` ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `period` | string | ➖ | `day` | `day` \| `week` \| `month` | ### Examples **Python** ```python u = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/claude-code/usage", params={"period": "week"}) print(u) ``` **TypeScript** ```ts const u = await evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/claude-code/usage", { period: "week" }); console.log(u); ``` --- ## Claude Code commits ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/claude-code/commits ``` Recent Claude Code-authored commits. **Auth:** API key or JWT · **Role:** `viewer` ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `limit` | integer | ➖ | `50` | Max commits returned | ### Examples **Python** ```python c = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/claude-code/commits", params={"limit": 20}) print(c) ``` **TypeScript** ```ts const c = await evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/claude-code/commits", { limit: 20 }); console.log(c); ``` --- ## Claude Code top files ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/claude-code/top-files ``` Most-edited files across Claude Code commits — a churn signal. **Auth:** API key or JWT · **Role:** `viewer` ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `period` | string | ➖ | `week` | `day` \| `week` \| `month` | | `limit` | integer | ➖ | `50` | Max files returned | ### Examples **Python** ```python tf = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/claude-code/top-files", params={"period": "month", "limit": 25}) print(tf) ``` **TypeScript** ```ts const tf = await evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/claude-code/top-files", { period: "month", limit: 25 }, ); console.log(tf); ``` # Workspace Settings Three independent toggles. Each is a `GET` + `PATCH` pair; the `PATCH` body carries exactly one field. | Setting | Read role | Write role | Default | |---|---|---|---| | `capture-text` | `viewer` | `admin` | `false` | | `broaden-sources` | `viewer` | `admin` | `false` | | `cost-provider` | `viewer` | `admin` | provider default | --- ## Capture text ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/settings/capture-text PATCH /v1/observ/orgs/{org}/workspaces/{ws}/settings/capture-text ``` Controls whether prompt and completion **text** is stored, as opposed to metrics and metadata only. > **Privacy control — fail-closed.** This defaults to `false`. While it is off, > the SDK does not send text bodies at all, so no text is stored. Turn it on only > when your data-handling policy permits storing prompt content, and pair it with > [redaction rules](#redaction). The SDK reads this flag at `init()` via > [`GET /v1/workspaces/{ws}/sdk-config`](#sdk-config) and fails closed if the > lookup fails. **PATCH request body — `_CaptureTextBody`** | Field | Type | Required | Description | |---|---|---|---| | `capture_text` | boolean | ✅ | Enable or disable text capture | ### Examples **Python** ```python cur = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/settings/capture-text") print("capture_text:", cur["capture_text"]) evigauge.patch("/v1/observ/orgs/{org}/workspaces/{ws}/settings/capture-text", json={"capture_text": True}) ``` **TypeScript** ```ts const cur = await evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/settings/capture-text"); console.log("capture_text:", cur.capture_text); await evigauge.patch("/v1/observ/orgs/{org}/workspaces/{ws}/settings/capture-text", { capture_text: true }); ``` > Changing this affects **new** spans only. Text already stored is unaffected; > to remove it, use [retention](#data-retention). --- ## Broaden sources ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/settings/broaden-sources PATCH /v1/observ/orgs/{org}/workspaces/{ws}/settings/broaden-sources ``` Allows the sources engine to verify claims against the **open web**, not only your internal knowledge base. **PATCH request body — `_BroadenSourcesBody`** | Field | Type | Required | Description | |---|---|---|---| | `broaden_sources` | boolean | ✅ | Enable open-web verification | ### Examples **Python** ```python evigauge.patch("/v1/observ/orgs/{org}/workspaces/{ws}/settings/broaden-sources", json={"broaden_sources": True}) ``` **TypeScript** ```ts await evigauge.patch("/v1/observ/orgs/{org}/workspaces/{ws}/settings/broaden-sources", { broaden_sources: true }); ``` > Open-web verification requires a search credential — see > [search credentials](#set-search-credential). Without one, the engine stays in > closed-KB mode regardless of this flag. --- ## Cost provider ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/settings/cost-provider PATCH /v1/observ/orgs/{org}/workspaces/{ws}/settings/cost-provider ``` Selects the pricing table used to convert token counts into dollars. **PATCH request body — `_CostProviderBody`** | Field | Type | Required | Description | |---|---|---|---| | `cost_provider` | string | ✅ | Provider key for the pricing table. **Free-form**, not an enum — the value is lower-cased and truncated to 64 characters. Send `""` to clear it and fall back to the default. | The `GET` returns both what you set and what is actually in force: ```json { "cost_provider": "anthropic", "effective_provider": "anthropic" } ``` `effective_provider` is the resolved value — your `cost_provider` when set, otherwise the deployment default (`openrouter` unless `OPEXIA_DEFAULT_COST_PROVIDER` overrides it). **Read `effective_provider`, not `cost_provider`, when displaying what pricing is in use**; `cost_provider` is `""` on a workspace that has never set one. ### Examples **Python** ```python cur = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/settings/cost-provider") print("in force:", cur["effective_provider"]) evigauge.patch("/v1/observ/orgs/{org}/workspaces/{ws}/settings/cost-provider", json={"cost_provider": "anthropic"}) # Clear the override and fall back to the deployment default. evigauge.patch("/v1/observ/orgs/{org}/workspaces/{ws}/settings/cost-provider", json={"cost_provider": ""}) ``` **TypeScript** ```ts const cur = await evigauge.get( "/v1/observ/orgs/{org}/workspaces/{ws}/settings/cost-provider"); console.log("in force:", cur.effective_provider); await evigauge.patch("/v1/observ/orgs/{org}/workspaces/{ws}/settings/cost-provider", { cost_provider: "anthropic" }); // Clear the override and fall back to the deployment default. await evigauge.patch("/v1/observ/orgs/{org}/workspaces/{ws}/settings/cost-provider", { cost_provider: "" }); ``` > This changes how **future** costs are computed. Historical figures keep the > pricing version in force when they were calculated, so past reports stay stable. # Engine Settings Per-workspace on/off switches for each reconstruction engine. Turning an engine off stops its compute and its cost. --- ## Get engine settings ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/engine-settings ``` **Auth:** API key or JWT · **Role:** `viewer` ### Response `200` — *not schema-modelled* ```json { "engines": { "content_reliability": true, "decision_trace": true, "decomposition": true, "sources": true, "alignment": false, "correlation": true, "savings": true, "external_crosscheck": false, "fleet_monitor": true } } ``` > **Absence means enabled.** An engine key missing from the map is ON. The gate is > an AND of this setting and any org-level gate, so an engine runs only when both > allow it. ### Examples **Python** ```python es = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/engine-settings") for name, on in es["engines"].items(): print(f"{'✅' if on else '⬜'} {name}") ``` **TypeScript** ```ts const es = await evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/engine-settings"); Object.entries(es.engines).forEach(([n, on]) => console.log(on ? "✅" : "⬜", n)); ``` --- ## Set engine settings ```http PUT /v1/observ/orgs/{org}/workspaces/{ws}/engine-settings ``` **Auth:** API key or JWT · **Role:** `admin` ### Request body — `EngineSettingsIn` | Field | Type | Required | Description | |---|---|---|---| | `engines` | object | ✅ | Map of engine name → boolean | ### Examples **Python** ```python evigauge.put("/v1/observ/orgs/{org}/workspaces/{ws}/engine-settings", json={ "engines": { "alignment": True, "external_crosscheck": False, # ships dark by default } }) ``` **TypeScript** ```ts await evigauge.put("/v1/observ/orgs/{org}/workspaces/{ws}/engine-settings", { engines: { alignment: true, external_crosscheck: false, // ships dark by default }, }); ``` # Data Retention Customer-settable retention, from **24 hours to 1 year**. The sweeper deletes expired spans on an hourly bucketed schedule and is **fail-closed**: if it cannot confirm what it is about to delete, it deletes nothing. Shortening retention is destructive and therefore uses a **two-step preview-and-confirm** flow. ## Allowed periods `hours` is an **allow-list, not a free integer.** Only these nine values are accepted; anything else is rejected. That is deliberate — a free-form field is how a fat-fingered `1` erases a year of telemetry, and there is no undo. | `hours` | Label | |---|---| | `24` | 24 hours | | `48` | 48 hours | | `168` | 7 days | | `336` | 14 days | | `720` | 30 days | | `1440` | 60 days | | `2160` | 90 days | | `4320` | 180 days | | `8760` | 1 year | > **Below 48 hours**, the daily roll-ups (drift, correlation, savings) run less > often than data expires, so those panels thin out. The choice is not blocked — > it is labelled. Surface that warning in your UI when a user selects `24`. --- ## Get retention ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/retention ``` **Auth:** API key or JWT · **Role:** `viewer` ### Response `200` — *not schema-modelled* ```json { "enabled": true, "hours": 720, "effective_hours": 720, "updated_at": "2026-08-08T10:00:00Z" } ``` ### Examples **Python** ```python r = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/retention") print(f"retention: {r['hours']}h ({r['hours'] // 24}d), enabled={r['enabled']}") ``` **TypeScript** ```ts const r = await evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/retention"); console.log(`retention: ${r.hours}h (${Math.floor(r.hours / 24)}d), enabled=${r.enabled}`); ``` --- ## Preview a retention change ```http POST /v1/observ/orgs/{org}/workspaces/{ws}/retention:preview ``` Reports **how much data a proposed period would delete**, and returns a `confirm_token` you must pass to the `PUT`. Read-only — nothing is deleted. **Auth:** API key or JWT · **Role:** `admin` ### Request body — `RetentionPreviewIn` | Field | Type | Required | Description | |---|---|---|---| | `hours` | integer | ✅ | Proposed retention period. Must be one of the [nine allowed values](#allowed-periods). | > **The preview body takes only `hours`.** It is a different model from the `PUT` > body — do not send `enabled` or `confirm_token` here. ### Response `200` — *not schema-modelled* ```json { "hours": 168, "spans_to_delete": 4820113, "traces_affected": 118402, "oldest_deleted": "2026-07-02T00:00:00Z", "confirm_token": "rtc_01J8XYZ..." } ``` ### Examples **Python** ```python prev = evigauge.post("/v1/observ/orgs/{org}/workspaces/{ws}/retention:preview", json={"hours": 168}) print(f"Would delete {prev['spans_to_delete']:,} spans " f"across {prev['traces_affected']:,} traces") ``` **TypeScript** ```ts const prev = await evigauge.post( "/v1/observ/orgs/{org}/workspaces/{ws}/retention:preview", { hours: 168 }); console.log(`Would delete ${prev.spans_to_delete} spans across ${prev.traces_affected} traces`); ``` --- ## Set retention ```http PUT /v1/observ/orgs/{org}/workspaces/{ws}/retention ``` **Auth:** API key or JWT · **Role:** `admin` ### Request body — `RetentionIn` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `enabled` | boolean | ✅ | — | Whether retention sweeping is active | | `hours` | integer \| null | ➖ | `null` | Retention period. Must be one of the [nine allowed values](#allowed-periods). | | `confirm_token` | string \| null | ➖ | `null` | Token from `:preview`. **Required when the change shortens retention.** | ### Examples **Python** ```python # Shortening retention is destructive → preview first, then confirm. prev = evigauge.post("/v1/observ/orgs/{org}/workspaces/{ws}/retention:preview", json={"hours": 168}) if prev["spans_to_delete"] > 0: print(f"About to delete {prev['spans_to_delete']:,} spans.") evigauge.put("/v1/observ/orgs/{org}/workspaces/{ws}/retention", json={ "enabled": True, "hours": 168, "confirm_token": prev["confirm_token"], }) ``` **TypeScript** ```ts // Shortening retention is destructive → preview first, then confirm. const prev = await evigauge.post( "/v1/observ/orgs/{org}/workspaces/{ws}/retention:preview", { hours: 168 }); if (prev.spans_to_delete > 0) { console.log(`About to delete ${prev.spans_to_delete} spans.`); } await evigauge.put("/v1/observ/orgs/{org}/workspaces/{ws}/retention", { enabled: true, hours: 168, confirm_token: prev.confirm_token, }); ``` ### Errors | Status | Cause | |---|---| | `409` | `confirm_token` is stale — the underlying data changed. Re-run `:preview`. | | `422` | `hours` is not one of the nine [allowed values](#allowed-periods), or `confirm_token` missing on a shortening change | | `403` | Role below `admin` | > **Lengthening** retention needs no token. **Shortening** always does, because it > destroys data. # Redaction Workspace-scoped regex rules applied to captured text **at ingestion**, before storage. Redaction is the companion control to [`capture-text`](#capture-text): capture the text you need, then strip the parts you must not store. --- ## List redaction rules ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/redaction-rules ``` **Auth:** API key or JWT · **Role:** `viewer` ### Examples **Python** ```python rules = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/redaction-rules") print(rules) ``` **TypeScript** ```ts const rules = await evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/redaction-rules"); console.log(rules); ``` --- ## Create a redaction rule ```http POST /v1/observ/orgs/{org}/workspaces/{ws}/redaction-rules ``` **Auth:** API key or JWT · **Role:** `admin` ### Request body — `RedactionRuleCreate` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `name` | string | ✅ | — | Human-readable rule name | | `pattern` | string | ✅ | — | Regular expression to match | | `replacement` | string | ➖ | `[REDACTED:CUSTOM]` | Text substituted for each match | ### Examples **Python** ```python evigauge.post("/v1/observ/orgs/{org}/workspaces/{ws}/redaction-rules", json={ "name": "customer-email", "pattern": r"[\w.+-]+@[\w-]+\.[\w.]+", "replacement": "[REDACTED:EMAIL]", }) ``` **TypeScript** ```ts await evigauge.post("/v1/observ/orgs/{org}/workspaces/{ws}/redaction-rules", { name: "customer-email", pattern: "[\\w.+-]+@[\\w-]+\\.[\\w.]+", replacement: "[REDACTED:EMAIL]", }); ``` > Rules apply to **newly ingested** spans only — they are not retroactive. > Test the pattern before creating it: an over-broad regex silently destroys > content you may need, and the original is never stored. # LLM & Search Credentials Credentials are stored with **AES-256-GCM envelope encryption**. Only the last four characters and a status are ever readable back — the plaintext key cannot be retrieved through the API by anyone, including an org owner. These credentials let Evigauge's own reconstruction engines call an LLM on your behalf. **Evigauge never proxies or routes your application's LLM calls.** --- ## Get LLM credentials ```http GET /v1/observ/orgs/{org}/llm-credentials ``` **Auth:** API key or JWT · **Role:** `dev` (org-scoped) ### Response `200` — *not schema-modelled* ```json { "configured": true, "provider": "anthropic", "model_id": "claude-opus-4-6", "api_key_last4": "9f2c", "api_base": null, "fallback_enabled": true } ``` ### Examples **Python** ```python c = evigauge.get("/v1/observ/orgs/{org}/llm-credentials") print(f"{c['provider']}/{c['model_id']} key=…{c['api_key_last4']}") ``` **TypeScript** ```ts const c = await evigauge.get("/v1/observ/orgs/{org}/llm-credentials"); console.log(`${c.provider}/${c.model_id} key=…${c.api_key_last4}`); ``` --- ## Set LLM credentials ```http PUT /v1/observ/orgs/{org}/llm-credentials ``` Any provider reachable through LiteLLM is supported. **Auth:** API key or JWT · **Role:** `dev` (org-scoped) ### Request body — `CredentialIn` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `provider` | string | ✅ | — | Provider key, e.g. `anthropic`, `openai`, `bedrock` | | `model_id` | string | ✅ | — | Model identifier for that provider | | `api_key` | string \| null | ➖ | `null` | Secret. Omit to keep the stored key and change only other fields. | | `api_base` | string \| null | ➖ | `null` | Custom endpoint for self-hosted or proxied providers | | `provider_config` | object | ➖ | `{}` | Extra provider-specific settings | ### Examples **Python** ```python evigauge.put("/v1/observ/orgs/{org}/llm-credentials", json={ "provider": "anthropic", "model_id": "claude-opus-4-6", "api_key": "sk-ant-...", }) # Rotate the model but keep the stored key: omit api_key entirely. evigauge.put("/v1/observ/orgs/{org}/llm-credentials", json={ "provider": "anthropic", "model_id": "claude-sonnet-4-6", }) ``` **TypeScript** ```ts await evigauge.put("/v1/observ/orgs/{org}/llm-credentials", { provider: "anthropic", model_id: "claude-opus-4-6", api_key: "sk-ant-...", }); // Rotate the model but keep the stored key: omit api_key entirely. await evigauge.put("/v1/observ/orgs/{org}/llm-credentials", { provider: "anthropic", model_id: "claude-sonnet-4-6", }); ``` --- ## Delete LLM credentials ```http DELETE /v1/observ/orgs/{org}/llm-credentials ``` **Auth:** API key or JWT · **Role:** `dev` (org-scoped) > Engines that need an LLM stop producing new results once the credential is > removed. Existing results are retained. ### Examples **Python** ```python evigauge.delete("/v1/observ/orgs/{org}/llm-credentials") ``` **TypeScript** ```ts await evigauge.del("/v1/observ/orgs/{org}/llm-credentials"); ``` --- ## Toggle credential fallback ```http PATCH /v1/observ/orgs/{org}/llm-credentials/fallback ``` When enabled, engines fall back to the platform default model if your configured provider is unreachable. **Auth:** API key or JWT · **Role:** `dev` (org-scoped) ### Request body — `FallbackIn` | Field | Type | Required | Description | |---|---|---|---| | `enabled` | boolean | ✅ | Enable or disable fallback | ### Examples **Python** ```python evigauge.patch("/v1/observ/orgs/{org}/llm-credentials/fallback", json={"enabled": True}) ``` **TypeScript** ```ts await evigauge.patch("/v1/observ/orgs/{org}/llm-credentials/fallback", { enabled: true }); ``` --- ## Get search credential ```http GET /v1/observ/orgs/{org}/search-credentials ``` The web-search key (Exa) used for open-web claim verification. **Auth:** API key or JWT · **Role:** `dev` (org-scoped) ### Examples **Python** ```python print(evigauge.get("/v1/observ/orgs/{org}/search-credentials")) ``` **TypeScript** ```ts console.log(await evigauge.get("/v1/observ/orgs/{org}/search-credentials")); ``` --- ## Set search credential ```http PUT /v1/observ/orgs/{org}/search-credentials ``` **Auth:** API key or JWT · **Role:** `dev` (org-scoped) ### Request body — `SearchCredentialIn` | Field | Type | Required | Description | |---|---|---|---| | `api_key` | string | ✅ | Search-provider API key | ### Examples **Python** ```python evigauge.put("/v1/observ/orgs/{org}/search-credentials", json={"api_key": "exa-..."}) ``` **TypeScript** ```ts await evigauge.put("/v1/observ/orgs/{org}/search-credentials", { api_key: "exa-..." }); ``` > Required for [broaden-sources](#broaden-sources) and the External Cross-Check > engine. Without it both stay in closed-KB mode. --- ## Delete search credential ```http DELETE /v1/observ/orgs/{org}/search-credentials ``` **Auth:** API key or JWT · **Role:** `dev` (org-scoped) ### Examples **Python** ```python evigauge.delete("/v1/observ/orgs/{org}/search-credentials") ``` **TypeScript** ```ts await evigauge.del("/v1/observ/orgs/{org}/search-credentials"); ``` # Aggregate & Spend Org-level views that cut across every workspace. --- ## Org aggregate ```http GET /v1/observ/orgs/{org}/aggregate ``` One metric aggregated across the organization, grouped by a dimension. **Auth:** API key or JWT · **Role:** `dev` (org-scoped) ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `metric` | string | ✅ | — | `reliability_avg` \| `cost_sum` \| `latency_p95` | | `groupby` | string | ➖ | `workspace` | `workspace` \| `project` | | `since` | string \| null | ➖ | `null` | ISO 8601 UTC lower bound | ### Examples **Python** ```python agg = evigauge.get("/v1/observ/orgs/{org}/aggregate", params={ "metric": "cost_sum", "groupby": "workspace", "since": "2026-08-01T00:00:00Z", }) print(agg) ``` **TypeScript** ```ts const agg = await evigauge.get("/v1/observ/orgs/{org}/aggregate", { metric: "cost", groupby: "workspace", since: "2026-08-01T00:00:00Z", }); console.log(agg); ``` ### Errors | Status | Cause | |---|---| | `422` | `metric` omitted, or a `metric` / `groupby` value outside the accepted set (both are pattern-validated) | --- ## LLM spend summary ```http GET /v1/observ/orgs/{org}/observability/llm-spend/summary ``` Org-wide LLM spend for one period. **Auth:** API key or JWT · **Role:** `dev` (org-scoped) ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `period` | string | ➖ | `month` | `day` \| `week` \| `month` | ### Examples **Python** ```python s = evigauge.get("/v1/observ/orgs/{org}/observability/llm-spend/summary", params={"period": "month"}) print(s) ``` **TypeScript** ```ts const s = await evigauge.get( "/v1/observ/orgs/{org}/observability/llm-spend/summary", { period: "month" }); console.log(s); ``` --- ## LLM spend timeseries ```http GET /v1/observ/orgs/{org}/observability/llm-spend/timeseries ``` **Auth:** API key or JWT · **Role:** `dev` (org-scoped) ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `days` | integer | ➖ | `30` | Days of history | ### Examples **Python** ```python ts = evigauge.get("/v1/observ/orgs/{org}/observability/llm-spend/timeseries", params={"days": 90}) print(ts) ``` **TypeScript** ```ts const ts = await evigauge.get( "/v1/observ/orgs/{org}/observability/llm-spend/timeseries", { days: 90 }); console.log(ts); ``` # Audit Log Every privileged action — credential changes, retention changes, member role changes, workspace deletion — writes an immutable audit row. --- ## List audit log ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/audit-log ``` **Auth:** API key or JWT · **Role:** `admin` ### Query parameters | Name | Type | Required | Default | Description | |---|---|---|---|---| | `limit` | integer | ➖ | `100` | Page size | | `cursor` | string \| null | ➖ | `null` | Opaque cursor from `next_cursor` | ### Response `200` — *not schema-modelled* ```json { "data": [ { "ts": "2026-09-01T14:23:05Z", "endpoint": "/v1/observ/orgs/acme/workspaces/prod/retention", "method": "PUT", "status_code": 200, "actor_user_id": "3f2b1c8e-...", "actor_api_key_prefix": null, "request_id": "9f2c4b1e...", "metadata": { "old_hours": 720, "new_hours": 168 } } ], "meta": { "request_id": "…", "schema_version": "1.0", "next_cursor": "eyJ0cyI6..." } } ``` > **`next_cursor` is nested inside `meta` here**, unlike `/traces` where it is at > the top level. Row keys are computed and not schema-modelled — confirm against > a live response. `actor_user_id` is populated for JWT-authenticated actions; `actor_api_key_prefix` is populated for API-key actions. Exactly one is set. ### Examples **Python** ```python for row in evigauge.paginate( "/v1/observ/orgs/{org}/workspaces/{ws}/audit-log", limit=100 ): print(row["ts"], row["action"], row["target"]) ``` **TypeScript** ```ts for await (const row of evigauge.paginate( "/v1/observ/orgs/{org}/workspaces/{ws}/audit-log", { limit: 100 }, )) { console.log(row.ts, row.action, row.target); } ``` # Health & Monitoring ## Service health ```http GET /health ``` Liveness probe. **Unauthenticated.** Available on both the Read API and the Ingest API. ### Response `200` ```json { "status": "ok" } ``` ### Examples **Python** ```python import httpx print(httpx.get("https://api.opexia.dev/health").json()) print(httpx.get("https://ingest.opexia.dev/health").json()) ``` **TypeScript** ```ts console.log(await (await fetch("https://api.opexia.dev/health")).json()); console.log(await (await fetch("https://ingest.opexia.dev/health")).json()); ``` --- ## Prometheus metrics ```http GET /metrics ``` Prometheus exposition format. **Unauthenticated.** Available on both services. --- ## Ingestion health ```http GET /v1/observ/orgs/{org}/workspaces/{ws}/health/ingestion ``` Whether spans are actually arriving and being processed for this workspace, and whether anything is dead-lettering. **This is the first endpoint to check when dashboard panels are empty.** **Auth:** API key or JWT · **Role:** `admin` ### Response `200` — *not schema-modelled* ```json { "status": "ok", "last_span_at": "2026-09-01T14:23:05Z", "spans_last_hour": 41203, "dead_letter_count_last_hour": 0, "engines": { "content_reliability": { "status": "ok", "last_run_at": "2026-09-01T14:20:00Z" }, "sources": { "status": "stalled","last_run_at": "2026-09-01T09:02:00Z" } } } ``` ### Examples **Python** ```python h = evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/health/ingestion") if h["dead_letter_count_last_hour"] > 0: print(f"⚠ {h['dead_letter_count_last_hour']} spans dead-lettered — check span validity") for name, e in h["engines"].items(): if e["status"] != "ok": print(f"⚠ engine {name} is {e['status']} (last run {e['last_run_at']})") ``` **TypeScript** ```ts const h = await evigauge.get("/v1/observ/orgs/{org}/workspaces/{ws}/health/ingestion"); if (h.dead_letter_count_last_hour > 0) console.log(`⚠ ${h.dead_letter_count_last_hour} spans dead-lettered — check span validity`); Object.entries(h.engines).forEach(([n, e]) => { if (e.status !== "ok") console.log(`⚠ engine ${n} is ${e.status} (last run ${e.last_run_at})`); }); ``` **Reading this endpoint when panels are empty:** | Symptom | Meaning | |---|---| | `spans_last_hour` is `0` | Nothing is arriving — check the SDK endpoint, key, and network path | | `dead_letter_count_last_hour > 0` | Spans arrive but fail validation — check required `opexia.*` attributes | | Spans arrive, an engine is `stalled` | Ingestion is fine; the engine is failing — most often a missing or expired LLM credential | # Organizations & Members **These endpoints use UUIDs (`{org_id}`), not slugs.** --- ## Get current user ```http GET /v1/auth/me ``` The authenticated principal and their memberships. The natural first call for a dashboard session. **Auth:** any credential · **Role:** any authenticated principal ### Response `200` — *not schema-modelled* ```json { "user_id": "3f2b1c8e-9a4d-4f11-b7c2-1e5a8d3f6b90", "email": "dev@acme.com", "auth_method": "jwt", "memberships": [ { "org_id": "8c1d...", "org_slug": "acme", "role": "admin", "workspaces": [{ "workspace_id": "3f2b...", "slug": "prod", "name": "Production" }] } ] } ``` ### Examples **Python** ```python me = evigauge.get("/v1/auth/me") for m in me["memberships"]: print(f"{m['org_slug']} ({m['role']}):", ", ".join(w["slug"] for w in m["workspaces"])) ``` **TypeScript** ```ts const me = await evigauge.get("/v1/auth/me"); me.memberships.forEach((m: any) => console.log(`${m.org_slug} (${m.role}):`, m.workspaces.map((w: any) => w.slug).join(", "))); ``` > Use this to map the slugs used by `/v1/observ/*` to the UUIDs used by > `/v1/orgs/*` and `/v1/workspaces/*`. --- ## Get organization ```http GET /v1/orgs/{org_id} ``` **Auth:** any credential · **Role:** `dev` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `org_id` | string (UUID) | ✅ | Organization ID | ### Examples **Python** ```python org = evigauge.get(f"/v1/orgs/{org_id}") print(org) ``` **TypeScript** ```ts console.log(await evigauge.get(`/v1/orgs/${orgId}`)); ``` --- ## Update organization ```http PATCH /v1/orgs/{org_id} ``` **Auth:** any credential · **Role:** `admin` ### Request body — `OrgPatchBody` | Field | Type | Required | Description | |---|---|---|---| | `name` | string \| null | ➖ | Display name | | `contact_email` | string \| null | ➖ | Billing / contact address | | `team_count` | string \| null | ➖ | Team size band | All fields optional — send only what changes. ### Examples **Python** ```python evigauge.patch(f"/v1/orgs/{org_id}", json={"name": "Acme Corporation"}) ``` **TypeScript** ```ts await evigauge.patch(`/v1/orgs/${orgId}`, { name: "Acme Corporation" }); ``` --- ## List members ```http GET /v1/orgs/{org_id}/members ``` **Auth:** any credential · **Role:** `dev` ### Examples **Python** ```python members = evigauge.get(f"/v1/orgs/{org_id}/members") print(members) ``` **TypeScript** ```ts console.log(await evigauge.get(`/v1/orgs/${orgId}/members`)); ``` --- ## Change a member's role ```http PATCH /v1/orgs/{org_id}/members/{user_id} ``` **Auth:** any credential · **Role:** `admin` ### Request body — `MemberPatchBody` | Field | Type | Required | Description | |---|---|---|---| | `new_role` | string | ✅ | `viewer` \| `dev` \| `admin` \| `owner` | | `target_workspace_id` | string \| null | ➖ | Scope the role to one workspace instead of the whole org | ### Examples **Python** ```python evigauge.patch(f"/v1/orgs/{org_id}/members/{user_id}", json={"new_role": "dev"}) ``` **TypeScript** ```ts await evigauge.patch(`/v1/orgs/${orgId}/members/${userId}`, { new_role: "dev" }); ``` > Promoting to `owner` does not demote the existing owner — use > [transfer ownership](#transfer-ownership) for that. --- ## Remove a member ```http DELETE /v1/orgs/{org_id}/members/{user_id} ``` **Auth:** any credential · **Role:** `admin` ### Examples **Python** ```python evigauge.delete(f"/v1/orgs/{org_id}/members/{user_id}") ``` **TypeScript** ```ts await evigauge.del(`/v1/orgs/${orgId}/members/${userId}`); ``` --- ## Transfer ownership ```http POST /v1/orgs/{org_id}/transfer-ownership ``` **Auth:** any credential · **Role:** `owner` — the only `owner`-gated endpoint. ### Request body — `TransferOwnershipBody` | Field | Type | Required | Description | |---|---|---|---| | `to_user_id` | string | ✅ | UUID of the member who becomes owner | ### Examples **Python** ```python evigauge.post(f"/v1/orgs/{org_id}/transfer-ownership", json={"to_user_id": new_owner_uuid}) ``` **TypeScript** ```ts await evigauge.post(`/v1/orgs/${orgId}/transfer-ownership`, { to_user_id: newOwnerUuid }); ``` > **Irreversible by the caller.** After this succeeds you are `admin`, not > `owner`, and only the new owner can transfer it back. # Invitations & Onboarding ## Create an invitation ```http POST /v1/orgs/{org_id}/invitations ``` **Auth:** any credential · **Role:** `admin` ### Request body — `InvitationCreateBody` | Field | Type | Required | Description | |---|---|---|---| | `email` | string | ✅ | Invitee's email address | | `role` | string | ✅ | Role to grant on acceptance | | `workspace_id` | string \| null | ➖ | Scope the invitation to one workspace | ### Examples **Python** ```python inv = evigauge.post(f"/v1/orgs/{org_id}/invitations", json={ "email": "newdev@acme.com", "role": "dev", }) print(inv) ``` **TypeScript** ```ts const inv = await evigauge.post(`/v1/orgs/${orgId}/invitations`, { email: "newdev@acme.com", role: "dev", }); console.log(inv); ``` --- ## List invitations ```http GET /v1/orgs/{org_id}/invitations ``` **Auth:** any credential · **Role:** `admin` ### Examples **Python** ```python print(evigauge.get(f"/v1/orgs/{org_id}/invitations")) ``` **TypeScript** ```ts console.log(await evigauge.get(`/v1/orgs/${orgId}/invitations`)); ``` --- ## Revoke an invitation ```http DELETE /v1/orgs/{org_id}/invitations/{invitation_id} ``` **Auth:** any credential · **Role:** `admin` ### Examples **Python** ```python evigauge.delete(f"/v1/orgs/{org_id}/invitations/{invitation_id}") ``` **TypeScript** ```ts await evigauge.del(`/v1/orgs/${orgId}/invitations/${invitationId}`); ``` --- ## Accept an invitation ```http POST /v1/auth/accept-invite ``` **Auth:** any authenticated principal — the accepting user must be signed in. ### Request body — `AcceptBody` | Field | Type | Required | Description | |---|---|---|---| | `token` | string | ✅ | Invitation token from the invite email | ### Examples **Python** ```python evigauge.post("/v1/auth/accept-invite", json={"token": invite_token}) ``` **TypeScript** ```ts await evigauge.post("/v1/auth/accept-invite", { token: inviteToken }); ``` --- ## Onboard an organization ```http POST /v1/onboarding/org ``` First-run creation of an organization for a newly signed-up user. **Auth:** any authenticated principal ### Request body — `OrgCreateBody` | Field | Type | Required | Description | |---|---|---|---| | `org_name` | string | ✅ | Organization display name | | `contact_email` | string | ✅ | Primary contact address | | `team_count` | string \| null | ➖ | Team size band | ### Examples **Python** ```python org = evigauge.post("/v1/onboarding/org", json={ "org_name": "Acme Corp", "contact_email": "ops@acme.com", "team_count": "11-50", }) print(org) ``` **TypeScript** ```ts const org = await evigauge.post("/v1/onboarding/org", { org_name: "Acme Corp", contact_email: "ops@acme.com", team_count: "11-50", }); console.log(org); ``` --- ## Onboard a workspace ```http POST /v1/onboarding/workspace ``` **Auth:** any authenticated principal ### Request body | Field | Type | Required | Description | |---|---|---|---| | `org_id` | string | ✅ | Organization UUID from the onboarding step above | | `workspace_name` | string | ✅ | Workspace display name | > Note this differs from [`POST /v1/orgs/{org_id}/workspaces`](#create-a-workspace), > which takes `name` (and optional `slug`) with the org in the **path**. The > onboarding variant takes `org_id` in the **body**. ### Examples **Python** ```python ws = evigauge.post("/v1/onboarding/workspace", json={ "org_id": org_id, "workspace_name": "Production", }) print(ws) ``` **TypeScript** ```ts const ws = await evigauge.post("/v1/onboarding/workspace", { org_id: orgId, workspace_name: "Production", }); console.log(ws); ``` # Workspaces Workspaces can be created through **two** paths, which differ in identifier type and request shape. | Endpoint | Org identified by | Body fields | Role | |---|---|---|---| | `POST /v1/orgs/{org_id}/workspaces` | **UUID** in path | `name`, optional `slug` | `admin` | | `POST /v1/observ/orgs/{org}/workspaces` | **slug** in path | `slug` **and** `name`, both required | `admin` | --- ## Create a workspace ```http POST /v1/orgs/{org_id}/workspaces ``` **Auth:** any credential · **Role:** `admin` ### Request body | Field | Type | Required | Description | |---|---|---|---| | `name` | string | ✅ | Display name | | `slug` | string \| null | ➖ | URL slug. Derived from `name` when omitted. | ### Examples **Python** ```python ws = evigauge.post(f"/v1/orgs/{org_id}/workspaces", json={"name": "Staging", "slug": "staging"}) print(ws) ``` **TypeScript** ```ts const ws = await evigauge.post(`/v1/orgs/${orgId}/workspaces`, { name: "Staging", slug: "staging" }); console.log(ws); ``` ### Errors | Status | Cause | |---|---| | `409` | Slug already taken within the organization | --- ## Create a workspace (observ path) ```http POST /v1/observ/orgs/{org}/workspaces ``` **Auth:** any credential · **Role:** `admin` ### Request body — `WorkspaceCreate` | Field | Type | Required | Description | |---|---|---|---| | `slug` | string | ✅ | URL slug — **required here**, unlike the UUID-path variant | | `name` | string | ✅ | Display name | ### Examples **Python** ```python evigauge.post("/v1/observ/orgs/{org}/workspaces", json={"slug": "staging", "name": "Staging"}) ``` **TypeScript** ```ts await evigauge.post("/v1/observ/orgs/{org}/workspaces", { slug: "staging", name: "Staging" }); ``` --- ## List workspaces ```http GET /v1/orgs/{org_id}/workspaces ``` **Auth:** any credential · **Role:** `dev` ### Examples **Python** ```python # Resolve a slug to the UUID that /v1/workspaces/* needs. wss = evigauge.get(f"/v1/orgs/{org_id}/workspaces") by_slug = {w["slug"]: w["id"] for w in wss} print(by_slug["prod"]) ``` **TypeScript** ```ts // Resolve a slug to the UUID that /v1/workspaces/* needs. const wss = await evigauge.get(`/v1/orgs/${orgId}/workspaces`); const bySlug = Object.fromEntries(wss.map((w: any) => [w.slug, w.id])); console.log(bySlug.prod); ``` --- ## Get a workspace ```http GET /v1/workspaces/{ws_id} ``` **Auth:** any credential · **Role:** `dev` ### Examples **Python** ```python print(evigauge.get(f"/v1/workspaces/{ws_id}")) ``` **TypeScript** ```ts console.log(await evigauge.get(`/v1/workspaces/${wsId}`)); ``` --- ## Update a workspace ```http PATCH /v1/workspaces/{ws_id} ``` **Auth:** any credential · **Role:** `admin` ### Request body — `WorkspacePatchBody` | Field | Type | Required | Description | |---|---|---|---| | `name` | string \| null | ➖ | New display name | | `slug` | string \| null | ➖ | New URL slug | ### Examples **Python** ```python evigauge.patch(f"/v1/workspaces/{ws_id}", json={"name": "Production (EU)"}) ``` **TypeScript** ```ts await evigauge.patch(`/v1/workspaces/${wsId}`, { name: "Production (EU)" }); ``` > **Changing `slug` breaks every hard-coded `/v1/observ/orgs/{org}/workspaces/{ws}/…` > URL** in your integrations and dashboards. Rename deliberately. --- ## Delete a workspace ```http DELETE /v1/workspaces/{ws_id} ``` Soft-deletes the workspace. **Auth:** any credential · **Role:** `admin` ### Examples **Python** ```python evigauge.delete(f"/v1/workspaces/{ws_id}") ``` **TypeScript** ```ts await evigauge.del(`/v1/workspaces/${wsId}`); ``` > The workspace disappears from listings and its API keys stop working > immediately. Deletion is soft, but there is no self-serve undelete endpoint. # API Keys Keys are scoped to exactly one workspace. **The secret is shown once, at creation.** --- ## Create an API key ```http POST /v1/workspaces/{ws_id}/keys ``` **Auth:** any credential · **Role:** `dev` ### Path parameters | Name | Type | Required | Description | |---|---|---|---| | `ws_id` | string (UUID) | ✅ | Workspace ID — **UUID, not slug** | ### Request body — `KeyCreateBody` | Field | Type | Required | Description | |---|---|---|---| | `name` | string | ✅ | Human label, e.g. `ci-pipeline` | | `env` | string | ✅ | `live` or `test`. The secret is shaped `{kind}_{env}_{token}`. | | `kind` | string | ✅ | `opx` (header auth), `dr` (bearer auth), or `cck` (Claude Code telemetry key) | ### Response `200` — *not schema-modelled* ```json { "key_id": "k_01J8XYZ...", "name": "ci-pipeline", "env": "live", "kind": "opx", "secret": "opx_live_xxxxxxxxxxxxxxxxxxxx", "prefix": "opx_live_xxxx", "created_at": "2026-09-01T14:23:05Z" } ``` > **`secret` appears in this response and nowhere else, ever.** Store it in your > secret manager immediately. If lost, revoke the key and mint a new one. ### Examples **Python** ```python key = evigauge.post(f"/v1/workspaces/{ws_id}/keys", json={ "name": "ci-pipeline", "env": "live", "kind": "opx", }) # Capture the secret NOW — it is never returned again. save_to_secret_manager(key["secret"]) print("key_id:", key["key_id"], "prefix:", key["prefix"]) ``` **TypeScript** ```ts const key = await evigauge.post(`/v1/workspaces/${wsId}/keys`, { name: "ci-pipeline", env: "live", kind: "opx", }); // Capture the secret NOW — it is never returned again. await saveToSecretManager(key.secret); console.log("key_id:", key.key_id, "prefix:", key.prefix); ``` --- ## List API keys ```http GET /v1/workspaces/{ws_id}/keys ``` Returns key metadata and prefixes. **Never returns secrets.** **Auth:** any credential · **Role:** `dev` ### Examples **Python** ```python for k in evigauge.get(f"/v1/workspaces/{ws_id}/keys"): print(f"{k['name']:20} {k['prefix']:18} {k['created_at']}") ``` **TypeScript** ```ts const keys = await evigauge.get(`/v1/workspaces/${wsId}/keys`); keys.forEach((k: any) => console.log(`${k.name.padEnd(20)} ${k.prefix.padEnd(18)} ${k.created_at}`)); ``` --- ## Revoke an API key ```http DELETE /v1/workspaces/{ws_id}/keys/{key_id} ``` Revocation is **immediate and irreversible**. In-flight requests using the key begin failing with `401` at once. **Auth:** any credential · **Role:** `dev` — but revoking a key you do not own additionally requires `admin` or `owner`. ### Examples **Python** ```python evigauge.delete(f"/v1/workspaces/{ws_id}/keys/{key_id}") ``` **TypeScript** ```ts await evigauge.del(`/v1/workspaces/${wsId}/keys/${keyId}`); ``` **Zero-downtime rotation:** ```python # 1. Mint the replacement. new = evigauge.post(f"/v1/workspaces/{ws_id}/keys", json={"name": "ci-pipeline-v2", "env": "live", "kind": "opx"}) # 2. Deploy new["secret"] everywhere and confirm traffic is flowing on it. # 3. Only then revoke the old key. evigauge.delete(f"/v1/workspaces/{ws_id}/keys/{old_key_id}") ``` # Platform Auth (Enterprise SSO) Registers an enterprise identity provider so your own IdP's JWTs are accepted. Each issuer is bound to exactly one organization. --- ## Register an auth provider ```http POST /v1/observ/platform/auth-providers ``` **Auth:** platform key only — send `x-opexia-platform-key`. Neither a workspace API key nor a user JWT is accepted. Contact your Evigauge representative to obtain the platform key. ### Headers | Name | Type | Required | Description | |---|---|---|---| | `x-opexia-platform-key` | string | ✅ | Platform-level registration key | ### Request body — `EnterpriseIn` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `org_name` | string | ✅ | — | Organization this issuer is bound to | | `kind` | string | ✅ | — | Provider kind, e.g. `oidc` | | `issuer` | string | ✅ | — | JWT `iss` claim value | | `jwks_url` | string | ✅ | — | JWKS endpoint for signature verification | | `audience` | string | ✅ | — | Expected `aud` claim value | | `algorithms` | string | ➖ | `RS256` | Accepted signing algorithms | | `claim_map` | object | ➖ | `{}` | Maps your IdP's claim names to Evigauge's expected fields | | `admin_email` | string | ✅ | — | Initial administrator for the organization | ### Examples **Python** ```python import httpx r = httpx.post( "https://api.opexia.dev/v1/observ/platform/auth-providers", headers={"x-opexia-platform-key": PLATFORM_KEY}, json={ "org_name": "Acme Corp", "kind": "oidc", "issuer": "https://acme.okta.com", "jwks_url": "https://acme.okta.com/oauth2/v1/keys", "audience": "evigauge", "algorithms": "RS256", "claim_map": {"email": "preferred_username"}, "admin_email": "ops@acme.com", }, timeout=30, ) r.raise_for_status() print(r.json()) ``` **TypeScript** ```ts const res = await fetch("https://api.opexia.dev/v1/observ/platform/auth-providers", { method: "POST", headers: { "x-opexia-platform-key": PLATFORM_KEY, "content-type": "application/json" }, body: JSON.stringify({ org_name: "Acme Corp", kind: "oidc", issuer: "https://acme.okta.com", jwks_url: "https://acme.okta.com/oauth2/v1/keys", audience: "evigauge", algorithms: "RS256", claim_map: { email: "preferred_username" }, admin_email: "ops@acme.com", }), }); console.log(await res.json()); ``` ### Errors | Status | Cause | |---|---| | `403` | `x-opexia-platform-key` missing or does not match. The comparison is constant-time. | | `503` | Platform registration is **disabled** — no platform key is configured on the deployment. This is the default state. | > A `503` here is configuration, not an outage: registration stays off until a > platform key is provisioned. Contact your Evigauge representative to enable it. # Ingest API (OTLP) **Base URL: `https://ingest.opexia.dev`** — a separate service from everything above. Most integrators never call these directly: the [SDK](./evigauge-sdk.md) or a standard OpenTelemetry collector does it. Call them directly only when writing a custom exporter. --- ## Ingest spans ```http POST /v1/otlp/traces ``` The main ingestion endpoint. Also aliased at `POST /v1/traces`. **Auth:** API key only (`x-opexia-api-key` or `dr_*` bearer). The key determines the destination workspace — there is no org/workspace in the path. **Returns `202 Accepted`.** Ingestion is asynchronous: a `202` means the batch was accepted for processing, not that every span passed validation. Check [ingestion health](#ingestion-health) to confirm nothing dead-lettered. ### Content type **JSON only.** Sending protobuf returns **`415`**. > If you use an OpenTelemetry collector, its `otlphttp` exporter **must** set > `encoding: json`. The protobuf default is the single most common cause of a > silent "no spans arriving". > > ```yaml > exporters: > otlphttp: > endpoint: https://ingest.opexia.dev > encoding: json # REQUIRED — protobuf returns 415 > headers: > x-opexia-api-key: opx_live_xxxxxxxxxxxxxxxxxxxx > ``` ### Request body Two shapes are accepted: 1. **Standard OTLP/JSON** — a `resourceSpans` envelope. Stock OpenTelemetry exporters produce this, and it works with zero `opexia.*` attributes: the API key backfills org, workspace, and project. 2. **Flat span array** — a bare JSON list of span objects, for custom exporters. **OTLP/JSON envelope:** ```json { "resourceSpans": [{ "resource": { "attributes": [ { "key": "service.name", "value": { "stringValue": "checkout-agent" } } ]}, "scopeSpans": [{ "spans": [{ "traceId": "4bf92f3577b34da6a3ce929d0e0e4736", "spanId": "00f067aa0ba902b7", "name": "llm.call", "kind": 3, "startTimeUnixNano": "1756736585000000000", "endTimeUnixNano": "1756736593420000000", "status": { "code": 1 }, "attributes": [ { "key": "opexia.project_id", "value": { "stringValue": "checkout-agent" } }, { "key": "gen_ai.request.model", "value": { "stringValue": "claude-opus-4-6" } } ] }] }] }] } ``` **Flat array:** ```json [{ "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "00f067aa0ba902b7", "name": "llm.call", "start_time_unix_ns": 1756736585000000000, "end_time_unix_ns": 1756736593420000000, "status_code": "OK", "attributes": { "opexia.project_id": "checkout-agent" } }] ``` ### Examples **Python** ```python import httpx, os, time now = time.time_ns() batch = [{ "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "00f067aa0ba902b7", "name": "llm.call", "start_time_unix_ns": now, "end_time_unix_ns": now + 1_000_000_000, "status_code": "OK", "attributes": { "opexia.project_id": "checkout-agent", "gen_ai.request.model": "claude-opus-4-6", }, }] r = httpx.post( "https://ingest.opexia.dev/v1/otlp/traces", headers={ "x-opexia-api-key": os.environ["OPEXIA_API_KEY"], "content-type": "application/json", # JSON only — protobuf returns 415 }, json=batch, timeout=30, ) print(r.status_code) # 202 = accepted for async processing ``` **TypeScript** ```ts const now = BigInt(Date.now()) * 1_000_000n; const batch = [{ trace_id: "4bf92f3577b34da6a3ce929d0e0e4736", span_id: "00f067aa0ba902b7", name: "llm.call", start_time_unix_ns: Number(now), end_time_unix_ns: Number(now + 1_000_000_000n), status_code: "OK", attributes: { "opexia.project_id": "checkout-agent", "gen_ai.request.model": "claude-opus-4-6", }, }]; const res = await fetch("https://ingest.opexia.dev/v1/otlp/traces", { method: "POST", headers: { "x-opexia-api-key": process.env.OPEXIA_API_KEY!, "content-type": "application/json", // JSON only — protobuf returns 415 }, body: JSON.stringify(batch), }); console.log(res.status); // 202 = accepted for async processing ``` ### Errors | Status | Cause | |---|---| | `400` | Body is not valid JSON, or a span is malformed (`resourceSpans` not a list, attributes not an object) | | `401` | Missing or invalid API key | | `415` | Protobuf content type — send JSON | --- ## Claude Code ingestion ```http POST /v1/otlp/claude-code/v1/traces POST /v1/otlp/claude-code/v1/metrics POST /v1/otlp/claude-code/v1/logs POST /v1/otlp/claude-code/v1/activity ``` Dedicated endpoints for Claude Code telemetry, feeding the [Claude Code Analytics](#claude-code-analytics) endpoints. Same auth and JSON-only rules as `/v1/otlp/traces`. Point Claude Code's OTLP configuration at these endpoints — see [`claude-code-enterprise-setup.md`](../claude-code-enterprise-setup.md). --- ## SDK config ```http GET /v1/workspaces/{ws}/sdk-config ``` Returns the workspace's SDK-relevant configuration. **Called automatically by `opexia.trace.init()`** — you rarely call it yourself. **Auth:** API key ### Response `200` ```json { "capture_text": false } ``` ### Examples **Python** ```python import httpx, os cfg = httpx.get( f"https://ingest.opexia.dev/v1/workspaces/{ws_id}/sdk-config", headers={"x-opexia-api-key": os.environ["OPEXIA_API_KEY"]}, timeout=5, ).json() print("capture_text:", cfg["capture_text"]) ``` **TypeScript** ```ts const cfg = await (await fetch( `https://ingest.opexia.dev/v1/workspaces/${wsId}/sdk-config`, { headers: { "x-opexia-api-key": process.env.OPEXIA_API_KEY! } }, )).json(); console.log("capture_text:", cfg.capture_text); ``` > **Fail-closed.** If this lookup fails, the SDK sets `capture_text = false` and > sends no text bodies. A network problem can never cause text to be captured > against policy. --- ## Whoami ```http GET /v1/auth/whoami ``` Resolves a credential to its org and workspace. The fastest way to verify a key works and is pointed at the workspace you expect. **Auth:** API key ### Examples **Python** ```python import httpx, os who = httpx.get( "https://ingest.opexia.dev/v1/auth/whoami", headers={"x-opexia-api-key": os.environ["OPEXIA_API_KEY"]}, timeout=10, ).json() print(who) ``` **TypeScript** ```ts const who = await (await fetch("https://ingest.opexia.dev/v1/auth/whoami", { headers: { "x-opexia-api-key": process.env.OPEXIA_API_KEY! }, })).json(); console.log(who); ``` # Appendix ## Complete endpoint index ### Read API — `https://api.opexia.dev` | Method | Path | Role | |---|---|---| | `GET` | `/health` | public | | `GET` | `/metrics` | public | | `GET` | `/v1/auth/me` | authenticated | | `POST` | `/v1/auth/accept-invite` | authenticated | | `POST` | `/v1/onboarding/org` | authenticated | | `POST` | `/v1/onboarding/workspace` | authenticated | | `GET` | `/v1/orgs/{org_id}` | `dev` | | `PATCH` | `/v1/orgs/{org_id}` | `admin` | | `GET` | `/v1/orgs/{org_id}/members` | `dev` | | `PATCH` | `/v1/orgs/{org_id}/members/{user_id}` | `admin` | | `DELETE` | `/v1/orgs/{org_id}/members/{user_id}` | `admin` | | `POST` | `/v1/orgs/{org_id}/transfer-ownership` | `owner` | | `POST` | `/v1/orgs/{org_id}/invitations` | `admin` | | `GET` | `/v1/orgs/{org_id}/invitations` | `admin` | | `DELETE` | `/v1/orgs/{org_id}/invitations/{invitation_id}` | `admin` | | `POST` | `/v1/orgs/{org_id}/workspaces` | `admin` | | `GET` | `/v1/orgs/{org_id}/workspaces` | `dev` | | `GET` | `/v1/workspaces/{ws_id}` | `dev` | | `PATCH` | `/v1/workspaces/{ws_id}` | `admin` | | `DELETE` | `/v1/workspaces/{ws_id}` | `admin` | | `POST` | `/v1/workspaces/{ws_id}/keys` | `dev` | | `GET` | `/v1/workspaces/{ws_id}/keys` | `dev` | | `DELETE` | `/v1/workspaces/{ws_id}/keys/{key_id}` | `dev` | | `GET` | `/v1/observ/orgs/{org}/aggregate` | `dev` | | `GET` | `/v1/observ/orgs/{org}/llm-credentials` | `dev` | | `PUT` | `/v1/observ/orgs/{org}/llm-credentials` | `dev` | | `DELETE` | `/v1/observ/orgs/{org}/llm-credentials` | `dev` | | `PATCH` | `/v1/observ/orgs/{org}/llm-credentials/fallback` | `dev` | | `GET` | `/v1/observ/orgs/{org}/search-credentials` | `dev` | | `PUT` | `/v1/observ/orgs/{org}/search-credentials` | `dev` | | `DELETE` | `/v1/observ/orgs/{org}/search-credentials` | `dev` | | `GET` | `/v1/observ/orgs/{org}/observability/llm-spend/summary` | `dev` | | `GET` | `/v1/observ/orgs/{org}/observability/llm-spend/timeseries` | `dev` | | `POST` | `/v1/observ/orgs/{org}/workspaces` | `admin` | | `GET` | `/v1/observ/orgs/{org}/workspaces/{ws}/traces` | `viewer` | | `GET` | `/v1/observ/orgs/{org}/workspaces/{ws}/traces/{trace_id}` | `viewer` | | `GET` | `…/traces/{trace_id}/content-reliability` | `viewer` | | `GET` | `…/traces/{trace_id}/decision-trace` | `viewer` | | `GET` | `…/traces/{trace_id}/decomposition` | `viewer` | | `GET` | `…/traces/{trace_id}/sources-matrix` | `viewer` | | `GET` | `…/traces/{trace_id}/optimization` | `viewer` | | `GET` | `…/usage/summary` | `viewer` | | `GET` | `…/usage/timeseries` | `viewer` | | `GET` | `…/usage/latency` | `viewer` | | `GET` | `…/usage/latency/phase/traces` | `viewer` | | `GET` | `…/cost-budget` | `viewer` | | `PUT` | `…/cost-budget` | `admin` | | `GET` | `…/savings/recommendations` | `viewer` | | `GET` | `…/savings/trace/{trace_id}` | `viewer` | | `GET` | `…/optimization/flags` | `viewer` | | `GET` | `…/optimization/over-provisioned` | `viewer` | | `GET` | `…/sources` | `viewer` | | `GET` | `…/drift/current` | `viewer` | | `GET` | `…/drift/history` | `viewer` | | `GET` | `…/alignment/policy` | `viewer` | | `PUT` | `…/alignment/policy` | `admin` | | `POST` | `…/alignment/docs` | `admin` | | `DELETE` | `…/alignment/docs/{doc_id}` | `admin` | | `GET` | `…/alignment/flags` | `viewer` | | `GET` | `…/alignment/by-user` | `viewer` | | `GET` | `…/fleet/fleets` | `viewer` | | `GET` | `…/fleet/{fleet_id}/graph` | `viewer` | | `GET` | `…/fleet/{fleet_id}/flags` | `viewer` | | `POST` | `…/fleet/{fleet_id}/pause` | `admin` | | `POST` | `…/fleet/{fleet_id}/resume` | `admin` | | `POST` | `…/fleet/{fleet_id}/clear` | `admin` | | `POST` | `…/fleet/ws-ticket` | `viewer` | | `GET` | `…/shipcheck/prompts` | `viewer` | | `GET` | `…/shipcheck/prompts/{prompt_key}/versions` | `viewer` | | `GET` | `…/shipcheck/prompts/{prompt_key}/versions/{version}` | `viewer` | | `GET` | `…/shipcheck/prompts/{prompt_key}/diff` | `viewer` | | `GET` | `…/shipcheck/pipeline/diff` | `viewer` | | `POST` | `…/shipcheck/reprice` | `viewer` | | `POST` | `/v1/observ/shipcheck/candidate` | API key | | `GET` | `…/claude-code/usage` | `viewer` | | `GET` | `…/claude-code/commits` | `viewer` | | `GET` | `…/claude-code/top-files` | `viewer` | | `GET` | `…/settings/capture-text` | `viewer` | | `PATCH` | `…/settings/capture-text` | `admin` | | `GET` | `…/settings/broaden-sources` | `viewer` | | `PATCH` | `…/settings/broaden-sources` | `admin` | | `GET` | `…/settings/cost-provider` | `viewer` | | `PATCH` | `…/settings/cost-provider` | `admin` | | `GET` | `…/engine-settings` | `viewer` | | `PUT` | `…/engine-settings` | `admin` | | `GET` | `…/retention` | `viewer` | | `PUT` | `…/retention` | `admin` | | `POST` | `…/retention:preview` | `admin` | | `GET` | `…/redaction-rules` | `viewer` | | `POST` | `…/redaction-rules` | `admin` | | `GET` | `…/audit-log` | `admin` | | `GET` | `…/health/ingestion` | `admin` | | `POST` | `/v1/observ/platform/auth-providers` | platform key | `…` abbreviates `/v1/observ/orgs/{org}/workspaces/{ws}`. ### Ingest API — `https://ingest.opexia.dev` | Method | Path | Auth | |---|---|---| | `GET` | `/health` | public | | `GET` | `/metrics` | public | | `GET` | `/v1/auth/whoami` | API key | | `GET` | `/v1/workspaces/{ws}/sdk-config` | API key | | `POST` | `/v1/otlp/traces` (alias `/v1/traces`) | API key | | `POST` | `/v1/otlp/claude-code/v1/traces` | API key | | `POST` | `/v1/otlp/claude-code/v1/metrics` | API key | | `POST` | `/v1/otlp/claude-code/v1/logs` | API key | | `POST` | `/v1/otlp/claude-code/v1/activity` | API key | ### WebSocket | Protocol | Path | Auth | |---|---|---| | `WSS` | `/ws/fleet/{workspace_id}?ticket=…` | Single-use ticket | --- ## Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| | `401` on every call | Wrong header for the key family | `opx_*` → `x-opexia-api-key`; `dr_*` → `Authorization: Bearer` | | `403` with a valid key | Key is bound to a different workspace | Mint a key in the target workspace | | `404` on a valid-looking path | Slug used where a UUID is required | See [slugs vs UUIDs](#path-parameters-slugs-vs-uuids) | | `404` on the OpenAPI doc | Fetching `/openapi.json` at the root | Use `/v1/observ/openapi.json` | | `415` on ingest | Protobuf OTLP | Set `encoding: json` on the collector's `otlphttp` exporter | | `202` on ingest but no traces | Spans dead-lettering after acceptance | Check [ingestion health](#ingestion-health) | | Empty engine panels, traces present | Engine disabled, or missing LLM credential | Check [engine settings](#get-engine-settings) and [LLM credentials](#get-llm-credentials) | | Trace `404`s that used to work | Aged out of the retention window | Check [retention](#get-retention) | | No prompt/completion text stored | `capture_text` is off (the default) | See [capture text](#capture-text) | | WebSocket closes with `1008` | Ticket expired or already used | Mint a fresh ticket per connection | | `429` under load | Per-org per-minute limit | Back off exponentially — the client helpers do this | --- ## See also - **[SDK Reference](./evigauge-sdk.md)** — `opexia-trace` for Python, plus OTLP setup for TypeScript and Node. - **[MCP & Claude Code Plugin](./evigauge-mcp-and-plugin.md)** — the `pxcore` MCP server and the `/opexia:*` Claude Code skills. --- # MCP & Plugin The pxcore MCP server and the Claude Code plugin. # Evigauge MCP Server & Claude Code Plugin Reference for the developer tooling that runs **on your machine**: the `pxcore` MCP server (token compression via imaged context) and the Evigauge Claude Code plugin (`/opexia:*` skills). **Companion documents:** [REST API Reference](./evigauge-rest-api.md) · [SDK Reference](./evigauge-sdk.md) --- ## Naming: Evigauge vs `opexia` The product is **Evigauge** (formerly **OpexIA**). The rename is a brand change only. **Every identifier in commands, configs, and code stays `opexia` / `pxcore`:** | Surface | Value — do not rename | |---|---| | Plugin name | `opexia` | | Slash commands | `/opexia:instrument`, `/opexia:secure`, `/opexia:log`, `/opexia:compress` | | MCP server name | `pxcore` | | MCP tools | `pxcore_read`, `pxcore_run`, `pxcore_grep`, `pxcore_view` | | Console scripts | `pxcore-mcp`, `pxcore-proxy`, `opexia` | | Python import | `import pxcore` | | Env var | `PXCORE_MODEL` | | PyPI package | `opexia-trace` (ships `pxcore` too) | --- ## What is in here | Component | Runs | Network | Purpose | |---|---|---|---| | **`pxcore` MCP server** | Locally, stdio or HTTP | None | Token compression — dense reference output becomes images the model reads with native vision | | **`pxcore-proxy`** | Locally, in-path | To your LLM provider | Same compression, applied transparently to Claude Code | | **`/opexia:instrument`** | In Claude Code | Yes — verifies a span lands | Detects your stack and instruments it correctly | | **`/opexia:secure`** | In Claude Code | **Zero egress** | Prompt-injection audit + mitigations under review | | **`/opexia:log`** | In Claude Code | **Zero egress** | Committed, agent-queryable relational dev-log | | **`/opexia:compress`** | In Claude Code | None | Set up, calibrate, and verify pxcore | > **Privacy.** `/opexia:secure` and `/opexia:log` are strictly local and make no > network call — security findings are a disclosure, so they must not travel, and > the dev-log is your codebase's history. `pxcore` compression contains no LLM in > its path. Only `/opexia:instrument` talks to Evigauge, and only to confirm your > test span arrived. # Part 1 — The pxcore MCP Server ## What it does `pxcore` renders **dense reference content** — logs, large JSON, files you are reading but not editing — as PNG images the model reads with native vision, instead of as text tokens. Exact content you must reproduce verbatim (IDs, paths, hashes, code you are about to edit) is **always kept as text**. Measured reduction on the imaged subset is roughly **64%** on code and logs. ### The safety model Compression is worthless if the model misreads what it was given, so pxcore is conservative by construction: 1. **No LLM in the compression path.** The decision is deterministic — nothing can hallucinate about what to compress. 2. **Default-OFF per model** until a calibration battery proves that model reads imaged content accurately. 3. **Three content classes, each gated by its own measured fidelity:** | Class | Example | Gate | |---|---|---| | `exact` | IDs, hashes, code to edit | **Never imaged** | | `gist` | Code, logs, varied output read to comprehend | Images when the model's *gist* score clears the floor | | `lookup` | Keyed record-sets where you find a value by key | Images only at a much higher bar | This split matters: live calibration showed a model's imaged-reading fidelity is not one number. Models read *gist* content reliably (arithmetic 100%, hex 87–100%) yet fail *lookup* (finding a value by key in a large keyed record-set — as low as 0/10). A single class would gate everything by the weakest mode and disable compression where it is genuinely safe. 4. **Net-loss guard.** If the rendered image would cost more tokens than the text it replaces, the text is kept. Prose is usually *more* expensive as an image. --- ## Installation ```bash pip install opexia-trace # ships pxcore, pxcore-mcp, and pxcore-proxy ``` ## Running the server ```bash # stdio — the default; what Claude Code and local clients use pxcore-mcp # HTTP — for Next.js / serverless callers pxcore-mcp --http pxcore-mcp --http --host 127.0.0.1 --port 8765 ``` ### CLI options | Flag | Type | Default | Description | |---|---|---|---| | `--http` | flag | off (stdio) | Serve JSON-RPC over HTTP POST instead of stdio | | `--host` | string | `127.0.0.1` | Bind host, with `--http` | | `--port` | integer | `8765` | Bind port, with `--http` | ### Transports | Transport | Protocol | Use for | |---|---|---| | **stdio** | Newline-delimited JSON-RPC on stdin/stdout | Claude Code and local MCP clients | | **HTTP** | Single JSON-RPC-over-POST endpoint (the non-streaming Streamable HTTP subset) | Next.js, serverless, any language with an MCP client | Pure standard library — no server framework, no extra dependency. ### `PXCORE_MODEL` MCP does not surface which model is calling, so the active model is supplied out-of-band: ```bash export PXCORE_MODEL=claude-fable-5 pxcore-mcp ``` This selects the calibration profile that gates compression. **An unknown or uncalibrated model leaves compression off** — pxcore returns plain text rather than risk a misread. --- ## MCP tools Four tools, exactly as advertised over `tools/list`. ### `pxcore_read` Read a file and return it token-efficiently. Large dense files come back as an image (read via native vision) with exact identifiers listed as text; small or exact-heavy files come back as plain text. **Use for reference reads you will not edit verbatim.** | Parameter | Type | Required | Description | |---|---|---|---| | `path` | string | ✅ | File path to read | ```json { "name": "pxcore_read", "arguments": { "path": "logs/app.log" } } ``` ### `pxcore_run` Run a shell command and return its output token-efficiently — large dense output as an image, exact IDs as text. | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `command` | string | ✅ | — | Shell command to run | | `timeout` | number | ➖ | `120` | Timeout in seconds | ```json { "name": "pxcore_run", "arguments": { "command": "kubectl get pods -A", "timeout": 30 } } ``` ### `pxcore_grep` Search files under a path and return matches token-efficiently. | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `pattern` | string | ✅ | — | Search pattern | | `path` | string | ➖ | `.` | Directory to search | ```json { "name": "pxcore_grep", "arguments": { "pattern": "TODO", "path": "src/" } } ``` ### `pxcore_view` Render a large block you already have into a token-efficient image. | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `text` | string | ✅ | — | The block to render | | `exact` | boolean | ➖ | `false` | Force it to stay text — for content that must be reproduced verbatim | ```json { "name": "pxcore_view", "arguments": { "text": "", "exact": false } } ``` --- ## Client configuration ### Claude Code `.mcp.json` in your project root: ```json { "mcpServers": { "pxcore": { "command": "pxcore-mcp", "env": { "PXCORE_MODEL": "claude-fable-5" } } } } ``` The bundled plugin uses a vendored copy instead, so it needs no `pip install`: ```json { "mcpServers": { "pxcore": { "command": "python", "args": ["-m", "pxcore_mcp"], "env": { "PYTHONPATH": "${CLAUDE_PLUGIN_ROOT}/vendor", "PXCORE_MODEL": "claude-fable-5" } } } } ``` ### TypeScript / Node Use the HTTP transport with the official MCP SDK. ```bash npm i @modelcontextprotocol/sdk pxcore-mcp --http --port 8765 # in another terminal ``` ```ts // pxcore-client.ts import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const client = new Client({ name: "my-app", version: "1.0.0" }); await client.connect( new StreamableHTTPClientTransport(new URL("http://127.0.0.1:8765")), ); // Discover what the server offers. const { tools } = await client.listTools(); console.log(tools.map(t => t.name)); // → ["pxcore_read", "pxcore_run", "pxcore_grep", "pxcore_view"] // Read a large log token-efficiently. const result = await client.callTool({ name: "pxcore_read", arguments: { path: "logs/app.log" }, }); console.log(result.content); // image blocks + exact identifiers as text ``` Raw JSON-RPC, with no SDK: ```ts async function callPxcore(name: string, args: Record) { const res = await fetch("http://127.0.0.1:8765", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args }, }), }); return (await res.json()).result; } console.log(await callPxcore("pxcore_grep", { pattern: "TODO", path: "src/" })); ``` ### Python Call the same functions in-process — no server, no MCP round trip. ```python import pxcore.agent_tools as tools # Each returns MCP-shaped content blocks. print(tools.read("logs/app.log")) print(tools.run("kubectl get pods -A", timeout=30)) print(tools.grep("TODO", "src/")) print(tools.view(large_block, exact=False)) # as_text=True forces plain text, bypassing imaging entirely. print(tools.read("config.yaml", as_text=True)) ``` --- ## Using `pxcore` as a library ```python import pxcore # Decide whether one block should be imaged, given a model profile. decision = pxcore.decide(block_text, profile) # Split an oversized block into page-images (capped at DEFAULT_MAX_PAGES = 16). pages = pxcore.decide_paged(huge_block, profile) # Classify content without acting on it. label = pxcore.classify(block_text) ``` **Public API:** `decide`, `decide_paged`, `classify`, `render`, `Meter`, `DriftMonitor`, `BlockHint`, `BlockLabel`, `Decision`, `Geometry`, `ImageWithFactsheet`, `KeepText`, `ModelProfile`, `Rendered`, plus the in-code integration helpers `to_anthropic`, `to_openai`, `compress_anthropic`. `DEFAULT_MAX_PAGES = 16` is a blast-radius guard, not an economic one: it stops a pathological megablock exploding into hundreds of images. Each page under the cap still images only if that page alone wins. ### In-code integration Compress the messages you send to a provider directly: ```python from pxcore import compress_anthropic compressed = compress_anthropic(messages, profile) response = anthropic_client.messages.create(model="claude-opus-4-6", messages=compressed, max_tokens=1024) ``` --- ## `pxcore-proxy` An in-path token-compression proxy for Claude Code — the same compression applied transparently, with no tool calls to change. ```bash pxcore-proxy --help ``` Its only dependency is `httpx`, already required by `opexia-trace`. | | MCP server | Proxy | |---|---|---| | **How it applies** | You call `pxcore_*` tools explicitly | Transparent, in the request path | | **Changes needed** | Use the tools instead of the built-ins | None | | **Control** | Per call | Global | | **Best for** | Selective compression, other languages | Blanket savings in Claude Code | # Part 2 — The Claude Code Plugin ## What it provides | Command | Purpose | Network | |---|---|---| | `/opexia:instrument` | Detect the stack, instrument it, verify a span lands | Yes — verification only | | `/opexia:secure` | Prompt-injection audit + mitigations under review | **Zero egress** | | `/opexia:log` | Committed, agent-queryable relational dev-log | **Zero egress** | | `/opexia:compress` | Set up, calibrate, and verify pxcore | None | Plus the bundled `pxcore` MCP server (vendored — no `pip install` required) and a `Stop` hook that enriches pending dev-log entries. Current version: **0.6.0**. --- ## Installation ``` /plugin marketplace add /plugin install opexia ``` Verify: ``` /help ``` `/opexia:instrument`, `/opexia:secure`, `/opexia:log`, and `/opexia:compress` should be listed. --- ## `/opexia:instrument` Instruments the project for Evigauge observability. It detects the tech stack (Python, Next.js, Node, React, React Native), chooses the correct integration route, writes the code and env, and **verifies a span lands with zero dead-letters**. ``` /opexia:instrument /opexia:instrument use direct http /opexia:instrument attribute per end user ``` **Argument:** an optional free-form instruction. ### What it does 1. **Detects the stack** — reads manifests and entrypoints. 2. **Picks a route:** | Stack | Route | |---|---| | Python | `opexia-trace` SDK (`init()` + `@observe`) | | Next.js | `instrumentation.ts` + OTLP/JSON exporter | | Node / TS | Generic OTel bootstrap + attribute helpers | | React / React Native | Direct HTTP span emission | 3. **Writes the code** — bootstrap, attribute helpers, and `.env` entries. 4. **Verifies** — emits a real span and confirms it arrived with **zero dead-letters**. This is the step that matters: an instrumented app that silently dead-letters every span looks identical to a working one until you check. ### Also use it for debugging Run it on an already-instrumented project to diagnose empty dashboard panels or dead-lettered spans. It knows the failure modes — protobuf `415`, nested objects in `opexia.decision` / `opexia.sources`, undocumented `opexia.*` keys, missing `capture_text`. See [SDK: verifying a span landed](./evigauge-sdk.md#verifying-a-span-landed) for the manual equivalent. --- ## `/opexia:secure` Audits every system instruction in the project for **prompt-injection susceptibility**, then applies mitigations **only after you explicitly say yes**. ``` /opexia:secure /opexia:secure audit only /opexia:secure prompts/** ``` **Argument:** optionally a path or glob to focus on, or `audit only` to skip applying fixes. ### How it works 1. Runs `opexia audit` locally — **zero network, zero LLM**. 2. Reads the local report. 3. Presents each finding with its injection type and mitigation. 4. Writes fixes **only** on your explicit go-ahead. ### What it audits Every system instruction it can find: `CLAUDE.md`, system prompts, agent instructions, skill definitions, and in-code prompt templates. Checks are **applicability-gated** — a check that cannot apply to your setup is skipped rather than reported as a false positive. A hardcoded secret is always a **FAIL**. Findings are mapped to **OWASP**, **NIST**, **MITRE**, and **NSA** references, so a finding is defensible in a security review rather than a bare assertion. > **Zero egress is a hard guarantee.** No network call, no LLM call, no process > spawned. Findings are a disclosure about your system's weaknesses — they must > never leave the machine. When this runs inside `opexia shipcheck` as Gate 3, > only the verdict and finding *categories* reach the shared PR comment; the > evidence stays local. --- ## `/opexia:log` Maintains a **local, relational, agent-queryable dev-log** — a committed knowledge graph of how the codebase was built. ``` /opexia:log init /opexia:log # enrich pending commit entries (default) /opexia:log query "why did auth break" /opexia:log rebuild ``` | Argument | Effect | |---|---| | `init` | Set up the dev-log and install the post-commit hook | | *(none)* | Enrich pending commit entries — the default | | `query ""` | Traverse the graph to answer a question | | `rebuild` | Rebuild `graph.jsonl` from the entry files | ### The model Every **git commit becomes a node**. Tasks, decisions, bugs, and components are also nodes. **Typed edges** connect them, so the history is traversable rather than a flat log. This answers questions a commit log cannot: *"what happened in auth and why did it break"*, *"which commit introduced this, and what decision drove it"*. The commit message records *what* changed; the graph records *why*. ### How it stays current - A **post-commit hook** writes a stub entry for each commit — it never blocks or slows a commit. - A **`Stop` hook** nudges Claude to enrich pending entries with the reasoning behind the change when it finishes a task. The hook is loop-guarded, and is a **no-op unless the repo has run `/opexia:log init`**. - `graph.jsonl` is the agent-queryable artifact. **Commit the dev-log.** Its value is being there for the next person — and for the next agent session. > Local and zero-egress. Nothing about your codebase's history leaves the machine. --- ## `/opexia:compress` Sets up and manages pxcore token compression in Claude Code. ``` /opexia:compress /opexia:compress calibrate /opexia:compress status /opexia:compress proxy ``` | Argument | Effect | |---|---| | *(none)* | Set up compression | | `calibrate` | Run the calibration battery for the active model | | `status` | Report whether compression is active, and why or why not | | `proxy` | Set up `pxcore-proxy` instead of the MCP route | ### Calibration Compression stays **off for a model until calibration proves it safe**. The battery measures imaged-reading fidelity separately for *gist* and *lookup* content, then gates each class independently. Pre-baked profiles ship inside the wheel (for example `pxcore/calibration/profiles/claude-fable-5.json`). Run `calibrate` for a model that has no profile yet. `status` is the right command when compression is not saving what you expected — it names the reason: uncalibrated model, content classified `exact`, or the net-loss guard keeping text. --- ## The `Stop` hook The plugin registers one hook: ```json { "hooks": { "Stop": [ { "hooks": [ { "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/devlog-stop.sh\"" } ]} ] } } ``` It nudges Claude to enrich pending dev-log commit entries when a task finishes. It is **a no-op unless the repo has run `/opexia:log init`**, so installing the plugin does not change behaviour in repos that do not use the dev-log. # Reference ## Files the tooling reads and writes | Path | Written by | Commit it? | |---|---|---| | `.opexia/agentmap.lock` | `opexia audit`, `/opexia:secure` | ✅ Yes — its value is the reviewable diff | | `.opexia/shipcheck.yml` | You | ✅ Yes | | `.opexia/devlog/` + `graph.jsonl` | `/opexia:log` | ✅ Yes | | `.opexia-wal/spans.jsonl` | The SDK | ❌ No — add to `.gitignore` | | `.mcp.json` | You or the plugin | ✅ Yes | ## Environment variables | Variable | Used by | Default | Description | |---|---|---|---| | `PXCORE_MODEL` | `pxcore-mcp`, `pxcore` | `claude-fable-5` | Active model, selecting the calibration profile | | `CLAUDE_PLUGIN_ROOT` | Plugin configs | *(set by Claude Code)* | Plugin install root | | `OPEXIA_API_KEY` | `/opexia:instrument`, `opexia shipcheck` | — | Workspace API key | ## Console scripts | Script | Purpose | |---|---| | `opexia` | Subcommand dispatcher — `shipcheck`, `audit`, `live` | | `pxcore-mcp` | The MCP server — stdio or HTTP | | `pxcore-proxy` | In-path compression proxy for Claude Code | All three install with `pip install opexia-trace`. --- ## Troubleshooting | Symptom | Cause | Fix | |---|---|---| | MCP server not listed in Claude Code | `.mcp.json` not picked up | Restart Claude Code; confirm `pxcore-mcp` is on `PATH` | | `pxcore-mcp: command not found` | Package not installed, or wrong venv | `pip install opexia-trace`; check the active environment | | Compression never activates | Model uncalibrated — the default is off | `/opexia:compress status`, then `/opexia:compress calibrate` | | Content stays text unexpectedly | Classified `exact`, or the net-loss guard fired | Expected — `exact` never images, and prose is often costlier as an image | | Token count went *up* | Imaging prose or small blocks | Current versions guard against this — upgrade `opexia-trace` | | `/opexia:log` does nothing | `init` not run in this repo | `/opexia:log init` | | `/opexia:secure` finds nothing | No system instructions detected | Pass an explicit path or glob | | Plugin commands missing from `/help` | Plugin not installed or not enabled | `/plugin install opexia`, then restart | | HTTP transport refuses connections | Server not running, or wrong port | Start `pxcore-mcp --http --port 8765` and match the client URL | --- ## See also - **[REST API Reference](./evigauge-rest-api.md)** — all 100 endpoints (93 Read API + 9 Ingest, 2 shared). - **[SDK Reference](./evigauge-sdk.md)** — `opexia-trace` for Python, plus the TypeScript / OpenTelemetry route.