Events API
Two halves. The read API returns the same request records, traces and usage the dashboard shows, for your own reports and pipelines. Guard events go the other way: your agent reports what happened around each guarded tool call (the decision, a reviewer’s answer, whether the tool ran), so a thread in the dashboard reads as one sequence from proposal to execution.
| Route | Scope |
|---|---|
GET /v1/requests | telemetry:read |
GET /v1/requests/{request_id} | telemetry:read |
GET /v1/usage | telemetry:read |
GET /v1/calibration-profiles/{name}/events | telemetry:read |
GET /v1/events | telemetry:read |
POST /v1/events | events:write |
Every read is scoped to the API key’s organization. Create a key with the right scope under Dashboard > API keys (scopes).
List requests
GET /v1/requests?q=<search>&from=<ISO time>&to=<ISO time>&limit=50&cursor=<next_cursor>| Parameter | Default | Meaning |
|---|---|---|
q | none | A search |
from, to | none | ISO 8601 timestamps; from is inclusive, to exclusive |
limit | 50 | 1 to 500 |
cursor | none | The next_cursor of the previous page |
Results are newest first, with keyset paging: pass next_cursor back as
cursor until it is null. Paging stays stable while new requests
arrive.
import os
import httpx
API = os.environ.get("CLI_BASE_URL", "https://cci.gitdate.ink/api/v1").rstrip("/")
http = httpx.Client(base_url=API + "/", headers={"Authorization": f"Bearer {os.environ['CLI_API_KEY']}"},
timeout=30.0)
def requests_matching(q: str, **params):
cursor = None
while True:
page = http.get("requests", params={"q": q, "limit": 500, "cursor": cursor, **params}).json()
yield from page["requests"]
cursor = page["next_cursor"]
if cursor is None:
return
for row in requests_matching("operation:evaluate outcome:fail_closed profile:support-routing-v3",
**{"from": "2026-09-01T00:00:00Z"}):
print(row["request_id"], row["status"], row["duration_ms"], row["heuristic_count"])Each row has request_id, trace_id, started_at, duration_ms,
method, route, path, operation, status, outcome, error_code,
api_key_id, api_key_prefix, api_key_name, source (hosted or
otlp), kind (request or job), session_id, thread_id,
environment, profiles, query_types, heuristic_count,
backend_calls, input_tokens, output_tokens, cost_usd, providers,
models, idempotency_key, replay_of, is_mock, sdk_version and
job_id. The response is {"requests": [...], "next_cursor": ..., "filter_errors": [], "request_id": ...}.
| Error | When |
|---|---|
422 invalid_filter | A search term is malformed; details.errors lists each term, its position and the problem |
422 invalid_cursor | The cursor was not produced by this API |
422 invalid_time_range | from or to is not an ISO timestamp |
Search grammar
A search is a list of whitespace-separated terms, all of which must match.
| Form | Meaning | Example |
|---|---|---|
field:value | The field equals the value | operation:evaluate |
field:"a value" | A quoted value, which may contain spaces | model:"gpt-4.1-2025-04-14" |
-field:value | Negation | -outcome:ok |
field:>N, >=, <, <= | Comparison, numeric fields only | duration_ms:>1000 |
meta.<key>:value | A key of the X-CCI-Metadata header | meta.team:ops |
| a bare word | Matches a request id, an idempotency key, or the start of an error message or run name | req_7d1e4a90c2b35f18 |
| Field type | Fields |
|---|---|
| Text | operation, outcome, route, method, key (API key id, prefix or name), provider, model, profile, query_type, session, thread, env, source, auth, error (error code), request, idempotency, tag, guarantee (an answer’s guarantee type), kind |
| Numeric | status, duration_ms, calls (backend calls), tokens (input plus output), cost (USD), heuristic_count |
Boolean (true, false, 1, 0, yes, no) | heuristic, escalated (a gate escalated, a route escalated, or a judge sent to the human queue), recalibrated, mock, replayed, error_only (status 400 or above) |
Aliases: op (operation), api_key (key), status_code (status),
latency and latency_ms (duration_ms), session_id (session),
thread_id (thread), environment (env), type (query_type),
guarantee_type (guarantee), request_id (request).
op:evaluate outcome:fail_closed profile:triage-set-v3 duration_ms:>1000
provider:openai -status:200 env:production
heuristic:true tag:pilot meta.release:"2026.10.1"
auth:revokedAn unknown field, a comparison on a text field or a non-number on a numeric field is an error. The dashboard uses the same grammar, so a search copied from Requests works here unchanged.
Read one request
GET /v1/requests/{request_id}?include_payload=true{
"request": {"request_id": "req_7d1e4a90c2b35f18", "status": 424, "outcome": "fail_closed", "...": "..."},
"spans": [{"span_id": "...", "parent_span_id": "...", "name": "profile.resolve triage-set-v3", "...": "..."}],
"answers": [{"query_id": "department", "query_type": "set", "guarantee_type": "heuristic", "...": "..."}],
"payload": {"request": {"...": "..."}, "response": {"...": "..."}, "redactions": 2, "truncated": false},
"guard_events": [],
"attempts": [{"request_id": "req_...", "status": 502, "outcome": "backend_error", "replay_of": null}],
"request_id": "req_..."
}spans is the trace in start order;
payload is null when capture was off for the request or
include_payload=false; attempts lists every request that shared this
request’s Idempotency-Key. An unknown id is 404 request_not_found.
Usage
GET /v1/usage?group_by=key&from=<ISO time>&to=<ISO time>group_by is key, operation, outcome, provider, model,
profile or environment (a request with several providers, models or
profiles counts under its first one). Each group has group, requests,
errors (outcomes server_error, backend_error, proxy_error),
fail_closed, backend_calls, input_tokens, output_tokens,
cost_usd, decisions (queries answered), heuristic_answers, and
latency percentiles p50_ms, p95_ms and p99_ms.
usage = http.get("usage", params={"group_by": "key", "from": "2026-09-01T00:00:00Z"}).json()
for row in usage["usage"]:
print(row["group"], row["requests"], row["cost_usd"], row["p95_ms"])Profile events
GET /v1/calibration-profiles/{name}/events?limit=200The profile’s history, newest first (at most 1,000 per call): type,
at, the request or job that caused it, the actor, version changes and
details. Types include profile.created, examples.added,
binding.bound, binding.stale, calibration.first,
calibration.recalibrated, calibration.appended, contexts.discarded,
retention.changed, monitor.created, monitor.alert_fired,
monitor.superseded, monitor.deleted, outcomes.added,
audit.completed and judge.job_completed.
Client-side guard events
POST /v1/events
{"events": [ ... up to 500 ... ]}| Type | Send it when |
|---|---|
guard.decision | The guard allowed, escalated or blocked a proposed call (action) |
guard.cache_hit | The decision came from the guard’s cache rather than a new evaluation |
guard.review | A person answered an escalation (outcome: approve, edit or reject; reviewer) |
guard.executed | The tool actually ran |
guard.blocked_delivered | The refusal reached the agent |
guard.error | The check failed and the call was escalated |
outcome.label | The true answer became known later |
| Field | Meaning |
|---|---|
type, event_id | Required. event_id is 1 to 128 characters; an event id the organization has sent before is ignored, so retrying a batch is safe |
at | ISO 8601 time of the event; the time of receipt when omitted |
request_id, query_id | The evaluate request (and query) behind the decision |
thread_id, session_id, turn_id, tool_call_id | Where it happened; thread_id and tool_call_id join events to requests sent with the same X-CCI-Thread-Id and X-CCI-Tool-Call-Id |
tool_name, action, outcome, reviewer, agent_name, framework, profile | Short strings, at most 256 characters each |
attributes | Any JSON object. The keys arguments, args, input, output, text, note, edited_arguments, messages, payload, tool_arguments, tool_input, tool_output, tool_result, prompt, response, context and content are content: stored only when the organization’s payload capture is not Off and zero retention is not on. Everything is redacted first |
The response is 202 {"accepted": <count>, "rejected": [{"index", "field", "message"}], "request_id": ...}: valid events are kept even when others
in the batch are rejected. Ingestion is best effort. Guard events are
telemetry only: outcome.label does not reach a drift monitor; send
labels for monitors to POST .../outcomes.
GET /v1/events?thread=<thread_id>&request_id=<request_id>&limit=200
returns events in the order they happened, for one thread or one request.
A shared helper
The helpers below need only httpx; copy them into your project. Use a
key with the events:write scope (a key can carry several scopes, for
example evaluate profiles:write events:write). Sending never raises, so
telemetry cannot break an agent.
# cci_events.py
import contextvars
import os
import uuid
from datetime import datetime, timezone
from typing import Any, Iterable, Mapping, Optional
import httpx
BASE_URL = os.environ.get("CLI_BASE_URL", "https://cci.gitdate.ink/api/v1").rstrip("/") + "/"
_http = httpx.Client(base_url=BASE_URL, timeout=10.0,
headers={"Authorization": f"Bearer {os.environ['CLI_API_KEY']}"})
# The current thread, set by your code before each agent run.
current_thread: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("cci_thread", default=None)
def event(type: str, *, event_id: Optional[str] = None, **fields: Any) -> dict[str, Any]:
"""One guard event; fields that are None are left out."""
out: dict[str, Any] = {"type": type, "event_id": event_id or str(uuid.uuid4()),
"at": datetime.now(timezone.utc).isoformat()}
out.update({key: value for key, value in fields.items() if value is not None})
return out
def decision_event(decision: Mapping[str, Any], **fields: Any) -> dict[str, Any]:
"""A guard.decision event from GuardDecision.to_dict(), or a decision dict an integration stored."""
guarantee = decision.get("guarantee") or {}
return event("guard.decision", tool_name=decision.get("tool"), action=decision.get("action"),
profile=guarantee.get("calibration_profile"),
attributes={"reason": decision.get("reason"), "guarantee": guarantee.get("statement"),
"arguments": decision.get("arguments")},
**fields)
def send(events: Iterable[Mapping[str, Any]]) -> Optional[dict]:
"""Post a batch of at most 500 events. Best effort: returns None on any failure."""
batch = list(events)
if not batch:
return None
try:
response = _http.post("events", json={"events": batch})
response.raise_for_status()
return response.json()
except httpx.HTTPError:
return None
def _tag(request: httpx.Request) -> None:
thread = current_thread.get()
if thread:
request.headers["X-CCI-Thread-Id"] = thread
def traced_http_client(timeout: float = 60.0) -> httpx.Client:
"""For CLIClient(http_client=...): every evaluate carries the current thread id."""
return httpx.Client(timeout=timeout, event_hooks={"request": [_tag]})Give the guard’s client the traced HTTP client, so each evaluate it
makes is filed under the same thread as the events:
from cli_sdk import CLIClient
from cci_events import traced_http_client
client = CLIClient(http_client=traced_http_client())Every integration calls ToolGuard.check (or acheck, which runs check
in a worker thread with the caller’s context), so a small subclass reports
every decision, whatever the framework:
from cli_sdk.integrations import ToolGuard
from cci_events import current_thread, decision_event, send
class ReportingToolGuard(ToolGuard):
"""A ToolGuard that also reports each decision as a guard.decision event."""
def __init__(self, *args, framework, agent_name=None, **kwargs):
super().__init__(*args, **kwargs)
self.framework, self.agent_name = framework, agent_name
def check(self, tool, arguments, state=None):
decision = super().check(tool, arguments, state)
send([decision_event(decision.to_dict(), framework=self.framework, agent_name=self.agent_name,
thread_id=current_thread.get())])
return decisionThe framework helpers below use the records each integration already keeps, which also carry tool call ids and reviewer answers. Their event ids are derived from those records, so calling a helper twice for the same turn sends nothing new.
LangGraph
add_cli_guard keeps the latest guarded turn in state["cli_guard"]:
the turn’s action, one entry per tool call (with its id) and, after a
review, a review entry. Report it after each invoke, including the one
that resumes a review:
from cci_events import current_thread, decision_event, event, send
def report_langgraph(graph, config, *, agent_name=None) -> None:
thread = config["configurable"]["thread_id"]
record = graph.get_state(config).values.get("cli_guard") or {}
events = []
for call in record.get("calls", []):
events.append(decision_event(call, framework="langgraph", agent_name=agent_name, thread_id=thread,
tool_call_id=call.get("id"), event_id=f"{thread}:{call.get('id')}:decision"))
review = record.get("review")
if review:
for call in record.get("calls", []):
events.append(event("guard.review", event_id=f"{thread}:{call.get('id')}:review",
framework="langgraph", agent_name=agent_name, thread_id=thread,
tool_call_id=call.get("id"), tool_name=call.get("tool"),
outcome=review.get("outcome"), attributes={"note": review.get("note")}))
send(events)
config = {"configurable": {"thread_id": "th_77"}}
current_thread.set("th_77")
result = graph.invoke({"messages": [("user", claim_text)]}, config)
report_langgraph(graph, config, agent_name="claims-triage")To record that a tool ran, send guard.executed from the tool itself,
which can receive its own call id and the run’s config:
from typing import Annotated
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import InjectedToolCallId, tool
from cci_events import event, send
@tool
def pay_claim(claim_id: str, amount: float, config: RunnableConfig,
tool_call_id: Annotated[str, InjectedToolCallId]) -> str:
"""Pay an approved claim."""
receipt = payments.pay(claim_id, amount)
send([event("guard.executed", event_id=f"{tool_call_id}:executed", framework="langgraph",
thread_id=config["configurable"]["thread_id"], tool_call_id=tool_call_id, tool_name="pay_claim")])
return receiptLangChain
cli_middleware stores each decision in the agent state, keyed by tool
call id; guard_decisions(result) returns them. Reviews are the decisions
you resume with, in the order pending_reviews(result) lists them:
from cli_sdk.integrations.langchain import guard_decisions
from cci_events import decision_event, event, send
def report_langchain(result, config, *, agent_name=None) -> None:
thread = config["configurable"]["thread_id"]
send(decision_event(decision, framework="langchain", agent_name=agent_name, thread_id=thread,
tool_call_id=call_id, event_id=f"{thread}:{call_id}:decision")
for call_id, decision in guard_decisions(result).items())
def report_langchain_reviews(pending, decisions, config, *, reviewer=None) -> None:
"""`pending` is pending_reviews(result); `decisions` is the list passed to Command(resume=...)."""
thread = config["configurable"]["thread_id"]
send(event("guard.review", event_id=f"{thread}:{item.get('interrupt_id')}:{i}:review", framework="langchain",
thread_id=thread, tool_name=item.get("name"), outcome=decision.get("type"), reviewer=reviewer)
for i, (item, decision) in enumerate(zip(pending, decisions)))Call report_langchain(result, config) after every agent.invoke, and
report_langchain_reviews(pending, decisions, config, reviewer="agent-ops")
just before you resume with Command(resume={"decisions": decisions}).
Google ADK
The ADK integration appends every decision, and every reviewer answer, to
session.state["cli_guard_audit"]. Report the list with the session you
get back from your session service after a turn:
from cci_events import decision_event, event, send
def report_adk(session, *, agent_name=None) -> None:
events = []
for i, entry in enumerate(session.state.get("cli_guard_audit", [])):
ids = {"framework": "google-adk", "agent_name": agent_name, "session_id": session.id,
"thread_id": session.id, "event_id": f"{session.id}:{i}"}
if entry.get("action") in ("human_approved", "human_rejected"):
outcome = "approve" if entry["action"] == "human_approved" else "reject"
events.append(event("guard.review", tool_name=entry.get("tool"), outcome=outcome, **ids))
else:
events.append(decision_event(entry, **ids))
send(events)Microsoft Agent Framework
CLIGuardMiddleware keeps a ticket only for escalated calls, so record
decisions with the ReportingToolGuard above
(ReportingToolGuard(client, rules, framework="agent-framework")), and
report each reviewer answer where you send it:
from cli_sdk.integrations.agent_framework import pending_reviews, review_message
from cci_events import current_thread, event, send
async def answer_review(agent, session, result, approved: bool, reviewer: str):
review = pending_reviews(result)[0]
request_id = getattr(review["request"], "id", None)
send([event("guard.review", event_id=f"{request_id}:review" if request_id else None, framework="agent-framework",
thread_id=current_thread.get(), tool_name=review["tool"],
outcome="approve" if approved else "reject", reviewer=reviewer)])
return await agent.run(review_message(review, approved), session=session)These helpers send one small request per call to keep the code short. For a busy agent, collect events in a queue and post them from a background thread in batches of up to 500; the event ids keep retries safe.