Skip to content
anecticoDocsDashboard
Browse documentation
Reference

REST API

Use Anectico's public API conventions and find every supported customer resource family.

On this page

The public REST API base is:

https://app.anectico.com/api/v1

Use an SDK for telemetry and the Anectico CLI for ordinary investigation or automation. Use REST when building a direct integration. Anectico is in early access, so pin the client and Anectico release you verified when request-schema stability matters.

Authenticate and select a project

Send an API key in X-Anectico-API-Key. X-API-Key and Authorization: ApiKey an_... are also accepted, while MCP uses bearer authentication. Prefer one documented header per client.

curl --fail-with-body \
  -H "X-Anectico-API-Key: $ANECTICO_API_KEY" \
  "https://app.anectico.com/api/v1/persons?project_id=$ANECTICO_PROJECT&search=buyer%40acme.example"

For organization-level credentials, many data reads accept project_id. A project-scoped key may omit a query-string project_id or repeat its exact signed project. Any non-empty conflicting query value, including one hidden in a repeated parameter, returns a stable 403 permission_denied before the product handler runs. The requested project is never silently replaced with the key's project. The same fail-closed response applies when a non-empty project_id field is malformed in a way the standard query parser would otherwise discard, such as an unescaped semicolon or invalid percent escape. A malformed unrelated query field does not change an otherwise exact or omitted selector. Organization-level credentials retain request-time project selection. Organization identity always comes from the authenticated principal, not from request input.

Browser logout is POST /auth/logout. When a refresh cookie is present, success means its whole refresh family was revoked before the browser cookies were cleared. If that revocation is temporarily unavailable, logout returns 503 with Retry-After: 1 and leaves the cookies unchanged so the browser can retry; it never reports success while another copy of the refresh token remains usable. A browser refresh credential remains bound to the organization selected when that login session was created. Selecting another organization re-authenticates and creates a new session; an older refresh credential can renew only its original organization. If membership or plan validation is temporarily unavailable during refresh, retrying does not consume the presented credential.

Non-2xx JSON responses use {"error":"...","message":"..."}. A recognized failure that changes the safe retry action also includes a machine-readable details object. For example, a ticket create that may have reached the external provider but was not recorded in Anectico carries:

{
  "error": "internal_error",
  "message": "internal server error",
  "details": {
    "reason": "PROVIDER_EFFECT_UNRECORDED",
    "provider": "jira",
    "external_id": "PROJ-9",
    "external_url": "https://jira.example/browse/PROJ-9"
  }
}

The reason and facts remain available even when a server-fault message is redacted. Treat external_url as provider-authored data. On PROVIDER_EFFECT_UNRECORDED, retry the identical create with the same idempotency key; do not mint a new key.

Telemetry reads — traces, logs, metrics, errors and the searches over them — distinguish a fault from a capacity limit. A read the deployment could not answer at that moment answers 503, not 500: the request was well formed and the same request will usually succeed on a retry, so retry it with backoff. A 500 means the opposite — something is wrong with the request or the service, and retrying it unchanged will not help. Narrowing the time range and the filters of a 503-ing search makes it cheaper and more likely to succeed.

Every 503 carries Retry-After (in seconds) and an unavailable error code, so the two are distinguishable without reading prose: a 503 body says "error": "unavailable" while a 500 says "error": "internal_error". Wait at least Retry-After before retrying. A 503 is also the answer when a request is shed because your organization already has too many reads in flight at once — retrying after the interval is the correct response to that, and issuing fewer concurrent reads is the way to avoid it.

A malformed value is refused, not ignored. If you send a query parameter we cannot parse — a misspelled boolean, a non-numeric limit, an unparseable timestamp — you get a 400. Leaving a parameter out still means "no filter"; the distinction is between absent and present-but-invalid, because a typo that silently drops your filter returns a wider answer you have no way to recognize as wrong. For the same reason a PATCH whose body changes nothing — empty, or naming only fields that do not exist or cannot be edited — is refused rather than recorded as an update.

A read that ran out of time answers 504, and one you cancelled answers 499 — neither is a 500, because the service is healthy and the query was simply too expensive to finish. Narrow it before retrying; the same request will exceed the same deadline. A read whose result set is too large to answer honestly answers 429 rather than returning a truncated one: the trend matrix above 100,000 bucket-and-series rows, and the evidence and step-tree ceilings described later. In every one of these cases you are told the answer is incomplete rather than handed a smaller answer that looks whole.

Person profiles and statistics follow the same rule for their activity, replay-session, and group measurements. A failed measurement returns an error; it is never rendered as a measured zero or an empty membership list. Release detail, release lists, per-Issue release breakdowns, and merged-Issue metadata likewise fail when a required measurement or enrichment is unavailable. On a successful response, zero counts and blank optional metadata therefore describe the measured data rather than a dependency outage.

Issue reads follow that rule. GET /errors/groups accepts exact platform, service_name, mechanism, and release occurrence filters alongside project_id, environment, and time bounds. It also accepts query, a free-text search: a case-insensitive substring of an error's message, emitting service, or error class. Like every other filter there, it selects the individual errors recorded inside the window, so an Issue is returned when at least one of its errors in that window matches — and the per-Issue counts, first/last timestamps, ordering, and pagination.total_count all describe that same searched set. Search the whole matching set by following the returned cursor rather than filtering a page you already hold. GET /errors/groups/{group_id}/affected also requires the intended project_id for an organization-level credential; a missing group and a group owned by another project both return the same 404.

Typed product measurements

POST /analytics/query, GET /analytics/results/{result_id} and GET /analytics/results/{result_id}/participants execute typed trends, ordered person funnels or exact-period retention and read frozen evidence. Query/get responses contain exactly one payload — result for trends, funnel for funnels or retention for retention — and only when the status is PRODUCT_RESULT_STATUS_READY. Funnel step/outcome selections and retention cohort/return selections use the same participant route. GET /analytics/results/{result_id}/contribution accepts exact snapshot_id, selection_id, positive decimal ordinal, contribution_ref, and project_id query parameters. It returns bounded frozen event references — funnel chain steps, or a retention cohort's entry and exact-period returns — with first-entry attribution and one-person outcome/maturity facts under current source permissions. It does not return arbitrary event properties or a live timeline. Each event reference carries a replay field exactly when it has a session: available when a recording exists and this credential may open it, none_recorded when the occurrence was captured in a session with no saved recording, or withheld when this credential lacks session-replay read access; the field is absent entirely for an occurrence with no session at all. Query and get responses carry a status (PRODUCT_RESULT_STATUS_PENDING, PRODUCT_RESULT_STATUS_RUNNING, PRODUCT_RESULT_STATUS_READY, PRODUCT_RESULT_STATUS_FAILED, PRODUCT_RESULT_STATUS_CANCELED, PRODUCT_RESULT_STATUS_EXPIRED, PRODUCT_RESULT_STATUS_INVALIDATED) and, when failed, a failure_reason (PRODUCT_RESULT_FAILURE_REASON_EXECUTION_BUDGET, PRODUCT_RESULT_FAILURE_REASON_TOO_MANY_RUNNING, PRODUCT_RESULT_FAILURE_REASON_SOURCE_UNAVAILABLE, PRODUCT_RESULT_FAILURE_REASON_IDENTITY_UNAVAILABLE, PRODUCT_RESULT_FAILURE_REASON_INTERNAL, PRODUCT_RESULT_FAILURE_REASON_KIND_UNAVAILABLE, PRODUCT_RESULT_FAILURE_REASON_SOURCE_UNADMITTED, or PRODUCT_RESULT_FAILURE_REASON_STORAGE_EXHAUSTED). A ready response carries its payload under the key for its kind — result for a trend, funnel for a person funnel, retention for a retention grid — and every one of the three carries the same manifest, so the coverage verdict for a trend is at result.manifest.current.coverage and for the other two at funnel.manifest.current.coverage and retention.manifest.current.coverage. Read it under the key your request asked for; the field is never omitted for one kind and present for another. A measurement runs in the background: POST /analytics/query returns as soon as it is accepted, with a result_id and a status, and wait_millis (0–25000) is how long the call blocks for PRODUCT_RESULT_STATUS_READY before answering with the status that stands. A response that is not ready carries no payload and is a correct answer, not a timeout; poll GET /analytics/results/{result_id} for a final status. Four measurements may be pending or running per project — a fifth answers PRODUCT_RESULT_STATUS_FAILED with PRODUCT_RESULT_FAILURE_REASON_TOO_MANY_RUNNING and no result_id. POST /analytics/results/{result_id}/cancel stops one that has not completed; it needs the same permission as running a measurement, and the stopped measurement publishes nothing. See measure product events for the shared definition, retry key, exact selections, current source permissions and MCP/CLI equivalents.

Resource catalog

Area Resource prefixes Main purpose
Customers and accounts /persons, /groups, /group-types, /account-segments Profiles, timelines, group accounts, watched segments
Raw observability /traces, /logs, /metrics, /services, /search, /stream/logs Search, correlation, aggregation, live logs, RED metrics, service map
Errors and delivery context /errors/groups, /releases, /sourcemaps, /symbols Issue triage, impact, lifecycle, deploys, readable stacks
Replay /replay, /replay-for-trace Recordings, snapshots, related evidence, trace lookup
Events and rollout /events, /analytics, /flags, /decide Event discovery, trends and triggerers, cohorts, flag configuration and evaluation
AI execution /agent-runs, /agent-sessions, /agent-turns, /agent-events, /telemetry-completeness, /llm, /evaluation, /ai Runs and their step graph, conversations and turns, step-level agent evidence, coverage by normalizer, scores, cost, pricing overrides, quality settings, quality evaluations and their provenance, natural-language query
Agent inventory and evidence /assets, /review-signals, /action-receipts, /receipt-chains, /abom-manifests Declared and discovered agents, what still needs a human, what an agent did and under whose authority, tamper-evidence checks, signed bills of materials
Customer Detective /investigations Start, continue, stop, follow, list, and read grounded investigations
Agent control and provenance /quarantines, /content-policies, /authority-edges, /memory-items, /deployments, /config-snapshots Approval-controlled containment, content boundaries, authority, memory labels and propagation, immutable deployment/configuration provenance
Fleet and controlled execution /fleet, /agent-drive-attempts, /agent-session-replays Fleet posture, drive-attempt evidence, and recorded-session replay results
Detection and response /anomalies, /alerts, /slo, /incidents, /oncall, /escalation-policies Findings, rules, SLO burn, silences, alert history and acknowledgements, response, schedules, routing
Saved work and export /dashboards, /search/saved, /export Dashboards and widgets, saved searches, background exports
Organization and access /projects, /account, /invitations, /settings, /usage, /users/me Projects, members, API keys, configuration, usage, contact methods
Integrations and delivery /connections, /notifications, /tickets, /issues Provider authorization, delivery channels and their message templates, Issue ticket links

Connections and provider actions

Connection create and patch requests may set customer-facing configuration, but provider-routing identity and outbound API destinations are server-managed. Requests containing base_url, installation_id, or team_id in connection metadata return 400; use the provider's verified connect or reauthorization flow to establish those values. A nested identity-mapping delete is also parent-exact: DELETE /connections/{connectionId}/identity-mappings/{mappingId} returns 404 when the mapping does not belong to that connection.

Slack buttons and modal submissions use the mapped member's permissions at click time. Removing alerts:acknowledge or errors:write takes effect for later clicks even when the Slack identity mapping still exists. A transient failure does not consume the action: the callback returns a retryable 503, and a repeated signed delivery may try the action again. Once the action succeeds, the same delivery is acknowledged without applying it twice. During a rate-limit dependency outage, both /webhooks/{provider} and /slack/interactivity fail closed with 503.

Groups

GET /groups/{group_type}/{group_key}?project_id= combines the saved group profile with activity statistics. If those statistics cannot be measured, the request returns a temporary error; it never substitutes event_count: 0 or empty first/last-seen values. A successful zero therefore means the group was measured and had no activity.

Anomaly findings

GET /anomalies returns findings newest first. sort=actionability returns the same findings in a different order: the ones a ranking model judged most likely to be real faults first, and findings it has not ranked last. sort=detected_at is the default and restates it explicitly. Any other value is rejected with 400. Neither order filters — the set of findings a request returns does not depend on sort.

A ranked finding carries two extra fields. likely_real is true when the ranking is confident the finding is a real fault, and actionability_score is where it placed the finding on a 0–3 scale. likely_real: false means the finding was not promoted; it never means the finding is noise, and a finding that has not been ranked at all reports false for the same reason.

An unranked finding omits actionability_score entirely rather than reporting 0 — a zero would be indistinguishable from a finding that was measured and placed at the bottom. When the reason a finding could not be ranked is known, actionability_unavailable_reason carries it: no_provider (ranking is not configured for the organization), budget_exhausted (the plan's daily ranking cap is spent — see limits), or one of several transient values meaning the ranking service could not answer. The field is absent when a finding is simply waiting to be ranked.

Ranking is an aid to ordering, not a filter or a lifecycle state. Acknowledge and resolve behave identically for ranked and unranked findings.

Project configuration

POST /projects and PATCH /projects/{id} distinguish an omitted sampling_rate from an explicit zero. Omit it to use the create default (1.0) or preserve an existing project value. Send 0 to disable ingestion for that project.

Tickets

/issues/{group_id}/tickets lists or creates ticket links for an Issue; it is not the Issue-list surface. Use /errors/groups and /errors/groups/{group_id} for Issue triage and detail.

Organization-level ticket creation requires project_id; every caller supplies an absolute issue_url and an Idempotency-Key header (or idempotency_key body field). The key is scoped to one project — the same key in another project is a separate create. Reusing the key with the same request returns the existing provider ticket link; reusing it with a different request is rejected. When the provider accepts the request but returns no usable answer, the response is a retryable 503 and the create is briefly held while the provider's issue search catches up, so a retry inside that window reports that creation is already in progress rather than filing a second ticket. Its error details carry reason=PROVIDER_EFFECT_UNRECORDED; keep retrying the unchanged request with the same key. A create that already reached the tracker stays bound to the connection and destination it reached, so retries go there rather than to whichever connection is active now; if that destination is gone or repointed, the retry is refused with 400 naming the destination it is waiting for, instead of filing elsewhere. That refusal does not clear on its own — restore the connection to that target and retry with the same key. A provider 4xx that definitively rejects creation is also a non-retryable 400; repair the provider authorization, permissions or target before sending the request again. Provider timeouts, throttling and 5xx responses remain retryable. GET /tickets/{id} and Issue ticket lists expose truthful sync state and safe retry errors. Every ticket operation resolves to one explicit project. Organization-level credentials must send one consistent project_id; project-scoped credentials may omit it or repeat their exact signed project. A different non-empty query value returns the shared 403 permission_denied before routing. A conflicting create-body value, when the query is omitted or exact, retains the same generic 404 for real, foreign, and unknown projects before ticket creation is attempted. Jira/Linear OAuth start accepts non-secret metadata. Reauthorization also sends the exact existing connection_id; the callback refreshes that needs_reauth row with an optimistic version check rather than creating a duplicate. The callback is /api/v1/connections/oauth/callback and returns to /configure/connections.

Connection metadata accepts only the documented fields for the selected provider. Custom outbound base_url values must be public HTTP(S) destinations: URLs with embedded credentials and addresses that resolve to local, private, link-local, multicast, or cloud-metadata networks are rejected. Provider routing identities are set only by a verified provider callback and cannot be replaced by a create or update request. A connection revoked by an organization administrator cannot be reactivated with ordinary connection-write access. If a first Jira ticket request discovers setup metadata while an administrator edits the same connection, the newer edit wins and the request can be retried; discovery never overwrites that concurrent change.

Customer Detective

POST /investigations/{id}/cancel stops the running answer and settles the investigation in the canceled state. It returns the settled investigation and a last_event_cursor. Retrying the same cancel returns the same settled result; an investigation that already finished or already failed cannot be canceled and is refused with 400. A live start/continue stream ends with the investigation_cancelled terminal outcome after cancellation, not a generic internal error.

GET /investigations/{id}/watch follows one investigation's progress as a Server-Sent Events stream of lifecycle updates — turn started, stage changed, turn completed, turn failed, canceled — plus a caught_up checkpoint that reports the status and whether it is final. It never re-sends answer text; read the current answer with GET /investigations/{id}.

Every progress frame carries an opaque cursor as its SSE event id. To resume, send that value back in the Last-Event-ID request header (a browser EventSource does this automatically on reconnect) or in the after_cursor query parameter; when both are present and disagree, the header wins. Resumption is exclusive — you receive only what happened after that position. Cursors are opaque: preserve them unchanged and never construct one. A cursor that is malformed, issued for a different investigation, or no longer available is refused with 400 before any frame is sent, so a refusal is always an HTTP status and never a stream that quietly starts in the wrong place. Recover by reading GET /investigations/{id} and resuming from the last_event_cursor it returns.

Every newly produced conclusion includes evidenceDisclosure, so software does not have to parse the answer's Markdown to learn what the model did not examine. Its omissions array contains closed source and reason enum values. A present disclosure with an empty array means disclosure was evaluated and nothing was omitted; a missing disclosure means the producer did not populate this contract, so do not interpret it as success.

An omission means at least some evidence in that source family was not examined; other evidence from the same family may still be cited. Reasons are EVIDENCE_OMISSION_REASON_SCOPE, EVIDENCE_OMISSION_REASON_POLICY, EVIDENCE_OMISSION_REASON_UNAVAILABLE, and EVIDENCE_OMISSION_REASON_LIMIT. Sources are the closed families ERRORS, RELEASES, TRACES, LOGS, CROSS_SIGNAL_SEARCH, PERSONS, ACCOUNTS, LLM_SPEND, INCIDENTS, SESSION_RECORDINGS, AGENT_RUNS, AGENT_RUN_STEPS, SESSION_CONTENT, AGENT_TRANSCRIPT, PERSON_PROFILE, ACCOUNT_PROFILE, INCIDENT_TIMELINE_CONTENT, and INVESTIGATION_CONCLUSION, each with the EVIDENCE_SOURCE_ prefix on the wire. The human-readable sections remain in markdown and are generated from the same omissions.

Telemetry deletion is project-exact and durable — not a display filter — on every signal that has it: DELETE /traces/{trace_id}, DELETE /logs/entry, DELETE /metrics/{metric_name}, DELETE /errors/groups/{group_id}, and DELETE /persons/{person_id}/errors. Each needs project_id as a coordinate rather than a filter — an organization-level credential must supply it and no route falls back to an organization-wide delete — plus the matching *:delete scope. The last two require errors:delete; the person route accepts a person ID or a distinct ID and resolves it to that person's full identity set before erasing, returning person_id and the distinct_ids it acted on so the reach of the erasure is visible rather than assumed. Deleting an Issue or a person's errors also suppresses matching occurrences that arrive later, and cannot be undone. Erasing a person's errors does not touch their traces, logs, metrics, events, or recordings; there is no single call that removes a person from every signal.

A successful delete means the suppression is durable and already in effect: from that response onward every read of the target is empty, including reads of copies still in flight when you called. It does not mean the underlying storage has finished being reclaimed — that happens in the background, and it is not something a read can observe. This is why the call answers promptly and does not hold while a large deletion is processed. Repeating a delete is safe and returns the same answer; it never reveals whether anything matched.

Telemetry ingestion uses the separate https://api.anectico.com host with OTLP /v1/traces, /v1/logs, and /v1/metrics plus diagnostic-event /api/v1/capture; it is documented separately because it does not follow normal resource CRUD.

Platform-operator /admin routes and provider callback/webhook routes are not customer integration surfaces and are intentionally excluded from this catalog.

Exact trace reads by ID do not add a hidden recent-time window; they search the retained data in the selected project. Detail responses carry partial, returned_span_count, and total_span_count. Both span-count fields are emitted as JSON numbers. When partial is true, the server returns the bounded span prefix but withholds whole-trace timing, statistics, and critical-path answers rather than computing them from incomplete data. Agent-run detail uses the same three completeness fields for its enriched span list.

Trace aggregates require the complete input and return HTTP 429 ResourceExhausted when the 100,000-span window limit is exceeded. Narrow the time range and retry. Trace and service-map critical-path analysis also returns HTTP 429 ResourceExhausted if its graph requires more than 100,000 path visits; no partially searched path is presented as the answer. Raw trace spans remain available through the span endpoint. Matching trace/span IDs in different projects are separate identities.

POST /traces/search evaluates trace-level filters before cutting the requested page. This includes minimum and maximum duration and both single- and multi-value service filters. Duration is the wall clock from the trace's earliest span start to its latest span end; service matching is an OR across the trace's complete service set. Both are evaluated over all spans for that trace in the requested organization, time window, and project, even when another filter selected the trace through one particular service, environment, or identity.

Follow a search result with the same window when opening its detail routes: GET /traces/{trace_id}, GET /traces/{trace_id}/spans, GET /traces/{trace_id}/stats, and GET /traces/{trace_id}/critical-path all accept project_id, start_time, and end_time. Omitting the times searches all retained data; supplying them reuses the narrower historical interval that produced the search result.

POST /search/unified fans a query out across the selected signal families. Its trace leg uses one case-insensitive union: trace ID, service name, operation name, distinct_id, or any span attribute value. A match in any one qualifies the trace before the per-source limit is applied, including an attribute-value match in an older trace. Attribute keys are not part of this free-text union; use the trace-search attributes object when an exact key/value pair is required.

Every field in that union is matched per span, and one matching span qualifies the whole trace. distinct_id is the one worth stating: a trace whose root span carries one person and whose child span carries another is returned for either, and the hit's own fields still describe the root span. Leading and trailing whitespace is stripped from the query before any of this, so a padded term matches exactly what the trimmed one does, and a query that is only whitespace applies no free-text filter at all rather than matching nothing.

GET /metrics/names returns at most 1,000 names. When more exist, truncated is true and total_count is 0 (unknown); when truncated is false, total_count is exact. Do not interpret the returned page length as the full metric-name cardinality when truncated is true.

Affected-customer search is available at GET /errors/groups/{group_id}/affected?project_id=&search=. The distinct_id half is evaluated before the 1,000-identity cap, so an older matching identity is prioritized into the candidate set. If an alias matched, that matching alias is returned as the row's distinct_id; when only the profile label matched, the match is visible in display_name and distinct_id remains the ordinary representative. Display-name matching happens only after permission-gated profile hydration and is therefore bounded by the 1,000-identity candidate page. totals_are_capped: true means the people, account, and pagination totals are floors over that bounded set and must be rendered with a +.

Log patterns

GET /api/v1/logs/patterns clusters a window's log messages into normalized templates and returns them largest first. Normalization replaces the variable parts of a message with placeholders — <UUID>, <IP>, <HEX>, <NUM>, <FLOAT> — so one template stands for every line that shares its shape. It takes logs:read.

Query parameters: project_id, start_time and end_time (RFC3339; omitting both searches all retained data), service_name, level, min_count and limit. min_count is the smallest cluster worth returning and is clamped to 1–1000, default 10; limit is clamped to 1–100, default 20. A value outside a range is clamped rather than refused; a value that is not an integer is ignored and the default applies.

The response carries patterns, total_patterns, total_logs (the records scanned in the window, which is what the per-cluster percentage is of), min_count echoing what was applied, and retrieved_at. Counts are JSON strings, as everywhere else in this API.

Each entry in patterns has template, count, percentage, service_name, level, sample (one representative raw message), first_seen and last_seen, plus four label fields:

Field Meaning
category One of request, auth, database, cache, queue, external_call, config, lifecycle, job, security, client_error, other. Absent unless the label was confident enough to be worth having.
severity_score 0–3, where 0 is ordinary traffic and 3 is an outage. Absent on an unlabeled template.
needs_attention true when severity_score cleared the "degraded or worse" bar. Always present.
label_unavailable_reason Why the last labeling attempt produced no label, for example no_provider or budget_exhausted. Absent both once labeled and before the first attempt.

Labels are produced after a read. A template returned for the first time carries none of the four and picks its label up on a later read, so treat every one of them as optional and absent rather than as a field that is merely empty.

Two readings the fields do not support. needs_attention: false means "not flagged", never "routine": an unlabeled template is false too, so the flag may promote a template and must never demote one. And a template with no category and no label_unavailable_reason has not been labeled yet — it is not a template that was examined and found uninteresting. Ordering is by count descending regardless of labels; nothing is filtered, hidden or reordered by them.

Replay and group read boundaries

Replay reads are project-scoped even when two projects reuse the same session id. A snapshot response sets has_gaps: true if playback is incomplete, including when the recording exceeds the 1,000-chunk reconstruction cap. Treat its events as a usable partial stream, not as proof that every recorded chunk was returned. Chunk upload times are bounded by receipt time, and a reversed interval is normalized to a non-negative interval; client clocks cannot extend server-side retention.

Group property policy applies to filtering as well as returned fields. If access to group properties is denied, GET /groups?search= still searches visible identifiers but does not match a hidden property such as an account name. DELETE /groups/{group_type}/{group_key} removes the current group from subsequent account-property reads after its dependency check succeeds; recreating that key creates a new current version.

Event and flag reads

Event discovery and triggerer reads are deterministic, project-scoped pages:

  • GET /events/names?project_id=&prefix=&property_key=&property_value=&limit=&cursor= returns events plus pagination. limit is 1–50 when present.
  • GET /events/triggerers?project_id=&event=&since=&until=&property_key=&property_value=&limit=&cursor= requires exact RFC 3339 since and until timestamps and returns canonical person IDs plus the recognizable distinct ID, with pagination. limit is 1–1,000 when present.

Trend measurements use POST /analytics/query with a typed definition and execution key; see Product analytics for frozen results and participants.

Malformed or duplicate limit, cursor, since, or until parameters return 400 before a query runs. Cursors are opaque and bound to the exact project and filters; preserve them unchanged. A project-scoped principal may omit project_id or repeat its exact project; a conflicting explicit query value returns 403 permission_denied before an analytics query runs.

POST /decide evaluates flags remotely. GET /flags/local-evaluation?project_id= downloads the equivalent local-evaluation snapshot. The snapshot body includes the full flag definitions plus schema_version, snapshot_version, updated_at, max_age_seconds, etag, and project_id. Its HTTP response also sends ETag: "<etag>" and Cache-Control: private,max-age=<max_age_seconds>. Send that exact quoted value as If-None-Match; an unchanged snapshot returns 304 with no body. Validators are bound to their project and must never be reused after switching projects.

Saved searches

Saved searches are isolated to one project. Project-scoped credentials are pinned to their signed project and may omit project_id or repeat that exact value. A different non-empty query value, including one hidden in a repeated parameter, returns the shared 403 permission_denied before routing. A conflicting mutation-body value with an omitted or exact query retains the Saved Search contract's same generic 404 for a real sibling project and an unknown project before the service is called. Organization-level credentials must send one consistent project_id. Within that project, list/get show the caller's private searches plus searches shared by another owner. A hidden private search, an unknown ID, and an ID copied from another project all return the same 404.

  • GET /search/saved?project_id=&signal=&limit=&cursor= lists with an opaque, scope-bound cursor.
  • GET /search/saved/{id}?project_id= returns one search and its version.
  • POST /search/saved creates a search; the JSON body requires project_id and idempotency_key for organization-level callers.
  • PATCH /search/saved/{id}?project_id= requires expected_version in the JSON body.
  • POST /search/saved/{id}/duplicate?project_id= requires expected_version and idempotency_key; the new copy is private to the caller.
  • DELETE /search/saved/{id}?project_id=&expected_version= performs the owner-only delete.

Create and duplicate idempotency is durable. An exact retry replays the original response, while reusing a key with different arguments returns 409. Stale update/delete/duplicate versions also return 409 without changing data. A malformed or wrong-scope non-empty cursor returns 400 and never restarts at page one.

Durable exports

  • POST /api/v1/export?project_id= creates a CSV, JSON, or Parquet job. Send a stable idempotency_key in the JSON body or Idempotency-Key header. Keys are 1–128 canonical ASCII letters, digits, ., _, :, or -; whitespace, control characters, and Unicode lookalikes are rejected instead of normalized. The opaque query must name a supported signal and may carry RFC 3339 or integral Unix-second start_time/end_time values.
  • POST /api/v1/export/csv?project_id= and POST /api/v1/export/json?project_id= are format-forcing convenience forms of the generic endpoint. Their request body and idempotency rules are otherwise the same; any body type value is ignored in favor of the path format.
  • POST /api/v1/logs/export?project_id= is the logs convenience endpoint. Its optional format is csv, json, or parquet, and its time bounds are strict RFC 3339 values.
  • GET /api/v1/export/jobs?project_id=&status=&type=&limit=&cursor= returns newest-first signed keyset pages. Preserve a non-empty cursor unchanged with the same project and filters.
  • GET /api/v1/export/jobs/{id}?project_id= polls lifecycle, monotonic progress, attempt count, and expiry. It also carries content_policy_reason and content_policy_withheld_classes: the project content policy's answer for this export, empty until the job runs, then allowed, policy, no_policy or resolver_error. See Save searches and export data.
  • DELETE /api/v1/export/jobs/{id}?project_id= atomically cancels a pending, running, or retrying job.
  • POST /api/v1/export/jobs/{id}/retry?project_id= re-enqueues a failed job.
  • GET /api/v1/export/jobs/{id}/download?project_id= resolves where an unexpired completed result can be fetched. It returns a url on this API pointing at the route below, not a storage link: the address confers nothing on its own and is authorized afresh on every use. Result objects expire and are deleted 24 hours after completion.
  • GET /api/v1/export/jobs/{id}/content?project_id= returns the result file itself, streamed. It honours Range (answering 206 Partial Content) and sends the export's filename as an attachment. Authorization is re-checked continuously while the file transfers, so a permission removed mid-download ends that transfer as well as the next one.

Every export job {id} is the exact lowercase canonical hyphenated UUID returned at creation (8-4-4-4-12, 36 characters). Uppercase, compact, braced, URN, control/Unicode-contaminated, and oversized forms return 400 before a database lookup. A well-formed unknown or sibling-project UUID returns the same generic 404, so UUID validation does not reveal whether a job exists.

Creation requires export:write and the read scope of every signal the export's rows come from — a unified-search export carries trace and log rows and therefore needs traces:read and logs:read alongside search:read. Read/list requires export:read. Retrying and downloading require, in addition to export:write / owner-admin export:download, that the caller still holds those source read scopes at the moment of the request: an export does not stay reachable on the strength of the permissions its creator had when it was made. Downloading is further limited to the credential that created the job, or to an organization owner/admin; another credential in the same project sees the same generic 404 as an unknown job. Every customer request resolves to one verified project in the authenticated organization. Project-scoped credentials may omit project_id or repeat their exact signed project. A different non-empty URL-query value returns the shared 403 permission_denied before routing. With an omitted or exact URL query, a conflicting top-level create value, nested export query, or logs-export body retains Export's same generic 404 without checking whether that project exists or calling Export. Organization-level credentials retain explicit project selection. Sibling-project job IDs return the same 404 as unknown IDs for read, cancel, retry, download, and content. Unknown formats, malformed or reversed times, tampered or foreign cursors, and expired downloads fail closed.

Capture acknowledgements

Before dispatching a structurally valid capture batch, the service records durable attempt evidence. If that cannot be recorded, it returns DEPENDENCY_UNAVAILABLE with HTTP 503 and no events are dispatched; retry with backoff and original message IDs. Queue acceptance still does not establish query visibility or complete source coverage. Request-level counts describe attempts, including retries. After capture processes a project or organization deletion, later capture for that scope is refused with FORBIDDEN (HTTP 403), without dispatch or a retry instruction. Deletion propagation and physical cleanup are asynchronous.

POST /api/v1/capture returns a version 1 acknowledgement. HTTP 200 means a batch ledger was returned; inspect every item before treating it as delivered. The batch.received count includes all original inputs, including invalid ones. Each zero-based index appears once, and received = queued + rejected + unknown.

{"version":1,"request_id":"d561b395-f87f-4e8b-bf54-6c077551ab21","batch":{"received":1,"queued":1,"rejected":0,"unknown":0,"items":[{"index":0,"delivery":"CAPTURE_DELIVERY_QUEUED","code":"CAPTURE_FAILURE_CODE_UNSPECIFIED","retry":"CAPTURE_RETRY_NONE"}]}}
Item delivery Meaning and action
CAPTURE_DELIVERY_QUEUED The queue publisher acknowledged this attempt. Do not retry it. This does not prove storage/query visibility or a completed identity update.
CAPTURE_DELIVERY_REJECTED INVALID_EVENT or QUOTA_EXCEEDED refused the item before publication. Fix the input or quota condition; do not retry unchanged in a background loop.
CAPTURE_DELIVERY_UNKNOWN PUBLISH_UNCERTAIN means some or all attempted records may already be queued. Back off and retry only this item with its exact original message ID, timestamp, identity and payload.

Failure codes use the CAPTURE_FAILURE_CODE_ prefix. Retry values use CAPTURE_RETRY_: NONE, AFTER_CHANGE, SAME_MESSAGE_ID, or UNSAFE_WITHOUT_MESSAGE_ID. The last value means the original event lacked a message ID; assigning one now cannot deduplicate the uncertain attempt. Assign message IDs and timestamps before first delivery. A malformed/unrecognized response or a lost HTTP response also leaves delivery uncertain; a 2xx alone must never cause a client to discard events.

Failures before dispatch use a failure object containing code and retry instead of batch: invalid input is HTTP 400, invalid credentials 401, denied permission 403 where distinguished, oversized input 413, rate limiting 429, and unavailable dependencies 503. The last two use CAPTURE_RETRY_BACKOFF and Retry-After. These failures never claim any event was dispatched. Authentication paths may intentionally treat insufficient credentials as 401.

In quota degrade state, ordinary product events are explicitly refused when pooled-event usage reaches 125% of the limit. They are not independently sampled. Valid identify and $groupidentify controls remain exempt. This enforcement is best-effort; unavailable quota state retains fail-open admission. A successful ledger cannot establish complete instrumentation or quantify unique loss across retries.

The endpoint and Go/JavaScript/Python/iOS/Android adapters (including bundled native bridge sources) are implemented in the current pre-production tree. The coordinated cutover still requires complete package/runtime qualification; this is not a claim that published SDK packages implement this response version.

Direct customer-property updates

SDK identify() is the usual profile-update surface. A CRM or batch importer that needs explicit source times, initialization, or deletion can send the complete raw capture wire with an analytics:write key:

{
  "events": [
    {
      "event": "crm_profile_updated",
      "distinct_id": "user_8842",
      "timestamp": "2026-07-30T12:30:00Z",
      "message_id": "crm-contact-8842-v17",
      "properties": {"source": "crm", "revision": 17},
      "set": {"plan": "enterprise", "locale": null},
      "set_once": {"signup_source": "web"},
      "unset": ["legacy_region"]
    }
  ]
}

properties belongs to the named product event and appears in its customer timeline payload. The top-level set, set_once, and unset fields mutate the canonical customer:

  • set stores literal JSON values. null is a present value; it does not delete.
  • unset is the only deletion operation.
  • For set/unset, the greatest (timestamp, message_id) wins independently per key. A lexicographically greater message ID breaks an equal-time tie.
  • set_once is deterministic initialization. The earliest candidate wins until any set/unset exists; an authoritative mutation always supersedes it.

The endpoint defaults an omitted timestamp to receive time and supplies an internal event ID when message_id is absent, but importers should provide both so retries and out-of-order delivery converge predictably. A key may appear in only one mutation field in an event. Empty or duplicate names and more than 250 combined mutation keys return a per-event validation error. unset is rejected on $groupidentify; v1 account properties do not support deletion.

Before an accepted event is published, Anectico recursively replaces detectable PII and credentials in properties, set, and set_once string leaves with [REDACTED], including values inside nested arrays and objects. A field whose separator-delimited or camel-case name ends in a credential concept (for example, initial_password, customer.api-token, or privateKey) has its complete value replaced with one placeholder, including numeric, boolean, null, object, and array values. Substring near-misses such as tokenizer, token_count, passwordless, and public_key remain available.

Run, unit-ID, and correlation properties preserve calendar-valid compact timestamp tokens such as 20260804-195200, even when those digits coincidentally satisfy a card checksum. The exception is token-specific and key-aware: actual card numbers, emails, and credentials in the same value are still redacted, ordinary free-text properties do not receive the exception, and credential-classified properties are always replaced as a whole.

Stable identity and correlation keys (distinct_id, anon_distinct_id, session_id, message_id, group_type, group_key, and the exact top-level properties.$groups map) are not rewritten; they must instead be printable and at most 200 bytes. A nested $groups field receives the normal redaction policy. Property keys are at most 512 bytes, string leaves are at most 32,768 bytes, maps and arrays contain at most 250 entries, and nesting is at most 16 levels. Control characters are rejected throughout. Event names and property keys whose text itself contains detectable sensitive material are rejected rather than renamed. Validation remains best-effort per batch: valid siblings are sanitized and queued while each invalid event receives an indexed refusal, and error/log paths never echo the rejected property content.

The reserved identify event performs the same canonical-person mutation and alias linking but remains a rowless control event. Use an ordinary named event, as above, when the source payload must also be visible in the customer timeline.

Agent inventory and evidence

Every endpoint below requires a project, and the agents:read scope except where a section says otherwise — the two registration endpoints require agents:write instead. They answer five questions about the agents running against your systems.

Fleet health

GET /api/v1/fleet?project_id={projectId}&limit=100&cursor={nextCursor}

This is the server-owned Fleet view: agents, workflows and A2A peers ordered by 24-hour run count, then name and asset id. Each row carries declared/discovered status, environment, last sighting, run and non-completed-run counts, last run, open review-signal count, and optional live-containment metadata. Runs are attributed by stable observed identity rather than display name, so same-named agents remain separate. Each row's agent.observation_keys is the sorted set of immutable observed identities currently attributed to that asset. Use those values for run drill-through; name is a display label and is not a unique selector.

The first page fixes window_start and window_end; every opaque continuation preserves that activity window. limit defaults to 100 and may be at most 200. has_more is derived from the same ordered population as the returned rows, and next_cursor is present only when it is true. total_agents, total_shadow_agents, and the run totals describe the complete measured population, not only the current page. unmatched_runs and unnamed_runs report activity that could not be assigned rather than silently dropping it.

A successful response with total_agents: 0 means the fleet was measured and is empty. If an inventory or activity source cannot be measured, the endpoint returns 503; it never substitutes a healthy-looking empty page. The endpoint requires agents:read. Live-containment metadata is read only with quarantine:request or quarantine:approve; otherwise containment_visibility is FLEET_CONTAINMENT_VISIBILITY_WITHHELD, not "none contained". A fleet request does not accept an environment filter because observed identity already includes the environment boundary.

Outbound A2A drive

These three authenticated endpoints provide the explicit one-message outbound control plane:

PUT  /api/v1/assets/{assetId}/driver
POST /api/v1/assets/{assetId}/drive
GET  /api/v1/agent-drive-attempts/{attemptId}?project_id={projectId}

The two writes require agents:write; the exact attempt read requires agents:read. PUT accepts scope.project_id, endpoint, connection_id, and enabled. Enabling atomically binds an absolute HTTPS endpoint on that agent asset to an active saved A2A bearer credential. Disabling sends nothing and leaves the pair off.

POST .../drive accepts scope.project_id, a required message_text of at most 65,536 UTF-8 bytes, and optional A2A context_id and task_id of at most 1,024 UTF-8 bytes each. It has no endpoint field and rejects unknown fields. The endpoint and selected credential are held stable for the call; redirects are not followed. Calls have a 15-second deadline, a 1 MiB response limit, a 64 KiB limit on the complete JSON-encoded A2A request, and a per-project limit of 10 per minute.

The response carries the SDK-decoded A2A reply_json bytes on success (base64 in the REST JSON representation) and an attempt record in every expected outcome. Not enabled, missing credential, rate limited, invalid target, encoded request too large, agent protocol error, and unreachable are distinct outcome enum values. A reflected saved bearer is discarded as an agent error with no reply bytes or digest. A failure to read or record a prerequisite is an unavailable API response, never one of those refusals. The exact GET returns actor, asset, resolved endpoint, outcome, content digests, and timestamps; it returns neither message content nor credential material. See Drive one registered agent for the complete workflow and response table.

Recorded-person session replay

These endpoints replay a real recorded session against an enabled agent and read its durable, turn-by-turn comparison:

POST /api/v1/assets/{assetId}/session-replays
GET  /api/v1/agent-session-replays/{replayId}?project_id={projectId}

The POST requires agents:write and agents:content:read. It accepts scope.project_id, session_key, and confirm_real_person_content_export, which must be true on every request. It has no destination, persona, prompt override, or turn-limit field. The recording is limited to eight turns, and each project may start three replays per hour. Turns also consume the outbound drive limit. A recording that cannot be replayed exactly is stopped rather than shortened or rewritten; this includes fragmented content, conflicting identities, a final run not explicitly recorded as completed, and an absent or ambiguous final response.

The result reports the source session, canonical person, target asset, every recorded turn, original and replayed response text, exact comparison details, and total/attempted/sent/compared counts. AGENT_SESSION_REPLAY_STATUS_COMPLETED means every turn was sent and compared; AGENT_SESSION_REPLAY_STATUS_STOPPED and AGENT_SESSION_REPLAY_STATUS_REFUSED always carry a reason. Target containment and export/storage decisions bind before the first replay-progress record; they are checked again before each send. The person's identity links the result but is not sent to the target agent. A result-recording failure is returned as AGENT_SESSION_REPLAY_STATUS_STOPPED with a stage-specific persistence_*_failed reason, not as a generic availability response.

The GET requires agents:read. Message content additionally requires agents:content:read and the current read policy. When content is withheld, the status, counters, comparison kinds, digests, and sizes remain visible. See Replay a real recorded session for the workflow and result semantics.

Assets — declared and discovered agents

What agents exist, and which ones did you declare?

GET /api/v1/assets?kind=agent&shadow=true
GET /api/v1/assets?kind=tool&related_asset_id={agentAssetId}
GET /api/v1/assets/{assetId}
PUT /api/v1/assets/{assetId}/ownership
POST /api/v1/assets/{assetId}/reviews
PUT  /api/v1/assets/{assetId}/data-classification

kind accepts agent, workflow, tool, model, skill, mcp_server, a2a_agent, model_router, prompt, retrieval_corpus, memory_store, sandbox, gateway, workload_identity and policy.

agent, workflow, tool and model are the four kinds an inventory row can have: every asset is recorded from telemetry, and telemetry records exactly those four. The other eleven names are valid as component kinds in a signed bill of materials (POST /api/v1/abom-manifests), where you declare what an agent depends on, but nothing records them into the inventory. Filtering the inventory on one of them is therefore refused with 400 and a message naming the kind, rather than answered with an empty page that could be mistaken for "none were seen".

model is the model that served a call — a provider plus a model name. model_router is a different thing: whatever chose the model. The two are separate kinds on purpose, and a call whose served model differs from the requested one records the served model without asserting a router.

An asset with shadow: true was created from telemetry rather than from a declaration you made — something ran that you never registered. shadow_evidence_ref points at the observation that created it. Omitting the shadow parameter returns both kinds; passing false returns only the ones you declared. A shadow record's name is evidence of what a workload called itself, never an identity you should authorize against.

related_asset_id narrows to the assets an observation linked to the one you name. It works in both directions from a single parameter: an agent's id lists the workflows, tools and models that agent uses; a tool's or model's id lists the agents using it. The link is recorded only where one span named both sides, so an empty result means "nothing linked them in telemetry", never "unused". An id that is not a UUID is rejected with 400.

kind, lifecycle_state, approval_state and risk_tier may each be repeated or comma-separated. Values inside one parameter are OR-ed and different parameters are AND-ed, so ?kind=agent&approval_state=unreviewed means "unreviewed agents". Omitting a parameter does not narrow on that field, and an unrecognized value is rejected with 400 rather than quietly ignored — so a misspelled filter never widens the page into the whole inventory without saying so. Ordering is newest first by last_seen_at, paging is cursor-based, and the page limit defaults to 100 with a maximum of 200.

Assign or clear the current owner with:

PUT /api/v1/assets/{assetId}/ownership
Content-Type: application/json

{
  "scope": { "project_id": "project-id" },
  "owner_team": "platform",
  "owner_principal": "user:owner-42"
}

Both owner fields are replaced. An empty value clears that assignment, and both empty explicitly leave the asset unassigned. The organization and asset ID come from the authenticated principal and URL; values for either in the body cannot redirect the write.

Record or reopen an approval decision with:

POST /api/v1/assets/{assetId}/reviews
Content-Type: application/json

{
  "scope": { "project_id": "project-id" },
  "approval_state": "APPROVAL_STATE_APPROVED",
  "risk_tier": "RISK_TIER_HIGH",
  "reason": "Matched the reviewed deployment and tool inventory"
}

APPROVAL_STATE_UNREVIEWED, APPROVAL_STATE_APPROVED and APPROVAL_STATE_REJECTED are accepted; APPROVAL_STATE_UNSPECIFIED is rejected. risk_tier is required and is the reviewed blast radius: RISK_TIER_UNASSESSED, RISK_TIER_LOW, RISK_TIER_MEDIUM, RISK_TIER_HIGH or RISK_TIER_CRITICAL. RISK_TIER_UNSPECIFIED is rejected — send RISK_TIER_UNASSESSED to record that the blast radius has not been assessed, which is also the value an asset carries until its first review. The response returns the updated asset with approver_principal, approval_reason, reviewed_at and risk_tier. The approver is derived from the authenticated credential and cannot be supplied in the body, and a reason is required. Reviewing an asset changes its approval decision and risk tier only: approving a quarantined asset does not activate or otherwise un-quarantine it.

This review is the only writer of risk_tier, so ?risk_tier=high returns the assets a reviewer has actually placed in that tier — never an inferred one.

Record what data an asset handles with:

PUT /api/v1/assets/{assetId}/data-classification
Content-Type: application/json

{
  "scope": { "project_id": "project-id" },
  "data_classification": "DATA_CLASSIFICATION_RESTRICTED",
  "reason": "Reads the cardholder corpus"
}

DATA_CLASSIFICATION_UNCLASSIFIED, DATA_CLASSIFICATION_PUBLIC, DATA_CLASSIFICATION_INTERNAL, DATA_CLASSIFICATION_CONFIDENTIAL and DATA_CLASSIFICATION_RESTRICTED are accepted; DATA_CLASSIFICATION_UNSPECIFIED is rejected. Send DATA_CLASSIFICATION_UNCLASSIFIED to record that you looked and found nothing to classify — that is a decision, and it is stored with your name on it. It is also the value every asset carries until somebody classifies it, which is why the response distinguishes the two: an asset nobody has examined reports data_classification: "DATA_CLASSIFICATION_UNCLASSIFIED" with an empty classified_by.

The response returns the updated asset with data_classification, classified_by, classification_reason and classified_at. The classifier is derived from the authenticated credential and cannot be supplied in the body, and a reason is required. Classification is an independent axis: it changes no approval state, risk tier, owner or lifecycle state, and reviewing an asset does not change its classification.

All three writes require agents:write. Reads and filters continue to require agents:read.

These endpoints are scoped by project, not by environment: they take no environment parameter, and supplying one is rejected rather than ignored. Each asset record still reports the environment it was seen in.

What still needs somebody to look at it?

GET /api/v1/review-signals?status=open
GET /api/v1/review-signals?asset_id=...

A review signal is the other half of a discovery. GET /api/v1/assets?shadow=true tells you an undeclared agent is running; the review queue tells you whether anybody has dealt with it — the status (REVIEW_SIGNAL_STATUS_OPEN, REVIEW_SIGNAL_STATUS_ACKNOWLEDGED, REVIEW_SIGNAL_STATUS_RESOLVED, REVIEW_SIGNAL_STATUS_DISMISSED), who resolved it and when, and last_seen_at, which moves forward while the thing is still active.

Each signal names its kind — REVIEW_SIGNAL_KIND_SHADOW_ASSET for something running that you never declared, REVIEW_SIGNAL_KIND_ABOM_DRIFT for a deployment whose configuration in the field no longer matches the manifest that attests it, REVIEW_SIGNAL_KIND_CONFIG_UNREGISTERED for an attested deployment with no registered configuration at all, so nobody can tell whether it drifts, REVIEW_SIGNAL_KIND_KILLED_ASSET_CREDENTIAL when telemetry from an API key resolves to an agent under an active kill containment but that key was outside the containment's approved set, REVIEW_SIGNAL_KIND_POISONED_MEMORY_ITEM when somebody has labelled a memory item quarantined, REVIEW_SIGNAL_KIND_UNREVIEWED_ASSET / REVIEW_SIGNAL_KIND_UNOWNED_ASSET for an asset that has stood in the inventory past the review window with no approval decision, or with neither an owner team nor an owner principal, and REVIEW_SIGNAL_KIND_REVOKED_AUTHORITY_EXERCISED when a mediated action evidenced a delegation edge that had already been revoked — plus a severity, a subject_ref identifying what the signal is about, and a detail_code: a short lowercase code you can group by, never a provider message. For the killed-credential kind, subject_ref is the non-secret API-key id, asset_id is the agent, evidence_ref is the containment id, and opened_at / last_seen_at are the source sighting times.

You do not have to watch this queue to hear about any of these: every kind's open and clear transitions are sent to every enabled notification channel in the organization. You can also create a rule with the review_signals source to target a project, kind, severity, or subject and choose its notification or escalation path. Repeated sightings refresh one signal and do not send another opening.

Every kind clears by itself once somebody deals with it, so a rule filtered on status=open stops without anybody clearing the alert by hand. shadow_asset and unreviewed_asset clear when POST /api/v1/assets/{assetId}/reviews records an approval or a rejection; unowned_asset clears when PUT /api/v1/assets/{assetId}/ownership sets either owner field; a poisoned_memory_item signal clears when the item is labelled trusted again; the drift kinds clear when a matching configuration is registered; and a killed-credential signal clears when the key is revoked or the containment lifted — it still never contains or revokes anything automatically.

kind, status and severity may each be repeated or comma-separated. Values inside one parameter are OR-ed, and different parameters are AND-ed, so ?kind=shadow_asset&status=open means "open shadow-asset signals". Omitting a parameter does not narrow on that field; an unrecognized value is rejected rather than quietly ignored, so a filter that does not apply is never silent. Ordering is newest first by last_seen_at, paging is cursor-based, and the page limit defaults to 100 with a maximum of 200 — the same rules as /assets.

A resolved signal stays in the queue as history. A later sighting of the same still-undeclared agent refreshes an open signal but never reopens one somebody closed.

Which telemetry belongs to which agent?

GET /api/v1/assets/attribution?observation_key=...&observation_key=...
GET /api/v1/assets/{assetId}/attribution

The first direction maps observation keys from your telemetry onto their current asset; the second returns every observation key that currently maps to one asset. The response carries an availability field, so a lookup that could not be answered is distinguishable from one that was answered with no match.

Each mapping also carries credentials: which API key produced that telemetry, when it was first and last seen doing so, and how many sightings carried it. This is what turns "quarantine this agent" into something you can act on — the key named here is the one to revoke.

Each entry states its source, and the distinction is the point:

source What it means
api_key An API key produced this telemetry, and api_key_id names it. This is the only entry a revocation can act on.
bearer_token A signed-in session produced it. There is no key to revoke; the control is the person's access.
dev_org_header A development-only trust setting accepted it, with no credential at all. Nothing can revoke it — if you see this outside development, that setting is on where it should not be.

An empty credentials list is not "no credential was used". It means nothing was recorded for that observation key — telemetry that arrived before this was tracked, for instance. Telemetry that provably carried no key is a present entry naming bearer_token or dev_org_header, never an absent one, so "we know there was none" and "we do not know" are never the same answer.

Containing a misbehaving agent

How do I stop it?

GET  /api/v1/quarantines
POST /api/v1/quarantines
GET  /api/v1/quarantines/{quarantineId}
POST /api/v1/quarantines/{quarantineId}/approve
POST /api/v1/quarantines/{quarantineId}/reenforce
POST /api/v1/quarantines/{quarantineId}/lift
POST /api/v1/quarantines/{quarantineId}/withdraw

POST /quarantines requires quarantine:request and asks for an asset, an observation key, or one API key to be freeze_writes (the default), revoke_credentials, or kill — see Permission scopes for what each one does. Send exactly one of asset_id, observation_key, or api_key_id, plus a required reason; an optional environment narrows the target and an optional request_id makes a retried request idempotent instead of opening a second one. A request touches no credential by itself and answers 201 with status: "pending".

POST /quarantines/{quarantineId}/approve requires quarantine:approve and takes no body — the target and action were fixed by the request, and an approver who could edit either would be approving something other than what was reviewed. The platform refuses an approval made by the same principal that made the request. Approving is what resolves the target to its exact credential set and disables it, and answers 200.

POST /quarantines/{quarantineId}/reenforce requires quarantine:approve, takes no body, and re-runs the parts of an active containment recorded as failed — a credential that could not be reached, or a mint ban that could not be installed. It approves nothing: the action, the target and the credential set are the ones the two principals already agreed, and only rows marked failed are re-attempted, because re-issuing a freeze over an already-frozen credential reports a weaker outcome than the one already recorded. The response carries retried, the number of things re-attempted; 0 means there was nothing left to repair and is a success. Use this rather than lifting: a lift restores the credentials and re-opens the mint path, which for a kill re-opens the agent in order to repair the thing meant to keep it shut. Anything that is not active answers 400.

POST /quarantines/{quarantineId}/lift also requires quarantine:approve, takes a required reason, and restores whatever a freeze reduced. It does not restore a revoked credential — revoke_credentials and kill have no undo; mint a replacement key instead. Nor does it free a credential another active quarantine still covers: that credential comes back on the last lift that releases it, and until then it is reported with detail_code held_by_another_quarantine. Two lifts issued at the same moment are safe — whichever of them ends up last still releases the shared credential, so simultaneous lifts cannot leave an agent contained by nothing.

Both lift and withdraw return the reason they were given, as lift_reason and withdraw_reason on the quarantine, alongside lifted_by/lifted_at and withdrawn_by/withdrawn_at. Each is capped at 1024 characters.

POST /quarantines/{quarantineId}/withdraw retracts a request nobody has approved yet, takes a required reason, and answers 200 with status: "withdrawn". It reaches no credential — there was nothing to reach — and its point is that the request stops occupying its target: while a containment is pending, no second one can be raised against the same agent, and a request that is never approved would otherwise block it indefinitely. It is refused with 400 on anything that is not pending; an approved containment ends with a lift, which restores credentials.

The scope it needs depends on whose request it is. Retracting your own takes only quarantine:request, because a pending request enforced nothing and retracting it restores capability to nobody. Retracting somebody else's takes quarantine:approve: it suppresses a containment before a second principal has considered it, which is the same grade of decision as refusing one.

The withdrawn request keeps its request_id. Re-sending that id returns the withdrawn record rather than opening a new containment — the same idempotency that protects a retry — so raising the request again means choosing a new request_id.

Two refusals are worth knowing before you script this. A request_id already used by a different principal answers 409 and tells you nothing about their request — pick another id. And an api_key_id target that names no key in your organization and in the containment's project answers 404 at approval, when the credential is checked, rather than producing a quarantine that claims to have contained it. A key created with org_wide belongs to no project and stays reachable from any of them.

GET /quarantines/{quarantineId} and GET /quarantines (repeatable status, plus limit) accept either scope — a requester has to be able to see the state of their own ask. Both return the same shape: status (pending / active / lifted / withdrawn), the per-credential outcomes under credentials, and unenforceable_sightings — telemetry sightings that carried no credential the platform could act on, which is why a fully-approved quarantine can still report unknown rather than verified for a resolved credential's own enforcement state.

See Contain an agent for what each action actually does to a credential — freezing stops a typical SDK key's telemetry, revoking has no undo, and kill's "no new credential ever again" reading is not something the platform enforces — and for a worked request → approve → verify → lift walkthrough.

Content policy

What may this project's recorded content be used for?

GET /api/v1/content-policies
GET /api/v1/projects/{projectId}/content-policy
PUT /api/v1/projects/{projectId}/content-policy

A project's content policy is a grid: six kinds of recorded content — model_transcript, tool_arguments, replay_dom, person_properties, memory_item, group_properties — against four boundaries — store (recorded at all), read (served to a reader), export (included in a bulk export or sent to a customer-controlled task callback), judge_transfer (sent to an evaluation model). Each cell is allow, allow_metadata (you may know the content exists and how large it is, never its bytes) or deny.

The two GETs require governance:read and return every cell, denied ones included, so you can render the whole grid without knowing the vocabulary in advance. GET /content-policies returns one policy per project in the organization, newest-changed first, and accepts limit and an opaque cursor; follow next_cursor until it is empty. A credential restricted to one project sees only that project, including on organization-wide policy and audit listings.

A project-scoped credential is pinned to its signed project on every policy, retention, refusal-audit, and erasure route, including routes whose project is written in the path. It cannot select a sibling project by replacing {projectId}. The policy list is narrowed to that same project; an organization-scoped credential retains the organization-wide list and explicit path selection.

Every project has a policy, including one you have never opened, and a project with no policy at all is answered as a fully-denied grid rather than a 404 — "no policy" and "denies everything" are the same state. A policy carries a version that increases on every change, plus updated_by.

PUT requires governance:write and replaces the grid; it does not merge. Send the complete set of cells you want to hold — a cell you omit, and a cell you send as deny, are the same thing. Pass the expected_version you last read to have the write refused with 409 if somebody changed the policy meanwhile; omit it to write unconditionally. An unrecognized class, boundary or decision is refused with 400 rather than dropped, so a typo can never quietly remove a row.

{
  "expected_version": 3,
  "rules": [
    { "content_class": "model_transcript", "sink": "read", "decision": "allow" },
    { "content_class": "model_transcript", "sink": "judge_transfer", "decision": "deny" }
  ]
}

A change takes up to 30 seconds to apply everywhere.

How long is each kind of recorded content kept?

GET /api/v1/projects/{projectId}/content-policy/retention
PUT /api/v1/projects/{projectId}/content-policy/retention

A retention window is a per-kind number of days, using the same six content_class values as the grid above. GET requires governance:read, PUT requires governance:write — the same two permissions, because this is the same control along a different axis.

Unlike the grid, the GET returns only the kinds that have a window. A kind with none is genuinely absent, and that means "kept for as long as the organization keeps it anyway". No default number is synthesized, because a number here is an instruction to delete and you must never be shown one you did not choose.

retention_days is at least 1 and at most 365 — the platform storage ceiling. Your organization’s effective history may be shorter; this setting does not extend it. A window only ever brings deletion FORWARD; a longer value is refused with 400 rather than stored as a promise that cannot be kept. 0 is refused too: it is what an omitted field looks like, and it must never be read as "keep nothing".

PUT replaces the whole set. A kind you omit has its window removed and returns to the default — which is how you undo a tightening, and the reason this is not a PATCH. An empty retention array clears every window. set_by and set_at are response-only and ignored on input.

{
  "retention": [
    { "content_class": "model_transcript", "retention_days": 7 },
    { "content_class": "replay_dom", "retention_days": 30 }
  ]
}

Shortening a window is the one change on this page that cannot be undone: widening a denied cell brings content back, and widening a window brings nothing back. Every change is recorded with what it was before, who made it and when.

A window that has passed is enforced on a schedule, so content can outlive its window by a few hours before the next pass removes it. What the pass removes is the CONTENT, not the record: for transcripts and tool arguments the prompt, completion, arguments and results are erased from the trace and the trace itself — its timing, status, service and token counts — is untouched. The same erasure reaches the other places that kind of content is kept: agent steps, a metric's exemplars and labels, a log line's attributes and resource, an error's tags, extra and request headers, the tags on the issue it rolls up into, and a product event's properties. For session replay the recording is the content and is removed outright. Windows on the two customer-property kinds are accepted and audited and are not yet enforced; they are reported as windows the organization could not act on rather than silently ignored.

What has this project's policy refused?

GET /api/v1/projects/{projectId}/content-policy/access-audit

Requires governance:read. Lists the times this project's policy WITHHELD content, newest first. Optional filters: content_class, sink, principal_id, since, until (RFC3339), limit, and the opaque cursor returned by the preceding page (default 200, maximum 1000). An omitted filter means every value rather than the unset one, so a bare call returns the whole project; an unparseable since or until is 400 rather than silently ignored. Preserve the cursor unchanged with the same filters; next_cursor is empty on the final page. The cursor includes a stable tie-breaker, so refusals sharing one timestamp are not skipped.

{
  "records": [
    {
      "record_id": "9c1f…",
      "scope": { "org_id": "…", "project_id": "…" },
      "content_class": "CONTENT_CLASS_MODEL_TRANSCRIPT",
      "sink": "CONTENT_SINK_EXPORT",
      "decision": "CONTENT_DECISION_DENY",
      "reason": "policy",
      "default_posture": false,
      "principal_kind": "CONTENT_ACCESS_PRINCIPAL_KIND_API_KEY",
      "principal_id": "…",
      "subject_ref": "",
      "occurred_at": "2026-08-28T09:14:02Z",
      "recorded_at": "2026-08-28T09:14:03Z",
      "count": 1
    }
  ],
  "next_cursor": "eyJ…"
}

count is how many identical refusals the entry stands for, and it is 1 for everything a person or a credential did. Refusals taken while recording content are different in kind: that decision is taken once per event on the ingest path, so a project whose policy refuses transcripts would produce one entry per event forever. Those are grouped by the minute, keeping the kind, the boundary and the reason, and count is how many there were. An entry with no count means one. If you are adding up what your policy has refused, add up count rather than counting entries.

reason says whose decision it was, and only one of its values means the control working as you configured it: policy is your own stored decision, no_policy means the organization default answered because this project had not been set up yet, resolver_error means the policy could not be read at all, no_project_scope means the request named no project, and unclassified means the caller could not say what it was sending. default_posture is the same distinction as a flag.

decision is CONTENT_DECISION_DENY or CONTENT_DECISION_ALLOW_METADATA. The second is still a refusal: existence, size and digest travelled and the bytes did not.

subject_ref is present only where the refused call named one person; it is empty for a decision about a payload rather than about a subject. occurred_at is when the decision was taken and recorded_at is when it was written down — they differ by a second or two because recording never delays the request it describes.

This is not an access log. A permitted read writes no entry, because a permitted read is every successful read in the organization. So an empty page means nothing was withheld — never that nothing was read — and this endpoint cannot answer who has viewed a given person's content.

Erasing a person's recorded content

Erase one person's content now

DELETE /api/v1/projects/{projectId}/content/{class}/{subjectRef}

Requires governance:write. Supported classes are model_transcript and tool_arguments; subjectRef is the person's distinct_id inside the project. The body requires a reason and a canonical operation UUID:

{"reason":"Account owner requested removal, ticket 4192","operation_id":"649ebca1-0452-45b7-aad3-29411ad0f6cd"}
{"rows_affected":12,"action_id":"erasure:649ebca1-0452-45b7-aad3-29411ad0f6cd","already_erased":false,"receipt_missing":false,"operation_id":"649ebca1-0452-45b7-aad3-29411ad0f6cd"}

The clear removes that person's class-specific attributes from subject-addressable telemetry and preserves surrounding records and other people's content. The original decision has a durable audit record, including a first zero-row clear. Retrieve its receipt with GET /api/v1/action-receipts/{actionId}.

Exact retries reuse the operation UUID and return the original count and action identity. already_erased means the original operation cleared zero rows; retries do not change it to true. Reusing the UUID with changed scope, subject, class, requester or reason is a 409 conflict. A new deliberate clear requires a new UUID. After durable completion, an exact retry does not clear later arrivals. Pending recovery can clear arrivals before completion.

receipt_missing: true means the physical clear completed but its receipt acknowledgement remains pending. Recovery retries the same frozen receipt without repeating the completed clear. The stable action identity remains available; persistent failures require operator attention.

replay_dom answers 501 here: recordings are selected by age across a project with no per-person selector, so a subject-scoped request is refused rather than deleting every recording in the project. Delete the individual recordings instead. memory_item, person_properties and group_properties are also unsupported here and return 501. An unknown or omitted class is 400; a reason longer than 400 bytes is 400.

All four boundaries have enforcement points. Eighteen of the twenty-four cells bind today, and six cells have nothing to gate. The product marks each cell with which of those states it is in. Decide what recorded content may be used for carries the whole grid and is the page to read before relying on a cell. In short:

  • store and read enforce model_transcript, tool_arguments, replay_dom, person_properties and group_properties. A denial at store is irreversible — the bytes are never written. A denial at read is not: the content stays recorded and a later change serves it again. A replay_dom denial refuses the chunk upload or the playback outright rather than returning an empty recording; a person_properties denial drops the property values from identify() while leaving the identity itself intact, and leaves the property bag off a person read, per person, according to the project that person belongs to.
  • group_properties is independent from person_properties. It defaults to allow at store, read and judge_transfer, preserving the existing account experience, and to deny at export. Its model-transfer cell is enforced: an account investigation omits the property bag unless both the caller's account access and this class permit the transfer. Its store and read cells enforce the same property bag. Nothing exports that bag today, so its export cell has nothing to gate.
  • export applies to model_transcript, tool_arguments, replay_dom and person_properties. Bulk telemetry exports carry model transcripts and tool arguments; content the policy refuses is withheld from the file and the job says which classes and why. A configured task callback may carry an investigation conclusion derived from those classes, a session snapshot, or customer properties, so it rechecks this boundary before every delivery attempt and sends task metadata without the conclusion when the transfer is denied or cannot be confirmed. See Save and export data and Connect an agent over A2A.
  • judge_transfer enforces those two, person_properties and group_properties. An automatic quality check on a run whose content is not permitted is recorded as withheld by policy rather than scored or silently skipped (Automatic quality checks), and a Customer Detective investigation leaves that content out of what it shows the model, then says so in a ## Withheld by policy section of the answer (Investigate with Customer Detective).

memory_item has no producer at all, so editing its cells records your intent without changing behavior. Documents pulled in by a retrieval step are recorded as part of the model transcript and are governed by model_transcript.

Two knock-on effects of person_properties: deny at read are worth knowing: people search stops matching on property values for that project (searching by an identifier you sent with identify() still works), and audiences defined by a property value stop refreshing. A people search that names no project takes the narrower behavior whatever your policy says — pass project_id to keep property matching.

read, export, and automatic judge_transfer decisions are independent. An export asks the export cell even when read is denied, and an automatic quality check asks judge_transfer. Customer Detective evidence still passes through read first because it forms a user-visible investigation, then through judge_transfer before anything is sent to a model.

Conversations

What happened in a conversation?

GET /api/v1/agent-sessions
GET /api/v1/agent-sessions/{sessionKey}
GET /api/v1/agent-turns?session_key=...

A session is one conversation, a turn is one exchange within it, and a run is one execution the agent performed to answer a turn — a turn can span several runs, and run_ids lists them in start order. Sessions are newest-activity-first and cursor-paged; turns require a session_key, because a turn list with no conversation is a scan of everything you own.

A session's turn_count counts only turns that carry an identifier. Telemetry that reports no turn id contributes none, so a session showing 0 turns and non-zero activity means the instrumentation is not reporting turns — not that nothing was said.

GET /agent-sessions/{sessionKey} answers 404 for a session with no activity in scope, rather than an empty body, so "no such conversation" is never confused with "a conversation that did nothing".

Telemetry completeness

How complete is my agent telemetry?

GET /api/v1/telemetry-completeness

Two answers about one set of spans. buckets gives exact row counts per normalizer version and source schema, newest first — deliberately buckets rather than one percentage, because "94% complete" cannot distinguish a fleet that is uniformly current from one where a tenth of your data came from a version behind and means something subtly different. defects gives the telemetry-quality verdicts: seven detectors over the same runs, each an exact count.

kind Counts What it means
PARTIAL_TRACE runs Nothing in the run is a root: the span that started it never arrived, so what you can see is a fragment
ORPHANED_WORK spans A step points at a parent that is not in its run — the work was recorded, the context it belongs to was not
INVALID_TIMING spans A step ends before it starts, or one of its timestamps is the Unix epoch, which is what an exporter that never set the field sends
MISSING_TERMINAL_OUTCOME runs The run never declared how it ended, so its status is inferred. This is the same population as status_explicit: false
ABSENT_SESSION_ATTRIBUTION runs No step carries a session key, so the run cannot be placed in its conversation
ABSENT_PERSON_ATTRIBUTION runs No step carries a distinct id, so the run cannot be attributed to a customer
ABSENT_VERSION_ATTRIBUTION runs No step carries an agent version, so a regression cannot be attributed to the change that caused it

On the wire each value carries its enum prefix — TELEMETRY_DEFECT_KIND_PARTIAL_TRACE, and likewise TELEMETRY_DEFECT_STATUS_* and TELEMETRY_DEFECT_UNIT_* — as in the example below. affected and evaluated are 64-bit counts and are encoded as JSON strings.

All seven are always present, whether they found anything or not, so you can read the list as a checklist without having to guess whether a missing entry means "clean" or "not looked for".

affected: 0 is not automatically good news, and status is what tells you which it is:

  • DETECTED — some of what was examined carries the defect.
  • CLEAN — something was examined and none of it carries the defect.
  • NOT_MEASURED — nothing was examined. The window, the filters, or the agent selector matched no runs, so the zero says nothing about your fleet. Treat this as "ask a different question", never as a pass.

evaluated is the population each count was taken over, and it is why status can say that: affected: 2 out of evaluated: 14 and out of evaluated: 14000 are not the same report. Note the two units — run-level detectors and span-level detectors have different denominators, so compare a count only against the evaluated beside it.

{
  "defects": [
    {
      "kind": "TELEMETRY_DEFECT_KIND_PARTIAL_TRACE",
      "status": "TELEMETRY_DEFECT_STATUS_DETECTED",
      "unit": "TELEMETRY_DEFECT_UNIT_RUN",
      "affected": "1",
      "evaluated": "8"
    }
  ]
}

The window selects runs, not spans: a run that started inside it is examined whole, including steps that began earlier. That is deliberate — narrowing the window must never make a run look like it is missing its first steps.

Each bucket also says what its rows can still be read to mean. Knowing that a tenth of your data came from an older normalizer is only useful next to the answer "and here is which fields of it no longer mean what you would mean". Every bucket carries a resolution:

{
  "normalizer_version": 5,
  "source_schema": "openinference",
  "row_count": "1204",
  "resolution": {
    "reader_version": 6,
    "recognized": true,
    "unresolved": true,
    "unresolved_fields": ["agent_observation_key"],
    "absent_fields": []
  }
}
Field What it tells you
unresolved the verdict. false means these rows can be read exactly as the newest rows are
unresolved_fields a later normalizer changed what these fields mean. The stored values are not comparable with the current ones, and cannot be recomputed — treat them as a different measurement, not a stale one
absent_fields a later normalizer started reading these fields. They are empty in these rows because nothing looked for them, which is not the same as your producer having sent nothing
recognized: false these rows predate the normalizer history this API knows, or were written by a newer one. No claim is made about them at all
ahead the rows are newer than the reader — you are reading during a rollout; ask again shortly
mixed only on aggregates: the rows behind one number were not all written by the same normalizer

The same resolution appears inside provenance on runs, steps and events, so any single result carries its own verdict. A run's is a range: a run whose steps straddled a normalizer change comes back with mixed: true rather than quietly reporting the newer version for all of it. If resolution is absent from a response, that endpoint did not measure it — it never means the row is current.

Two defects have a fix on your side rather than ours. PARTIAL_TRACE and ORPHANED_WORK are almost always a dropped or never-flushed export: check that your exporter flushes on shutdown and that its queue is not silently discarding batches. INVALID_TIMING at the epoch means a span was exported with its timestamps never set.

The agent run graph

What did an agent actually do?

GET /api/v1/agent-runs?agent_observation_key=...&person_id=...&group_type=...&group_key=...
GET /api/v1/agent-runs/{runId}/graph
GET /api/v1/agent-events?run_id=...
GET /api/v1/agent-events?run_id=...&category=context&context_source_id=...
GET /api/v1/agent-events?memory_item_key=...
GET /api/v1/action-receipts?chain_id=...
GET /api/v1/action-receipts/{actionId}

The run list accepts repeated or comma-separated agent_observation_key values and matches any of them. It also accepts a canonical person_id, or an account selector supplied as the pair group_type and group_key; sending only half of that pair is invalid. These are whole-run selectors: when any step carries the selected identity, the response retains every step in that run. Display names are labels and should not be used to distinguish two fleet records.

Comparing releases. The run list also accepts release=<version>, which keeps runs whose earliest declared gen_ai.agent.version matches exactly, and without_release=true, which keeps the runs that declared no version at all. The two cannot be combined; sending both is invalid.

Every response — including one with no runs in it — carries release_coverage for the population your other filters select, with the release filter itself removed. It reports run_count, released_run_count and the sorted releases observed, and a state of AGENT_RELEASE_COVERAGE_STATE_NO_RUNS (nothing to measure), AGENT_RELEASE_COVERAGE_STATE_NONE (runs exist, none declared a version), AGENT_RELEASE_COVERAGE_STATE_PARTIAL or AGENT_RELEASE_COVERAGE_STATE_COMPLETE. Read it before you conclude anything from an empty page: a release filter over a project that never sets the attribute returns no runs, and without the coverage beside it that is indistinguishable from a release with nothing wrong. Each run also carries release_declaration, so an empty release is reported as AGENT_RELEASE_DECLARATION_UNDECLARED rather than left ambiguous.

/agent-runs/{runId}/graph returns the run, every step in it, the non-span evidence attached to it, and the action receipts it produced, in one response. Steps are in start order and each carries parent_span_id — that field alone defines the shape of the run; there is no separate edge list to reconcile it against.

Every model step also carries a context summary. instrumented: true means at least one valid assembly, compaction, or cache event was accepted for that step; assembled_tokens, overflow, compactions, and cache_outcome are its fold. A model step with no accepted context event still returns the object with instrumented: false, including older rows and rejected-only telemetry. Non-model steps omit it. Never infer instrumentation from zero-valued counters or the normalizer version: an instrumented assembly may measure zero tokens.

Each step also carries outcome and, when it failed, a bounded error_type code — so the graph names which step failed rather than only how many did. outcome is what the step itself declared about its response; it is never derived from a span's status, so OUTCOME_UNSPECIFIED means the step declared no outcome and never that it succeeded. The same two fields are on the agent-facing get_agent_run_graph capability, spelled there as the bare lowercase member (error, ok, unspecified).

error_type is the error.type attribute your instrumentation set on that step — the error class, such as RuntimeError or java.lang.IllegalStateException. It is a code, not a message: values longer than 64 bytes, or carrying anything but letters, digits, ., _, -, : and /, are dropped rather than shortened, because a truncated class name would name an error you never raised. A step's free-text status message is never served here for the same reason.

When a value is dropped, the step says so: error_type_rejection names the reason — too_long for a value over the 64-byte bound, not_a_code for one carrying characters no error class or code does. It is unspecified (and omitted from the MCP capability's JSON) when nothing was dropped, which includes the ordinary case of a step that declared nothing at all.

So an empty error_type has two readings and the sibling field tells you which: with no rejection beside it, the step declared no error class — set error.type on the failing span if you want the graph to name it. With a rejection beside it, your instrumentation is setting the attribute and this platform will not serve the value it sent; send the class rather than the message, and the graph will name it. The two need different fixes, which is why they stopped sharing one answer.

The run and each model step also carry cost_nanos beside a cost_source that says how the number was arrived at — a cost is not readable without it:

  • COST_SOURCE_CLIENT — you reported the charge (anectico.gen_ai.cost_usd, or a per-token price override). A zero here is a measured zero: a free tier, a cached completion, a call that genuinely cost nothing.
  • COST_SOURCE_TABLE — this platform priced the call from token counts and the model's rate.
  • COST_SOURCE_UNPRICED — a priced model call for which no rate is known here. Set a pricing override for the model, or report the cost yourself.
  • COST_SOURCE_CLIENT_INVALID — you reported something that is not a cost: a negative charge, a NaN, an infinity, or a per-token price that is one of those. Each stated per-token price is judged on its own, so an honest output price does not rescue a negative input price, and a negative price applied to zero tokens is still refused. The cost reads 0 because nothing is known about it, and the call is excluded from every spend total — fix the instrumentation that sends the value.
  • COST_SOURCE_MIXED — run only: at least one call has a valid measured CLIENT or TABLE cost and at least one other call has CLIENT_INVALID. The run's cost_nanos is the subtotal of only the valid calls; the refused claim contributes nothing and remains visible regardless of which call happened first. Inspect the model steps for each call's exact source.
  • COST_SOURCE_NONE — the step reported no cost and none was derived.

The distinction between COST_SOURCE_UNPRICED, COST_SOURCE_CLIENT_INVALID, and COST_SOURCE_NONE matters: COST_SOURCE_UNPRICED is a gap here that you close with a pricing override, COST_SOURCE_CLIENT_INVALID is a gap in what your instrumentation sent, and COST_SOURCE_NONE is neither. A refused claim is never served as COST_SOURCE_CLIENT with a zero, because that would be indistinguishable from a call that really was free. At run level provenance is an order-independent fold: COST_SOURCE_MIXED takes priority for valid-plus-invalid calls; otherwise the precedence is COST_SOURCE_CLIENT_INVALID, COST_SOURCE_UNPRICED, COST_SOURCE_CLIENT, COST_SOURCE_TABLE, then COST_SOURCE_NONE. This makes an incomplete subtotal visible while each model step continues to state the exact source of its own cost.

action_receipts is the run's ledger of consequential actions, in the same folded form /action-receipts returns: requester_principal for the principal that asked for it, approver_principal and approved_at for an action that was approved, delegator_chain for the authority it was taken under (outermost first), policy_id and policy_version for the policy it was authorized against, and final_state, where COMPENSATED means the effect was deliberately undone rather than merely completed. None of this is a step — an approval and a compensation are transitions in an append-only chain, not spans — so the graph is where you read what a run was permitted to do, and the step tree is where you read what it did.

Each receipt carries its entries, and each entry carries its own authority. The receipt's authority is the folded value; when you need to know whether Anectico performed an action or merely received a report that it happened, read the entry. An empty action_receipts means the run took no receipted action; a ledger that cannot be read is an error, never an empty list. A run whose ledger is past the size ceiling answers 429, exactly as an oversized step tree does.

The graph is whole or it is refused. There is no page size. A page of a graph is a graph with missing parents or evidence, which is indistinguishable from a genuinely small run. A graph with more than 2,000 evidence events therefore answers 429 instead of returning the first 2,000 as if they were complete; the step tree and the other whole-graph collections have their own ceilings for the same reason. The CLI exits with the request error and the get_agent_run_graph capability returns a tool error, so neither surface emits a plausible partial result. If you hit the ceiling, the run is almost certainly a loop, and /agent-events?run_id=... will page through its evidence.

A run's status is a producer declaration or undeclared; the platform does not turn child errors into a run verdict. status_explicit is true only when the run supplied a trustworthy terminal outcome. error_count remains the number of errored spans and can be non-zero on a successfully recovered run, so use it as step-level evidence rather than as the run result. An undeclared run has an empty terminal_reason.

outcome_vocabulary records how the run can declare its outcome. AGENT_RUN_OUTCOME_VOCABULARY_ANECTICO means it carries the Anectico agent-observability vocabulary, AGENT_RUN_OUTCOME_VOCABULARY_DECLARING_VENDOR means its root agent span came from a recognized framework integration that explicitly sets OpenTelemetry status, and AGENT_RUN_OUTCOME_VOCABULARY_NON_DECLARING means no declaring integration could be identified confidently. The terminal-status and terminal-reason fields are excluded from the Anectico vocabulary test, so a run cannot qualify for an instrumentation-coverage denominator merely by already carrying the outcome being measured. An UNSET OpenTelemetry status remains undeclared in every vocabulary.

A run's release comes from the SDK-declared gen_ai.agent.version. Read release_declaration with it: AGENT_RELEASE_DECLARATION_DECLARED means the value was present, while AGENT_RELEASE_DECLARATION_UNDECLARED means the run was measured and sent none. Agent-run list responses additionally carry release_coverage with the run count, released-run count, sorted declared releases, and one of AGENT_RELEASE_COVERAGE_STATE_NO_RUNS, AGENT_RELEASE_COVERAGE_STATE_NONE, AGENT_RELEASE_COVERAGE_STATE_PARTIAL, or AGENT_RELEASE_COVERAGE_STATE_COMPLETE. In particular, AGENT_RELEASE_COVERAGE_STATE_NONE means runs exist but this population declares no releases; it is not an empty-result guess.

GET /api/v1/evaluation/results returns stored evaluation results. Every result carries a provenance.execution object recording how it came to exist: sampling (SAMPLING_DECISION_SAMPLED, SAMPLING_DECISION_NOT_SAMPLED or SAMPLING_DECISION_WHOLE_DATASET) with sampling_rule_id and sampling_rate present only for SAMPLING_DECISION_SAMPLED; cost_state (COST_STATE_CHARGED, COST_STATE_FREE, COST_STATE_UNPRICED, COST_STATE_UNREPORTED) with charged_cost_nano_usd, input_tokens and output_tokens; judge_model_state (JUDGE_MODEL_STATE_RECORDED, JUDGE_MODEL_STATE_NOT_APPLICABLE, JUDGE_MODEL_STATE_UNREPORTED) with judge_model; and job_status (JOB_STATUS_COMPLETED, JOB_STATUS_PARTIAL, JOB_STATUS_FAILED, JOB_STATUS_CANCELED) with job_failure_class. Read the state before the value in each pair: a charge of zero means a free evaluator under COST_STATE_FREE and an unknown price under COST_STATE_UNPRICED, and judge_model is the model that answered rather than the one the evaluator version requested. JOB_STATUS_FAILED and JOB_STATUS_CANCELED appear only on the immediate reply to POST /api/v1/evaluation/evaluate, where the score exists and its storage is still owed; no stored result carries them, and a release gate refuses both. A result written before this record existed returns empty values here and is refused by release gates for the same reason a result with no evaluator version is.

GET /api/v1/evaluation/release-series attributes one exact evaluator version and metric to those observed releases. It requires evaluator_id, positive evaluator_version, and metric_name; accepts optional inclusive RFC3339 start and end subject-time bounds; and returns declared releases oldest first with counts, means, and the delta from the preceding release. If every identity is semantic versioning the order uses semantic precedence; otherwise the entire series uses first-observed time, and ordering_strategy states which rule won. Explicitly unreleased results appear in a separate aggregate instead of disappearing or acquiring an invented release identity.

counts.handoffs is measured. A step is counted as a handoff when your instrumentation declared anectico.agent.handoff.target on it, so a 0 here means no handoff was recorded in that run — it is an answer, not a gap. The count reads the declared target and not the step's operation: recording a handoff on your agent-run span annotates that span and does not rename it, so the step — and the run — keep CANONICAL_OPERATION_INVOKE_AGENT and are still counted here.

There are TWO guardrail counts and they are different numbers. counts.guardrail_checks counts every step on which a guardrail ran — one your instrumentation named, or one reported by a framework that says a guardrail ran and nothing more — counted once however it qualified. Recording a guardrail on your agent-run span leaves that step categorized as agent work and still counts it here. counts.guardrail_decisions counts only the steps whose instrumentation declared anectico.guardrail.name. The gap between them is how much of your guardrail surface reports what it decided.

A step carries a guardrail object when it declared that name. It reports name, the guardrail's own result, a finding_class, the policy_id and policy_version in force, and the decision the policy reached — one of allow, deny, modify, escalate. A step with no guardrail object means the guardrail half was not instrumented; it never means the guardrail passed. A decision of GUARDRAIL_DECISION_UNSPECIFIED means the same thing about the decision alone, and is never a synonym for allow. Every field is bounded and a value outside its bound is refused rather than truncated — see Agent span attributes for the exact shapes and for the reason result and decision are two fields.

counts.a2a_calls and a step's a2a object report Agent-to-Agent protocol work: the method, and the task_id and context_id when the call carried them. Today the calls you drive against Anectico's own A2A endpoint are reported this way; your own agent-to-agent traffic appears only if your agents emit the a2a.* attributes themselves, and streamed A2A calls are not yet observed.

counts.approvals is still always 0. Nothing reported on a step expresses an approval, so that zero means "not reported", never "none happened". Every other count is measured.

A step carries a handoff object when it declared a handoff to another agent. It reports the target — the agent the work was handed to, exactly as you declared it — and an optional reason. Both are bounded: the target must be spelled as an identifier (letters, digits and . _ - : /, up to 128 bytes) and the reason must be a short lowercase code such as specialist_required, never a sentence. A value outside either bound is refused rather than truncated, and the field simply does not appear. A reason declared with no target records nothing at all: there is no handoff for it to annotate.

A handoff does not change the step's category. The span is still the agent invocation it was, so it keeps OPERATION_CATEGORY_AGENT and continues to count as one; what changes is canonical_operation, which becomes CANONICAL_OPERATION_HANDOFF.

Downstream application steps are classified. A step that is not agent work but carries stable OpenTelemetry evidence of an HTTP request, a database query, an RPC or a messaging operation — one of http.request.method, db.system.name (or the older db.system), rpc.system or messaging.system — comes back with category: OPERATION_CATEGORY_APPLICATION. This is what lets one run's graph span the boundary between the agent and the services it called, with parentage intact.

OPERATION_CATEGORY_UNSPECIFIED is a distinct answer and not a weaker version of that one: it means the step carried none of those markers and nothing else classified it either. Read it as "not classified", never as "ordinary application work" — connection details such as server.address or url.full are deliberately not enough, because they appear on model calls too.

Model steps carry requested_model and served_model separately, so a provider that serves a different build than you asked for is visible. Both are empty on runs recorded before this was captured; that means unknown, not that they matched.

Model steps also report streamed and stream_abandoned. stream_abandoned is true when your own code stopped consuming a streaming response before it ended — a break, a close, a cancelled request. That is deliberately not an error and is never counted as one, because a caller stopping early is ordinary control flow and booking it as a failure would raise alerts on code that is working. What it does tell you is that the token counts on that call are the provider's last reported values and are partial by construction. Both fields are false when nothing was reported about streaming at all, so read a false as "not stated" unless you know your instrumentation sends it.

Tool steps carry mcp_session_id and mcp_protocol_version when the step was an MCP call, so a misbehaving tool can be traced back to the session and the protocol version it ran under.

A tool step's arguments and result describe recorded tool content without disclosing it. Each is present only when something was recorded about that channel, and each carries a sha256, a byte_size, a disposition and a truncated flag — never the value itself. disposition: FULL means the content is held; disposition: ABSENT with a non-zero byte_size means it was captured by your application and not kept, which is what your project's tool_arguments content policy decides. The absence of the field entirely means the tool call recorded nothing. byte_size is the size before any truncation your SDK applied, so truncated: true tells you the held value is a prefix of a larger one rather than the whole answer.

See Memory provenance and quarantine for the concept behind the fields below — item identity, the two trust labels, and what a quarantine opens — before the exact shapes here.

Memory steps carry a memory object when your instrumentation declared which item a memory operation touched. It reports the store the operation targeted, a bounded memory_operation, an item_key, an item_provenance and a declared_trust_label.

item_key is derived by the platform from your declared item id together with your organization, project and store — the declared id itself is never stored or returned. That derivation is what makes the same item recognizable across two conversations, which is the question the field exists to answer: pass it as GET /api/v1/agent-events?memory_item_key=... to get every recorded read and write of that one item. The filter is a complete selector on its own — you do not need a run or a session beside it — and an item nobody has recorded returns an empty page rather than an error.

No memory item content is stored or returned, by this or any other endpoint. item_provenance is a bounded reference — a trace id, a trace/span pair, or a sha256: digest — and a value in any other shape is refused rather than kept, so nothing free-form reaches the field. declared_trust_label is what your own instrumentation asserted about the item, and is one of trusted, suspect or quarantined. It is reported separately from trust_label, which is the label established through review — the one you set with the endpoints below. The two are never folded together: your own assertion cannot become the reviewed judgement, or a poisoned item could declare itself clean.

trust_label is the reviewed label that was in force at the moment that row happened, not the item's label now. An item read while it was still trusted and quarantined an hour later reports trusted on that read and quarantined on everything after the decision — which is what lets you say which conversations carried an item before anybody knew it was bad. trust_label_revision names the exact judgement that applied, so you can read its reason and its author out of the label history below, and trust_label_in_force_since is when that judgement was recorded.

An empty trust_label means nobody had judged the item when that row happened, and it means nothing else: trust_label_revision is 0 in exactly that case (revisions start at 1), and if the reviewed label cannot be read at all, the whole request fails rather than returning memory rows without their labels. A memory row is never disclosed bare.

Agent memory

Reviewing a memory item

PUT  /api/v1/memory-items/{itemKey}/label
GET  /api/v1/memory-items/{itemKey}/label
GET  /api/v1/memory-item-labels?memory_item_key=...&label=quarantined

{itemKey} is the item_key above. The PUT body carries label — MEMORY_TRUST_LABEL_TRUSTED, MEMORY_TRUST_LABEL_SUSPECT or MEMORY_TRUST_LABEL_QUARANTINED — and a reason. Both are required: an omitted label is refused rather than treated as "no opinion", and a decision with no reason is not reviewable. The PUT takes agents:write; both reads take agents:read.

You do not say who is labelling. The reviewer identity and the timestamp are taken from the credential that made the request and returned as labeled_by and labeled_at; a value you send for them is not accepted. A request made with a machine credential that carries no user or key identity is refused, and so is one made on the platform's own behalf: labelling a memory item is a decision a person makes.

The history is append-only. Relabelling adds a revision rather than replacing one, so GET /api/v1/memory-item-labels?memory_item_key=... returns every judgement ever recorded about the item, newest first, each with its own revision, reason, labeled_by and labeled_at — which is what lets you ask who trusted an item before an incident. GET /api/v1/memory-items/{itemKey}/label returns the current one, the highest revision. An item nobody has labelled is a 404, never a 200 carrying an empty label: "nobody has looked at this" and "somebody looked and said nothing" are different answers.

Labelling an item quarantined opens exactly one review signal of kind poisoned_memory_item, whose subject_ref is the item key; labelling it trusted again resolves that same signal. A second quarantined refreshes the open signal rather than opening another, and suspect leaves an open signal open, because a downgrade is an unfinished review rather than an all-clear. The response says which happened, in review_signal_opened and review_signal_resolved. You may label an item before it has been recorded anywhere: containment often runs ahead of the next session.

A label records a judgement; it contains nothing by itself. Anectico does not scan memory for poisoning and does not stop your agent reading its own store — nothing here revokes a credential or blocks a call.

Where an item went, and under what label

GET /api/v1/memory-items/{itemKey}/propagation?limit=200

This is the read a poisoning question actually needs: every recorded place one item was written or read, oldest first, each carrying the label that was in force at that moment. It takes agents:read.

Each entry reports the session_key, turn_id, run_id, span_id and occurred_at of one place the item was, the canonical_operation that touched it, the store, the item_provenance and your own declared_trust_label — plus label_state, label_in_force, label_revision, label_id and label_in_force_since. distinct_session_count is how many separate conversations the item reached: one means it never left its own, and two or more is the cross-session spread the endpoint exists to make visible.

label_in_force is historical, not current. It is the judgement that had been recorded when that entry happened — so an item read while it was still trusted and quarantined an hour later reports trusted on that entry, and quarantined on anything after the decision. That is the point: it lets you say which conversations carried an item before anybody knew it was bad. The item's label now rides once on the response, as current_label and current_label_state.

label_state is MEMORY_LABEL_STATE_LABELED or MEMORY_LABEL_STATE_UNLABELED, and the second one is a real answer rather than a blank: nobody had judged the item at that point. An entry never carries an empty label word instead.

An item that was never recorded anywhere and was never labelled is a 404. An item you labelled before it was ever observed returns the label with no entries — containment often runs ahead of the next session. If more entries exist than limit returned, truncated is true; a partly-read flow must not be mistaken for the whole of it. limit defaults to 200 and caps at 500.

If the label cannot be read, the whole request fails rather than returning entries without their labels: an item disclosed without its quarantine label is exactly the answer this endpoint must never give.

The memory object is absent whenever your instrumentation declared no item, which is the common case: the OpenTelemetry conventions standardise the memory operations and define nothing about item identity, so most frameworks emit the operation alone. Read that absence as the item is not instrumented — the step still reports the memory operation and it still counts in counts.memory_ops. It never means no memory was read. Declaring anectico.memory.item.id, gen_ai.data_source.id, anectico.memory.item.provenance and anectico.memory.trust_label on your memory spans is what fills it.

A step's outcome is filled only when the telemetry stated one for that response. completed, failed and cancelled become OUTCOME_OK, OUTCOME_ERROR and OUTCOME_CANCELLED. A response reported as incomplete, queued or in_progress leaves outcome unset and carries the stated value in that step's attributes, under gen_ai.response.status: none of the three means success or failure, and labelling them either way would be a guess. A step whose telemetry stated no status leaves outcome unset too, and a step's span status is never used to fill it — "something in this span errored" is not the same claim as "this response failed". Step outcomes belong to the run graph; /agent-events does not report an event-level outcome.

/agent-events returns step-level evidence — tool calls, retrievals, safety checks — anchored on a run, a session, an agent identity plus a bounded window, or a window no wider than 24 hours. A request with no anchor is rejected rather than silently scanning. Ordering is newest first and paging is cursor-based. Its category and canonical_operation filters, and /action-receipts' authority and final_state, follow the same rule as /assets: repeatable, OR-within and AND-across, and an unrecognized value is rejected rather than producing a page that is quietly the wrong one.

Context events use category=context and carry exactly one typed context detail:

  • assembly: budgets, assembled tokens, overflow and assembly digest, plus the ordered bounded source metadata;
  • compaction: trigger/strategy, before and after counts/digests, removed source IDs and the server-derived ratio;
  • cache: provider, key digest, outcome and optional reuse, latency and cost savings.

context_source_id is a lineage narrowing filter over an assembly's ordered source IDs. It does not count as an anchor by itself: supply run_id (or another ordinary bounded event anchor) too. The raw span-event attribute map is never returned. Optional numeric members are omitted when the producer omitted them, while a measured zero is returned as zero. That rule applies independently to assembly counts, each source's tokens and position, compaction counts and ratio, cache reuse, latency and savings, and a model step's assembled-token summary. The step summary's instrumented bit remains the authoritative distinction between an uninstrumented step and one carrying an accepted context event.

/action-receipts also accepts repeated normalizer_versions, which narrows the page to receipts a given normalizer version produced — the filter to reach for when you are reproducing an earlier answer and need the population pinned. Unlike authority and final_state it is an open number rather than a closed vocabulary, so a version this deployment has never produced is a legitimate question with an empty answer rather than a 400. Omit it for every version.

outcome is not an agent-event field. The evidence an event is derived from does not carry an outcome of its own, and this API will not infer one — a safety check that denied inside an otherwise successful step must not be labelled as having succeeded. The deprecated ?outcome= selector is therefore rejected instead of returning an empty page that could be mistaken for "none happened". Filter on category or canonical_operation instead, and use the containing run's status (/agent-runs/{runId}) when you need success or failure.

category accepts only values telemetry can report. Thirteen categories are derived today: model, agent, workflow, planning, tool, mcp, retrieval, memory, safety, evaluation, context, application and a2a. Any other value is refused with 400 rather than answered with an empty page, so an empty page for a category always means "none happened". Approvals, authority changes and compensations are not categories at all: they are action receipts, returned by GET /api/v1/agent-runs/{runId}/graph under action_receipts, and served in full by /authority-edges and /action-receipts — both of which carry more than a span category could.

An action receipt is the fold of an action's lifecycle, not one row: its entries array is the complete sequence of transitions, and final_state is the state of the last one. authority bounds what the receipt claims — RECEIPT_AUTHORITY_MEDIATED means the action ran through this platform, RECEIPT_AUTHORITY_REPORTED means an agent told us it acted and the receipt attests only to what was received and when. Each entry carries its own authority as well, so a reader never has to infer which lines are whose claim; one chain holds both kinds, and an action's authority cannot change halfway through its life.

requester_principal is the principal that asked for the action — a bounded identifier such as user:..., apikey:... or a platform service name. It is taken from the first entry that carries one, because the requester is part of what the action is and is declared when the action opens; later transitions carry the field and may leave it empty, and taking the last would blank the name of the principal the action was authorized for. Each entry also carries its own requester_principal, so you can see which transition each name was declared on rather than inferring it from the fold.

If the entries name more than one requester, the receipt reports the first and lists every distinct name in conflicting_requester_principals. That list is empty in the ordinary case. It exists because nothing prevents a chain from carrying two, and showing one principal's name over another principal's transition in silence would be the more comfortable answer and the wrong one.

effect_class folds to the WIDEST reach any transition declared, on the order EFFECT_CLASS_NONE < EFFECT_CLASS_INTERNAL < EFFECT_CLASS_EXTERNAL. A chain normally opens with an INTENDED step that makes no claim about reach — nothing has happened yet — and that omission is recorded as EFFECT_CLASS_NONE, so a receipt that took the first declaration would report NONE for every action however far it later reached. A later transition can therefore raise the receipt's declared reach and can never lower it, which is the same rule destructive follows: under-reporting what an action touched is the dangerous direction. Each entry still carries the class it was written with, so you can see which transition made the claim.

Ordering: the list is by your clock, a single receipt is by ours. occurred_at is a value you send on every entry, and GET /api/v1/action-receipts orders by it, newest first — that is the question a page of receipts answers, and the paging cursor is built on it. GET /api/v1/action-receipts?chain_id=... without an action_id asks a different question, "the chain's most recent receipt", and answers it in the order this platform sequenced the entries in. The two can disagree, and when they do the single-answer read is the one that cannot be moved by a future-dated occurred_at.

approver_principal and approved_at come from the AUTHORIZED transition, and only from that one. A receipt whose chain holds no AUTHORIZED entry reports both as empty, whatever else its entries carry — that is deliberate, and it is what stops an agent's own account of itself from supplying an approver for an action nobody approved. So an action that genuinely required a second person's sign-off records that sign-off as its own transition, and you read it from the fold.

A containment is the worked example. Approving one writes two entries in one chain: AUTHORIZED, carrying the approver and the moment they signed, then QUARANTINED, carrying the outcome the enforcement reported in its detail. Both entries name the requester, so the receipt answers "who asked" and "who approved" as two different principals — which is what the sign-off rule required. The lifecycle position is still the last transition, so final_state is QUARANTINED; the approval does not become the state, it becomes the authority. The same AUTHORIZED transition is what puts the action into /authority-edges, so a contained agent is answerable to "who authorized this, for what purpose" like every other mediated action.

policy_id and policy_version name the rule the action was decided under. On a RECEIPT_AUTHORITY_MEDIATED receipt — one this platform performed or gated — they are always present, and they are drawn from a fixed vocabulary of the form <domain>:<rule> with a rule version of the form v1:

policy_id The decision it records
mcp:write_gateway_authorization The scope and confirmation check applied to a consequential write made through the MCP endpoint — the rule behind both AUTHORIZED and DENIED on those actions
agents:containment_approval The second-principal approval a containment requires before an agent's credentials are frozen or revoked
content:retention_window The stored per-class retention window, enforced by the scheduled erasure pass
content:subject_erasure An on-demand erasure of one subject's content, decided by a person rather than by a schedule

The pair is folded from the first transition that carries it, and every transition of a platform-decided action carries the same pair — so a single entry read on its own still answers "under what rule". The version moves only when what the rule decides changes, which is what makes two receipts written months apart comparable.

A platform-decided action carries one policy record for its whole lifecycle. A transition naming a different rule — or a different version of the same rule — is refused with 400 when it is appended, because the chain is append-only and a second record, once written, could never be taken back: the receipt would assert one rule while the entries underneath it named two, and every entry digest would still verify.

If the entries do name more than one, the receipt reports the first and lists every distinct record in conflicting_policies, rendered <id>@<version> in sequence order. That list is empty in the ordinary case, and it is the same promise conflicting_requester_principals makes: a disagreement you can see beats a first-wins answer you cannot.

A policy record is one value. policy_id and policy_version are set together or not at all, on every receipt including a RECEIPT_AUTHORITY_REPORTED one — a transition carrying only one of them is refused with 400, naming which field was sent alone. Two entries each carrying half a record are not a record: joining one entry's id to another's version would report a rule neither of them declared. So the receipt's policy_id/policy_version come from a single entry that named both, they are empty when no entry did, conflicting_policies lists complete records only, and each entry carrying half is named in malformed_policy_records as seq <n>: <field> without <the other field>. That list is empty in the ordinary case and can only be populated by entries written before this rule.

On a RECEIPT_AUTHORITY_REPORTED receipt the two fields are yours: send whatever identifies the policy your own system applied, in whatever spelling it uses. They are free text there, bounded only by length, and this platform neither validates nor interprets them — including the one-record rule, which binds only the receipts this platform writes. Two of your own spellings on one action are reported in conflicting_policies rather than refused. The pair rule above does bind them: free text is still one value in two fields, and sending an id with no version, or a version with no id, is refused.

Receipts written before 2026-08-28 carry no policy on the platform's own actions. The chain is append-only and the entry digest covers these fields, so those rows cannot acquire one — an empty policy_id on an older RECEIPT_AUTHORITY_MEDIATED receipt means "written before the platform recorded this", never "decided by nothing".

compensated and rolled_back are not synonyms, and the fold keeps them apart. Compensation says a second action was performed to offset the first: the original effect happened and still stands. A rollback says the first action was reversed at its source: there is nothing left to offset. The receipt reports the newest of the two in remediation, separately from final_state — an action rolled back and then quarantined has final_state: RECEIPT_STATE_QUARANTINED, and "was the effect undone" is still yes. A rollback does not retract a verification either: the effect was verified, and what changed is that it no longer stands.

Both are refused (400) on an action whose entries hold no EXECUTED, EFFECT_VERIFIED or EFFECT_UNKNOWN step. An action that was only intended, or denied, or failed has no effect that either sentence could be true of.

verification is a three-state answer, not a boolean. attempted: false means nobody checked; attempted: true, verified: false means a check ran and could not confirm the effect, and method names the checker. An unconfirmable effect is a result, not a missing field — treat an EFFECT_UNKNOWN step as "nobody can say", never as failure.

Action receipts

Recording what your own agent did

POST /api/v1/action-receipts/entries

Requires agents:write. One call records one lifecycle step — intended, requested, executed, failed, effect-verified, effect-unknown, compensated, rolled-back, and the rest — not a whole receipt; steps sharing an action_id fold into one. On the wire each step is its full name, RECEIPT_STATE_ prefixed (RECEIPT_STATE_EFFECT_VERIFIED); see the spelling note below. Send the action_id, the state, the occurred_at, and whatever the step knows: the tool, the effect class, argument and intent digests, the purpose, the delegation chain, the session and turn, the resource URIs.

Two fields are derived and rejected on input. receipt_authority follows from your credential — your entries are recorded as RECEIPT_AUTHORITY_REPORTED, and you cannot ask for RECEIPT_AUTHORITY_MEDIATED. chain_id is ours to name: you have one action chain per project, and a caller who could start a fresh chain per action would have a chain that is tamper-evident about nothing.

Some action_id names are reserved. An id beginning quarantine:, erasure: or gateway: names an action this platform performed on its own authority, and an entry of yours under one is rejected with 400. The reason is not tidiness: an action has exactly one authority for its whole life, so an entry of yours opening one of those ids first would make the platform's own record of the same action a conflict — and the containment, erasure or gateway call would have happened with nothing in the ledger saying who performed it. Every other action_id is yours to choose, including ids that merely contain those words; only the prefixes are reserved.

Those prefixes are also how you read such a receipt. A containment's receipt is GET /api/v1/action-receipts/quarantine:{quarantineId} — the quarantine id under its namespace, not the bare id, which names no action and answers 404. The colon is an ordinary character in a path segment: send it as-is or percent-encoded, both reach the same receipt.

Spell state and effect_class in full, in a request body. They are closed vocabularies and the body takes the full name — "state": "RECEIPT_STATE_EXECUTED", "effect_class": "EFFECT_CLASS_EXTERNAL" — which is also exactly how every response renders them, so a value you read back can be sent again unchanged. The query filters on GET /api/v1/action-receipts take the short lowercase form instead (?final_state=effect_verified), because a filter is a URL and a body is a document. That asymmetry is the one thing about these fields worth remembering: "effect_class": "external" in a body is not the external class, it is a value this API does not define.

A value outside either vocabulary is rejected with 400, and the error names the field and lists what it will accept. It is never quietly recorded as something else. That matters more here than on an ordinary endpoint: a receipt is an append-only record of what an action did, effect_class is where a reader establishes whether the action reached anything outside your systems, and a spelling we answered with a guess would put a confident, unfalsifiable "no external effect" on a row that might have been a destructive external write. Until 2026-08-21 an unrecognized effect_class did exactly that, and receipts written through this endpoint before then cannot be distinguished afterwards from ones that meant EFFECT_CLASS_NONE — see below.

Omitting effect_class is not the same as sending an unusable one. Leave the field out and you are making no claim about the effect; the entry records EFFECT_CLASS_NONE, which is the documented default, and that is a deliberate answer to a deliberate silence. Send a spelling we cannot read and the call fails, because there is no honest default for a claim nobody can interpret. state has no default at all — it is required, and an omitted one is rejected.

Say EFFECT_UNKNOWN when you could not confirm the effect, and use EFFECT_VERIFIED only with an effect_verify_method naming how you checked — a verification that names no verifier is rejected, because nobody reading it later could judge it. effect_verify_method and detail_code are short lowercase identifiers (^[a-z][a-z0-9_]{0,63}$), not sentences: send provider_readback, never a provider's error text. Both are grouped over in the product, and a free string there is unbounded cardinality plus whatever the provider decided to echo back at you.

Send digests, never content: arguments_sha256 and intent_sha256 are hashes, and a receipt is audit evidence retained for 400 days. Never put prompt text, tool arguments or retrieved documents in one.

Events carry a content digest and size, never the content itself. Prompts, completions and tool payloads are a separate permission.

Deployments and configuration provenance

What did you deploy, and what configuration is it running?

POST /api/v1/deployments
POST /api/v1/config-snapshots
POST /api/v1/abom-manifests
GET  /api/v1/config-snapshots/{configHash}

The three POSTs require agents:write, which is owner/admin-only to mint; the GET is an ordinary agents:read. This is the registration path a release pipeline uses, and it exists because the information is not in your telemetry: every span, run, turn and receipt carries an effective_config_hash, and nothing carries the configuration that hash stands for. Only the system that resolved it can say. Register both and drift detection has something to compare; register neither and it silently has nothing to say about any deployment.

POST /api/v1/deployments declares where one asset version is running. Send the asset, the environment, your own external_ref for the slot (a release id, a workload name) and who deployed it. The platform assigns the deployment_id and returns it — a supplied one is rejected, because a chosen id could point a configuration or a manifest at a record you do not own — and created tells you whether this was a new slot or a re-registration of an existing one. Re-registering the same (environment, asset, external_ref) updates that slot rather than creating a second. Set retired_at when the deployment goes away; a retired deployment stops being watched for drift.

POST /api/v1/config-snapshots declares one effective configuration: the effective_config_hash your telemetry already stamps, plus what it stands for — enabled tools, enabled MCP servers, flag variants, and a small resolved map of metadata — and the deployment and ABOM manifest it belongs to. A hash is immutable. Re-registering the same hash with a different composition is refused with 409, because that hash is stamped on rows already written and one hash naming two configurations would make all of them ambiguous. A changed configuration has a new hash. Re-registering the same hash with the same composition is a repeat sighting and succeeds. Fields are identifiers and are bounded: an oversized value, or one carrying a newline, is rejected rather than truncated — this is metadata storage, not a place to send prose or prompt text.

POST /api/v1/abom-manifests declares the composition of one asset version — its tools, models, prompts, MCP servers and skills, each as an identifier, a version and a content hash. The platform canonicalizes exactly what you sent, signs it, stores it immutably and returns the signed artifact: the canonical bytes, their SHA-256, the signature and the id of the key that made it. The manifest id is assigned here and is what a configuration references.

Send release too, and send the same string your agents put on their spans as gen_ai.agent.version. It is the label this bill of materials is for, it goes inside the signed bytes, and it is what lets anyone holding an exported manifest check that it is the composition for the release that actually ran — rather than for some other release of the same agent. Two releases of one unchanged composition are then two artifacts with two signatures, which is the point: without it, one signed document would serve every release and a label beside the signature could be retyped by anyone.

release is optional and explicitly so. Omit it and the signed bytes say "release": null — "this producer named no release" — which is a different statement from "release": "", and the two canonicalize differently on purpose. If you are not tagging releases yet, omit the field; do not send an empty string to mean the same thing.

Two things about this endpoint are worth stating plainly, because a signature invites a stronger reading than it earns. What you send is what is signed, so canonical_form, manifest_sha256, signature, signing_key_id and authority are all rejected on input rather than ignored. And a manifest you declare is recorded with authority: "declared" — inside the signed bytes, not beside them. The signature then proves what you declared, under your organization, at that instant, and that it has not changed since. It does not assert that the components are accurate; nobody but you can know that. authority: "mediated" is reserved for a composition the platform itself supplied, so a verifier reading only the exported artifact can always tell the two apart. Only components, versions and hashes belong here — a manifest is exportable, so never put prompt text, tool arguments or retrieved documents in one.

GET /api/v1/abom-manifests/{manifestId} returns one stored manifest and a verdict on it: valid says whether the stored artifact still matches the byte contract this platform currently produces, and defect names the reason when it does not (canonical_form_superseded, canonical_form_unreadable, digest_mismatch, column_body_mismatch). The manifest is returned either way — it is your artifact and the only copy of it you can reach here — and valid: false is never an error status.

canonical_form_superseded means the manifest was signed under an earlier version of the byte contract: its signature is still a true statement about the bytes it covers, and those bytes are no longer a body this platform would produce for that manifest. Sign a fresh manifest for the asset version and reference that one going forward. This is what you will see for anything signed before release joined the signed body.

canonical_form_unreadable and digest_mismatch both mean the stored artifact is damaged rather than merely old: the signed bytes cannot be parsed, or the digest recorded beside them is not the digest of those bytes. Neither is expected, and a manifest reporting one should be signed again.

column_body_mismatch is about the record beside the artifact rather than the artifact itself. So that manifests can be filtered and listed, authority and release are also held in a searchable form outside the signed bytes; this defect says the two disagree. The values reported to you are always the ones inside the signature — the signed bytes are the artifact, and nothing outside them can change what a manifest declares — so the manifest you read is still the one that was signed, and its signature still verifies. The defect is telling you that the searchable copy has drifted and that filters over it may have missed this manifest. Report it to us; nothing on your side produces it.

GET /api/v1/config-snapshots/{configHash} resolves a hash back to what it stands for, and returns every deployment that has ever run it. It is the one endpoint in this section that honors ?environment=, and it must: the same configuration registered in staging and in production is two records, so a lookup that ignored the environment would answer about one with the other. An unregistered hash answers 404.

Each entry in deployments[] carries the validity interval of that binding: first_seen_at is when the deployment started running this configuration, and superseded_at is when it moved to another one. An absent superseded_at means the binding is current. Resolving an action to the bill of materials that was actually in force when it ran means picking the entry whose interval contains the action's own timestamp — not the newest entry, and not the current one. A deployment that was upgraded and later moved back to this configuration appears once per period it ran it.

This is what makes the hash on an old trace, run, turn or receipt still answerable. Upgrading a deployment to a new configuration does not erase what the previous one referenced, so deployments[] for a configuration nothing runs any more is a list of closed intervals rather than an empty array.

Verification and tamper evidence

Has the record been tampered with?

GET  /api/v1/receipt-chains/{chainId}/verify
POST /api/v1/receipt-chains/{chainId}/checkpoints
GET  /api/v1/abom-manifests/{manifestId}

The verify response splits entry_count into mediated_entry_count and reported_entry_count, which sum to it. "The chain verifies" is a statement about integrity and says nothing about who vouches for what is in it.

POST .../checkpoints is an ordinary agents:read and returns a signed assertion that at this instant the chain held exactly n entries ending in a given digest. Keep it. A hash chain is tamper-evident against modification and reordering and silent about deletion of its tail; a checkpoint you retained is what closes that, because it is not in our database and the key's private half never leaves the platform. A chain that does not currently verify is not checkpointed — signing a state already known to be faulty would make it look attested.

See Verify attestations for what these prove, what they do not, and how to check a signature yourself. The short version for a manifest: the signature proves that these exact bytes were declared, by whom, and when — and the authority inside those bytes is what tells a reader whether the composition came from you or from the platform. It is never a claim that the composition is correct.

Credentials and their grant history

Who holds a key, and who issued it?

GET  /api/v1/account/api-keys
POST /api/v1/account/api-keys
PATCH /api/v1/account/api-keys/{id}
DELETE /api/v1/account/api-keys/{id}

Creating a key without project_id binds it to the organization's default project (or the first available project when none is named default). If that project cannot be resolved, creation is refused; omission never creates an organization-wide key. Set org_wide: true explicitly to create an organization-wide key. A project-scoped key cannot set org_wide, select a sibling project, or omit its way around its signed project boundary.

Every key in the listing names the principal that minted it. user_id is the person who issued it; created_by_api_key_id is the key that issued it, when a key issued a key. created_by_state says which of those applies, so an empty user_id is never ambiguous:

created_by_state What it means
user user_id names the person who issued the key
api_key created_by_api_key_id names the key that issued it
system The platform issued it; there is no attributable principal
orphaned_user A person issued it and their record is no longer available, so the attribution was lost. The key is still valid
unknown The key predates this field

What was this key ever allowed to do?

GET /api/v1/account/audit-events?target_api_key_id={id}

target_api_key_id returns one key's whole grant history — issued, scopes changed, revoked — newest first, and never events belonging to any other key. It is matched against the key that was changed, never the key that did the changing, so a key that administers other keys does not collect their history.

Each event carries the scope set in metadata:

  • scopes — the set that stands after the act, as a comma-separated list. On an issue it is what was granted; on a revocation it is what the credential could do at the moment it was revoked, which is readable nowhere else afterwards. The whole set is returned however large it is, so a key holding everything a role grants reads back in full. An empty value means the act granted nothing, which is different from the field being absent.
  • previous_scopes — the set a change replaced. It is present on a scope change and on a freeze, where it is the authority the containment removed while scopes is what the key is left with; a rename replaces nothing and carries neither.

event_type filters the taxonomy — api_key_created, api_key_updated, api_key_revoked, membership_role_changed — and composes with target_api_key_id, so "every scope change to this key" is one request. resource=api_key selects credential events including their API mutations.

Revocations always name their actor. A revocation that follows a project or organization deletion records the platform as the actor and names the deletion that caused it under metadata.via.

Request and response conventions

  • Send JSON writes with Content-Type: application/json unless an upload endpoint requires a raw symbol artifact.
  • JSON field names are snake_case.
  • Times are RFC 3339; common windows use start_time and end_time. Omitting an optional timestamp selects the endpoint's documented default, while a non-empty malformed timestamp returns 400 invalid_request instead of silently selecting that default.
  • GET /persons/{person_id}/timeline accepts start_time and end_time to bound the merged customer story. Keep those bounds unchanged while following its opaque pagination cursor.
  • Repeated filters may accept repeated parameters or comma-separated values, depending on the resource.
  • Paginated reads commonly accept limit and an opaque cursor. Return the next cursor unchanged; do not decode or construct it.
  • Log, trace, Errors, and alert-rule paginated reads—including affected-customer results—reject a malformed non-empty pagination cursor with 400 InvalidArgument; they never treat it as a request for the first page.
  • Server limits clamp page sizes. Do not assume one maximum across resources.
  • A paginated read reports has_more together with a next_cursor, and sets neither once the page is the last one. Treat a full page with has_more false as the complete result, and keep following next_cursor while has_more is true rather than assuming one page is everything.
  • total_count is a hint, and has_more/next_cursor are the contract. Page on those. A count is measured a moment apart from the page it accompanies, so on data still being written it can lag; it will never be smaller than the results already handed to you, but treating it as the loop condition can end a read early. Where an endpoint does not compute a count at all it reports 0, which means "unknown", not "empty".
  • Those two properties hold on every paginated read, not just the busiest ones. total_count is never below the number of results in the same response — including the ones an earlier page already skipped — and has_more is decided by the result set itself rather than by the count, so a lagging count can no longer end your loop early. If you have been treating total_count as the loop condition, switch to has_more: it is the only one of the two that is exact.

The following collection routes all accept limit and opaque cursor. Their default page size is 100 and their maximum is 500. Preserve next_cursor exactly and continue until has_more is false. API keys and alert silences expose those values inside pagination; the other routes expose top-level next_cursor, has_more, and returned_count alongside the named item array.

Route Item array
GET /services services
GET /alerts/history alerts
GET /alerts/silences silences
GET /releases releases
GET /errors/groups/{group_id}/releases breakdown
GET /errors/groups/{group_id}/merged-sources sources
GET /incidents incidents
GET /evaluation/release-series releases
GET /evaluation/datasets/{datasetId}/versions/{version}/items items
GET /account/api-keys api_keys
GET /oncall/schedules schedules
GET /oncall/schedules/{id}/overrides overrides
GET /users/me/contact-methods contact_methods
GET /escalation-policies policies
GET /flags flags
GET /flags/local-evaluation flags
GET /group-types group_types
GET /llm/pricing overrides
GET /dashboards dashboards
GET /connections connections
GET /connections/{id}/identity-mappings mappings
GET /issues/{group_id}/tickets tickets
GET /notifications/channels channels
GET /notifications/templates templates
GET /evaluation/evaluators evaluators
GET /evaluation/sampling-rules rules
GET /evaluation/datasets datasets
GET /evaluation/experiments experiments
GET /evaluation/gates gates

PATCH bodies are partial. On these control-plane routes, an omitted mutable field keeps its stored value; it is not replaced by that field's empty or false default:

PATCH /api/v1/alerts/rules/{id}
PATCH /api/v1/alerts/silences/{id}
PATCH /api/v1/account-segments/{id}
PATCH /api/v1/notifications/channels/{id}
PATCH /api/v1/notifications/templates/{id}

This distinction applies to scalar, object, map, and list fields. For example, a channel body with only config keeps its name and is_enabled state; "is_enabled": false explicitly disables it. A template rename keeps its subject and template. A rule severity edit keeps its pause state, labels, notification channels, template, and escalation policy. A segment rename keeps its group_type and filters, and extending a silence window keeps its comment. An explicitly empty value is still an edit when that value is valid: for example, "notification_channels": [] detaches every channel, while "filters": [] deliberately changes an account segment to match every account of its group type. Omit a field when you intend to preserve it.

Rule PATCHes must include the current version; a stale value is rejected rather than overwriting a newer edit. These PATCH routes reject unknown JSON fields with 400 invalid_request, so a misspelled safety-sensitive field cannot look as if it was applied.

GET /api/v1/invitations?status= accepts the exact invitation enum names returned by the API: INVITATION_STATUS_PENDING, INVITATION_STATUS_ACCEPTED, INVITATION_STATUS_REJECTED, or INVITATION_STATUS_EXPIRED. An unknown non-empty spelling returns 400 invalid_request; it never widens into the unfiltered invitation list.

Alerting and notification behavior

A forced evaluation can report that it measured nothing

POST /api/v1/alerts/rules/{id}/evaluate

An aggregate over a window that matched no rows is not a value. max, min, avg and the percentiles have no answer over an empty window, and the number that comes back for them is a placeholder rather than a measurement — so the result carries "unmeasured": true, an unmeasured_reason, "rows_scanned": 0, and "observed": null on each condition. Nothing fires on such a tick, and nothing resolves either: an alert the rule already had stays open and picks up an unmeasured_since timestamp naming when its evidence went away, because "no data" is not "the problem cleared".

A third case exists and is distinct from both: if the window held rows but the aggregated column was NULL in every one of them, you get "unmeasured": false with "observed": null — the platform looked, and the reducer had no answer. Read the two fields as a pair.

Read observed, not value. value is 0 both when nothing was measured and when the measurement really was zero; only observed: null tells the two apart.

count and sum are not affected — the count of an empty window is zero and the sum of an empty window is zero, and both are answers. Use a count-based comparison, or an absence condition, when you want a rule that pages because a window is empty.

An error budget is readable without being paged

GET /api/v1/slo/{rule_id}/burn

An SLO rule states a quality objective. This answers the two questions an alert on it cannot: how much of the error budget is left, and how fast is it going. The budget is (1 - target) times the evaluations in the compliance window, reported as a count of events — "18 of 30 failing runs remain" is actionable in a way that a percentage is not. The burn rate of a window is that window's own failure ratio divided by the ratio the objective allows — (failing / total) / (1 - target) — so a rate of 1 spends the budget exactly over the compliance period and 2 spends it in half. It does not depend on how many evaluations happened to land in the window, so a quiet hour and a busy one with the same failure ratio report the same burn.

Everything defaults from the rule's own burn policy. ?window=6h&window=30m, ?target=, ?compliance_window= and ?factor= override it when you want to ask a different question of the same objective.

A window holding no evaluations comes back with status: "SLO_BURN_STATUS_NOT_MEASURED" and an unmeasured_reason, never a burn_rate of 0. Those mean opposite things: zero says the objective is healthy, unmeasured says nothing was evaluated. would_fire is the same decision the rule itself makes — both the long and the short window over factor — and is false with a reason whenever that decision could not be made at all.

Alert acknowledgement is reversible

POST /api/v1/alerts/{id}/acknowledge
POST /api/v1/alerts/{id}/unacknowledge
GET  /api/v1/alerts/acknowledgments?alert_id=...
GET  /api/v1/alerts/history?rule_id=...

Acknowledging an alert suppresses its escalation, so the ability to withdraw one matters: a mis-click no longer permanently silences a real alert. Acknowledgements are a sequence, not a flag — the same alert can be acknowledged and withdrawn more than once, and /alerts/acknowledgments returns each one with its actor, time and note. The current state is the single boolean on the alert; the history is what a post-incident review needs.

/alerts/history is anchored by rule_id or alert_id. Anchor on the rule to answer "how often has this fired", which is the question that usually matters.

Silences can be read and edited, not only created and deleted

GET   /api/v1/alerts/silences/{id}
PATCH /api/v1/alerts/silences/{id}

Editing a silence keeps its identity and its audit trail. Deleting and recreating one to change its window does not, so prefer the PATCH. The silence's author is preserved across edits — editing does not transfer authorship. Send only the fields being changed; omitted matchers, window endpoints, name, and comment remain unchanged.

Notification channels can be edited without restating their alerting state

GET    /api/v1/notifications/channels
POST   /api/v1/notifications/channels
GET    /api/v1/notifications/channels/{id}
PATCH  /api/v1/notifications/channels/{id}
DELETE /api/v1/notifications/channels/{id}
POST   /api/v1/notifications/channels/{id}/test

The PATCH is partial. Updating only a webhook URL or other config value leaves the channel's name and enabled state unchanged. Send is_enabled: false only when the channel should stop delivering.

Notification templates decide what a channel says

GET    /api/v1/notifications/templates
GET    /api/v1/notifications/templates?channel_type=slack
GET    /api/v1/notifications/templates/effective?channel_type=slack
POST   /api/v1/notifications/templates
GET    /api/v1/notifications/templates/{id}
PATCH  /api/v1/notifications/templates/{id}
DELETE /api/v1/notifications/templates/{id}

/effective answers a different question from /{id}: it is the template that would be used for a channel type right now, which is what you want when checking what a Slack notification will actually look like. Asking for a template by id tells you about that template, which is not necessarily the one that would be sent.

Template PATCHes are partial too. A body such as {"name":"Primary page"} changes only the name; omitted subject and template fields retain their stored content.

  • Use IDs returned by the API for updates and deletes; do not derive them from display names.
  • A write normally answers 200: the change is committed and a read issued straight afterwards already reflects it. A write whose effect is served from a list that is rebuilt just behind it can answer 202 instead. 202 is a success and the change is committed and permanent — what it adds is that the corresponding read has not been confirmed to agree yet, so a read issued immediately afterwards may briefly still show the previous value. Re-read to see the settled state; do not repeat the write. DELETE /account/members/{user_id} is the endpoint that answers this way today, and repeating it returns 404 because the member is already gone.

Errors and retries

Errors use a JSON envelope:

{
  "error": "PermissionDenied",
  "message": "permission denied"
}

The error string comes from one of three families, and which one you get says where the refusal was decided: most codes are a downstream gRPC status name forwarded verbatim in PascalCase (PermissionDenied for ordinary insufficient-scope, NotFound, InvalidArgument, and so on); any 5xx always answers with a fixed, redacted lowercase code (internal_error or unavailable) regardless of the underlying fault; and a handful of refusals Edge decides itself before calling a downstream service — a malformed request body, a project-scope conflict (lowercase permission_denied), or an API-key project-floor refusal (forbidden) — use their own lowercase snake_case code. Match on the exact string; casing alone is not a reliable signal.

Treat 400 as invalid input, 401 as a missing or invalid credential, 403 as insufficient scope, 404 as missing or inaccessible, 409 as a state conflict, and 429 as a rate or plan limit. Retry 429 and temporary 5xx responses only with bounded exponential backoff, honoring Retry-After when present. Do not automatically retry validation or permission failures.

A 409 never becomes a success on its own. It covers two situations, and neither is fixed by sending the same request again: something you named is already taken — a connection for that provider, an escalation policy or on-call schedule with that name in that project, a contact method with that address for that user — or the version you supplied is stale. Change the request (a different name, or a re-read version) or change the state (retire what is holding the name), then send it once. An agent that retries a 409 unchanged will loop until it gives up.

Some 400 responses are not about your request. A capability whose backing store is not present in your deployment answers 400 with a message naming the capability — "error reads are not available in this deployment", "session replay is not available in this deployment". Sending a different request will not change the answer; the capability is absent for everyone on that deployment until an administrator enables it. Distinguish it from a validation failure by the message: a validation failure names a field of your request, this names a product capability.

No error message names the software behind Anectico. Message text describes your request or a product capability, never a database, queue, cache, identity provider or internal component. If you are matching on message text, match on the stable leading phrase, and prefer error and the HTTP status class over free text in every case.

Every 2xx is a success, 202 included — branch on the status class, not on equality with 200, or a committed write will read as a failure. See Request and response conventions for what 202 adds.

When a request body will not parse

A body that cannot be read into the endpoint's shape returns 400 invalid_request with a diagnostic in parentheses, so a client — or an agent acting on its own — can tell a malformed request from a server fault and correct it without a second round trip:

{
  "error": "invalid_request",
  "message": "request body is not valid JSON for this endpoint (line 1, column 24; invalid_value; type google.protobuf.Timestamp)"
}

Read it as follows.

  • The text before the opening parenthesis is stable. Match on that, or on error, rather than on the parenthetical.
  • line and column locate the failure in the body you sent, counting from 1.
  • The middle term names the kind of failure: syntax (the bytes are not JSON), truncated (the body ended early), type_mismatch (a value of the wrong JSON type), invalid_value (the right type, but not a value the field accepts — a timestamp that is not RFC 3339, for example), duplicate_field, unknown_field, or unclassified.
  • field and type, when present, name the field that rejected the value and the type it expects. Either may be absent when the failure happened before a field could be identified.

The diagnostic never contains the value you sent. It carries only a position in your request and names drawn from the published API schema, so a malformed body carrying customer data does not come back in the error — and does not reach a log. If a value is being rejected and the reason is not obvious from the field and type, compare against the field's documented format rather than expecting the value to be echoed.

The most common cause of invalid_value is user-supplied data reaching a typed field: an email, an identifier or a token placed where a timestamp or duration belongs. Validate those fields before sending rather than relying on the rejection.

Discover exact operations

The installed CLI mirrors the public surface and can print its current command contract:

anectico docs             # JSON manifest for tools and agents
anectico docs --markdown  # readable command and flag reference

Use the current CLI manifest or dashboard network contract for exact early-access request bodies. Do not copy internal gRPC or service routes into an external integration.

Capture-attempt quality

GET /api/v1/events/capture-quality requires analytics:read. Supply project_id for an organization credential; a project credential uses its signed project and cannot select another. start_time and end_time are required RFC3339 timestamps, at millisecond precision or coarser, forming an increasing half-open event-time window from 1970 of at most 183 days. Optional environment selects one exact environment; empty selects all. Unknown or repeated parameters fail.

The response separates product and identity control positions into pending, queued, uncertain and not_attempted, with separate invalid and quota_rejected counts. These are submitted positions, including retries, not unique events or lost user actions. Valid input is attributed by coarse UTC-hour overlap, so a short window can include adjacent events in the same hour. Invalid input has no trustworthy time/environment and covers all retained attempts for this project. Attempts without a project are not assigned to it.

source_completeness is always unknown: queued does not mean consumed, zero does not prove absence of loss, and client sampling declarations do not establish actual upstream coverage, withholding, retention availability or processing progress. retained_project_requests is the number of retained requests examined, including those outside the selected window. digest is opaque current evidence that can change when any retained project attempt changes; it is not a saved result ID or authorization grant. The current whole-read ceilings are 10,000 requests, 8 MiB and 100 distinct sampling source/rate groups in the selected window; exceeding a ceiling returns HTTP 429 without a partial count. Storage failures, deleted scopes and invalid requests also fail instead of returning an apparently empty result. Deletion propagation and physical cleanup are asynchronous.

sampling_provenance is declared. Each entry in sampling_declarations contains source, rate, state (declared_sampled for rates below one, declared_unsampled for one), product publication counts and quota_rejected. sampling_unknown contains the remaining undeclared product publication and quota counts. Declarations plus unknown equal the corresponding product and quota totals; identity-control and invalid positions are excluded. Entries are ordered by source, then numeric rate. sampling_declaration_limit reports 100. These are submitted positions, not a count of unique stored events, sampled-away events or authorized source identities. The same environment and coarse hour-overlap selection applies. See declaring sampling for the reserved capture property and validation rules. The complete request evidence, including these declarations, participates in the digest; a changed retry cannot overwrite an earlier stored event.

The same operation is available through anectico analytics quality --start-time ... --end-time ... and the MCP get_capture_quality read action (also requiring mcp:read).

Windowed event catalog

GET /api/v1/events/catalog requires analytics:read and an active project. Select project_id for organization credentials; project credentials cannot select another project. Required start_time and end_time are increasing RFC3339 timestamps with millisecond precision or coarser, forming [start,end) within currently readable history and at most 90 days. A future end_time is allowed, but does not establish completeness for the future portion of the window. Optional environment is exact (empty selects all), prefix is literal and case-sensitive, limit defaults to 50 (maximum 200), and cursor continues a previous page. Unknown or repeated parameters are rejected.

The response includes the effective org_id, project_id, selection, and events ordered by name. Each event has name, converged occurrence count, first_seen and last_seen, all limited to the selected window. Properties, examples and person identifiers are not returned. next_cursor accompanies has_more; preserve all filters and page size when continuing. Cursors expire 15 minutes after the initial read without renewal. Start a fresh read after expiry.

ingested_before preserves the initial receipt cutoff; observed_from and observed_until describe this page's database read. These do not freeze the source or prove consumption. Late consumption and deletion may change later pages. platform_completeness and upstream_completeness are both unknown, even for an empty page. Results are returned with Cache-Control: no-store.

Invalid selections/cursors and expired or unavailable project history fail. A resource-budget refusal returns HTTP 429 without partial counts; narrow the window or prefix. Use the event catalog guide for MCP get_event_catalog and CLI analytics catalog usage.

Typed event property schema

GET /api/v1/events/schema requires analytics:read. Provide project_id, exact event, inclusive start_time, exclusive end_time, and optional exact environment. Project credentials cannot select a different project. Windows use millisecond RFC3339 timestamps and span at most 90 days within current project history.

The version-1 JSON response echoes the selection and returns observed_events, properties (key, present/missing, nonzero JSON type counts, finite_numbers, window-local first_seen/last_seen), visibility: "permitted_stored_properties", receipt/read clocks and both completeness fields set to unknown. There are no raw values/examples or hidden-key lists. Content-derived summaries require the requesting content scope and current project policy/class retention. Empty schemas and omitted fields do not prove absence. Results use Cache-Control: no-store.

There is no pagination or successful truncation. Current limits are 250 distinct keys of up to 512 UTF-8 bytes, 250,000 events, 8 MiB source strings and a ten-second service deadline, plus database limits. Exact environment strings allow up to 32,768 UTF-8 bytes and are not trimmed. A budget error returns 429; a narrower window can reduce rows/bytes/property diversity, but cannot shrink one event's property bag. Temporary source unavailability returns 503 with Retry-After: 1. Unavailable current policy/history or a mid-read policy change returns 400 (FailedPrecondition); no evidence is substituted with zero. See event property schemas for field semantics and the equivalent get_event_schema MCP action and anectico analytics schema command.

Tracking-plan configuration reads

GET /api/v1/analytics/tracking-plans lists live metadata; GET /api/v1/analytics/tracking-plans/{plan_id} reads a current or retained definition. Both require tracking_plans:read and exact project scope. Lists accept limit (default 50, maximum 100) and after_plan_id; detail accepts revision (0=current). These are non-cacheable configuration reads, not telemetry queries. See Manage tracking plans.

GET /api/v1/analytics/insights lists/searches saved-insight metadata with project_id, optional search, limit (0 defaults to 50; maximum 100) and after_insight_id. GET /api/v1/analytics/insights/{id} returns current or exact retained configuration with project_id and optional nonnegative revision (0 selects current). Both require insights:read and recheck current credentials before disclosure. List returns insights, has_more and next_after_insight_id; get returns insight. Responses use protobuf JSON with integer revisions encoded as strings. See Manage saved insights.

POST /api/v1/analytics/insights/preview and /api/v1/analytics/insights/apply provide the human dashboard write path. They require a current user session and insights:read plus insights:write (create/update) or insights:delete (retire). API keys and delegated OAuth must use the confirmed MCP actions. No query parameters are accepted; bodies are strict ApplySavedInsightRequest protobuf JSON: project_id, insight_id, mutation_key, exact decimal-string expected_revision, title, description, definition, and delete. Create uses revision "0"; update/delete require the selected positive revision. Retirement omits metadata and definition. Organization comes from the verified principal. Responses are non-cacheable.

Preview returns the validated mutation, before or prior committed receipt, and definition_hash; it changes nothing. Apply repeats validation and returns insight plus applied (false for an exact durable retry). Preserve one mutation key and body through retries. CAS conflicts and unavailable references are refused. The verified receipt supplies the semantic operation, exact project/target/revision and applied flag for the normal account audit path; audit delivery remains separate from the Analytics commit. POST /api/v1/analytics/tracking-plans/preview and /api/v1/analytics/tracking-plans/apply require a current human session, tracking_plans:read and the operation's write/delete scope. Agents use confirmed MCP actions, also available through CLI. Strict protobuf JSON contains project_id and mutation (plan_id, mutation_key, decimal-string expected_revision, name, definition, delete). Preview changes nothing; apply returns plan, mutation_key and applied. Retire with an exact positive revision and no name/definition. Sensitive protections persist. Unknown/repeated fields and query parameters are refused. All tracking read/write responses use exact decimal-string revisions. See Manage tracking plans.

Analytics history refusals use HTTP 400 with error: "FailedPrecondition" and details: {"reason": "ANALYTICS_HISTORY_UNAVAILABLE"}. This applies to query, result, participant and contribution reads. Choose a supported recent window and audience source selection and explicitly query with a new execution key; repeating an expired frozen execution cannot recover history. Other precondition failures do not imply this reason. See analytics history recovery.

A measurement kind this release cannot run uses the same HTTP 400 and error: "FailedPrecondition" with details: {"reason": "ANALYTICS_KIND_UNAVAILABLE"}. The definition is valid, so editing it and resubmitting cannot succeed, and no result_id is issued to poll. Trends, person funnels and exact-period retention all run in this release.

A project holding stored events that were never accepted through capture uses the same HTTP 400 and error: "FailedPrecondition" with details: {"reason": "ANALYTICS_SOURCE_UNADMITTED"}. Those events have no record of being received, so no measurement over them can be certified. The request is valid, so editing it, narrowing the window or retrying cannot succeed; send the events through the capture endpoint or an SDK instead of writing, backfilling or importing them into storage directly. A saved definition that reaches the background executor reports the same cause as PRODUCT_RESULT_FAILURE_REASON_SOURCE_UNADMITTED.

A deployment with no storage capacity left answers HTTP 429 with error: "ResourceExhausted" and details: {"reason": "ANALYTICS_STORAGE_EXHAUSTED"}. Branch on the reason, not the status: this 429 is not a rate limit, so Retry-After semantics and backoff do not apply and no amount of waiting clears it. The request is valid, so editing it, narrowing the window or retrying cannot succeed; contact whoever operates the deployment to make room. A saved definition that reaches the background executor reports the same cause as PRODUCT_RESULT_FAILURE_REASON_STORAGE_EXHAUSTED.

Frozen analytics audience preview

GET /api/v1/analytics/results/{result_id}/audience-preview?project_id=PROJECT_UUID&selection_id=SELECTION_UUID

Read-only, Cache-Control: no-store. Requires current analytics/person/source read permissions. Only project_id and selection_id query parameters are accepted. Returns the complete selection's exact population counts, state (eligible, unresolved, unsupported, too_large), provisional member_limit, source scopes and original result/snapshot/computation/expiry references. It returns no member IDs and creates no cohort. See audience preview.

Exact audience generations and dashboard writes

GET /api/v1/analytics/audiences/{id}?project_id=PROJECT_UUID&generation=1 requires an exact positive decimal generation and current analytics, person and source read permissions. It returns audience with its immutable source references, count, lifetime and generation; it does not return member IDs. Missing, expired, pruned or inaccessible generations are refused.

POST /api/v1/analytics/audiences/preview and /api/v1/analytics/audiences/apply require a current human user session and the same lifecycle permissions as the confirmed MCP actions. API keys and delegated OAuth are refused with HTTP 403 and must use MCP or CLI confirmation. No query parameters are accepted. Strict protobuf JSON bodies contain project_id, UUID mutation_key, result_id, selection_id, cohort_id, decimal-string expected_generation, name, description and integer lifetime_seconds (1–2,592,000). Create uses empty cohort_id, generation "0" and a unique project name. Replacement uses the exact positive generation and empty metadata. Organization comes from the verified principal, never a different tenant.

Preview returns the validated mutation, full member_count, source_scopes, and before or committed when applicable. It reserves nothing. Apply repeats validation and returns audience and applied (false for an exact durable retry). Preserve identical arguments and the original mutation key after an uncertain response. The full selection must contain at most 10,000 resolved people; unresolved or account populations are refused. All these responses use Cache-Control: no-store. See save and reuse an audience.

Explicit analytics remeasurement

POST /api/v1/analytics/remeasurement/source prepares an ad-hoc query template from an exact original result, explicit population_mode (fresh_population or saved_audience), and absolute next_window (start, end). Saved mode requires audience with exact id and decimal-string generation. Optional next_comparison replaces the comparison; omission clears it. Execute definition_template with the ordinary analytics query endpoint and a new execution key. POST /api/v1/analytics/remeasurement/compare accepts the identical source request under source and the exact later_result_id. Both endpoints are read-only, accept no query parameters and require current analytics/person/source permissions. Query execution separately requires analytics:query. Comparison retains separate original/later evidence and refuses a changed question. See remeasurement for retry, saved-generation, expiry and unavailable-original semantics.

Tracking-plan property drift

GET /api/v1/analytics/tracking-plans/{plan_id}/drift compares an explicitly pinned positive revision with permitted stored observations for exact event, start_time, end_time, optional environment and selected project_id. Requires tracking_plans:read and analytics:read; content policy still governs disclosed property rows. The response contains exact source coordinates, the permitted schema observation and per-property comparison rows. Unavailable evidence has null counts. Reads are non-cacheable, repeated/unknown parameters are rejected, and current authority/retained declaration are rechecked before disclosure. See tracking-plan drift semantics.