Every connection, and which way it points.
Token Observe holds no credential into your security stack.
On this page
Which way each connection points.
For every evidence feed — every SIEM, proxy and log platform on this page — Token Observe holds no credential into your security stack: the connection points inwards, which is both the shorter security review and the smaller blast radius; the worst a compromised deployment can do to your SIEM is stop receiving from it. Two vendor connectors are the exception, they pull, and they are named as such below rather than covered by that sentence.
That is a design decision with a cost, and the cost is that somebody has to configure an outbound feed in a console they already administer. Token Observe could have polled the finance system, the flow logs and the vendor identity console, and would then hold read access to all three — which makes it a more attractive target than the estate it protects. So for every source that has a push route your exporter pushes, on a scoped, revocable credential that can submit exactly the streams named on it and cannot read a finding, call a model or reach a tool.
The exception is real and is two rows wide. Two vendor pull connectors ship — GitHub Copilot seat spend and Cisco Umbrella DNS and proxy activity — and each does hold a read credential you supplied into somebody else’s console, because there is no push route out of either product for what they collect. Both are off until you set ACP_CONNECTORS_ENABLED, both are scoped as narrowly as the vendor allows, and neither has met a live tenant. Where a claim on this page says no credential is held, it is that pair the claim is excluding.
The outbound list is deliberately closed and worth reading as a whole, because it is the complete set of places this process opens a connection to, and the product’s own data-flow document lists it by call site: the model providers you enable, the Model Context Protocol servers you register, the webhook receivers you configure, an optional audit-anchor sink, the OpenID Connect issuer you configure for sign-in — discovery, JWKS and the token exchange are outbound calls to it — the model price catalogue at openrouter.ai, and the two vendor pull connectors that are off until you switch them on. Every one but the catalogue takes its URL from configuration you supplied. The product vendor is not on that list and receives no telemetry, phone-home data, prompts, keys or trace database; governed payloads leave your network only for the providers you configured, after policy and redaction.
Every outbound row that names a credential is bounded by two allowlists — which environment variables it may read, and which hosts it may send to — across four namespaces that are disjoint by test rather than by convention: provider, MCP server, webhook and vendor-admin. No integration can name another’s secret, and none can name the session secret, either audit key, the retired audit-key ring or the effect key; that list is the fixture of the disjointness test rather than a description of intent. The reason is stated plainly in the deployment guide: without those limits a registry write is not an integration but a read primitive over the whole process environment with an operator-chosen sink, and it matters precisely because this design concentrates every provider key in the estate into one process.
The weakest of the four pairs is named as such rather than buried. For a tool server the named variable’s value is sent raw as an Authorization header, the row is writable at operator rank rather than admin, and the outbound call is triggered by any viewer listing that server’s tools. The host list defaults to loopback only, because the shipped demo upstream runs there — set it before go-live, since somewhere on your network and somewhere on the internet are the same string to this process.
- Inbound
- It connects to Token Observe
- Outbound
- Token Observe connects to it, using a credential you supplied
- Push
- It sends to Token Observe; Token Observe holds nothing on it
- Pull
- Token Observe fetches from it, using a credential you supplied
Model providers
Six provider kinds are first-class — openai, anthropic, google, azure, bedrock and openrouter — and each is a row in a registry rather than a line in a configuration file, carrying its own base URL, the name of the environment variable holding its credential, a priority, a timeout and three independent data-policy assertions — zero retention, exclusion from training, and a serving region — which are operator declarations about a contract, recorded and audited here but not verified against the agreement they describe. A seventh kind, mock, fabricates output for the offline demo and is not built at all when any enabled row has a different kind, so forgetting it cannot serve invented answers beside a live provider. Embeddings are the one surface that is narrower than the rest: POST /v1/embeddings is proxied with the same governance and metering, for openai and openrouter rows only, and Anthropic, Google, Azure and Bedrock routes fail closed before credential resolution rather than being sent a request in the wrong dialect.
The Chat Completions dialect, and the reusable core every OpenAI-compatible upstream composes on — OpenRouter, vLLM, LiteLLM and Azure-style gateways are all built from this one adapter. Two behaviours here are governance requirements rather than API conveniences: stream_options.include_usage is sent on every stream whatever the client asked for, because metering must not depend on client behaviour, and tool-call arguments that do not parse are a hard failure rather than an empty object, because policy argument matchers read those arguments and substituting an empty object would walk an unparseable call straight past a rule written to stop it.
Inbound client headers are forwarded from an allowlist of three — openai-organization, openai-project and openai-beta — because reflecting arbitrary headers at an upstream you hold credentials for is a request-smuggling primitive.
The Messages dialect, with the version header pinned at 2023-06-01 and feature gating left to anthropic-beta. max_tokens is required upstream, so a request that omits it is defaulted to 4,096 here rather than refused — that figure matches what LiteLLM injects, so an OpenAI-shaped client that never sets a cap behaves the same through this gateway as through the proxies operators are migrating from. Cache tokens are reported alongside input tokens rather than inside them, so usage is normalised into the mutually exclusive buckets the cost engine needs before it reaches the ledger.
The Generative Language API spoken natively rather than through a compatibility shim, because the differences are structural: the model is in the URL, there is no system role and no assistant role, consecutive same-role turns are rejected so turns are merged on the way out, and tool schemas are an OpenAPI subset that 400s the whole request on a keyword it does not know, so a definition authored for another vendor is stripped before egress. A refused prompt arrives as a 200 carrying a block reason and no candidates, and it is classified as a content-policy refusal so typed failover does not replay it at another vendor and launder a refusal into a success.
A candidateCount other than 1, cachedContent, opaque file references, built-in Google tools, thought state and non-text response modalities are all rejected before egress. Inline a cached turn as text instead.
Azure OpenAI, now branded Azure AI Foundry Models, speaks the OpenAI dialect, so only three deltas need their own code. The URL names a deployment rather than a model, and a deployment is an operator’s private name — two deployments of one model routinely have different quotas and regions — so the mapping is explicit and configurable, and identity is used only where no map is configured at all. Authentication is the api-key header rather than a bearer, since sending both puts the credential on the wire twice and invites the endpoint to validate it as an Entra token and fail.
The api-version is pinned rather than tracked, and deliberately not the newest: Azure changes response shapes between versions, and a gateway that silently followed latest would change its metering behaviour without a deploy. A resource needing a later contract sets one, which is a visible operator decision.
Bedrock matters out of proportion to its traffic share, and the source says why: regulated buyers already hold an AWS data-processing agreement and committed spend, and for many of them it is the only route to a frontier model procurement will sign. Requests are SigV4-signed rather than bearer-authenticated, so the secret never reaches the wire. The signing region is taken from the endpoint host wherever that host proves one, and a contradictory environment value is treated as a configuration failure rather than an override, because the alternative signs for one region while sending prompts to another. The base URL may be omitted and derived from the region, so a VPC or FIPS endpoint stays expressible by stating it.
One API fronts several model families with incompatible body shapes, and only the Anthropic-on-Bedrock shape is spoken; any other family is refused with a named reason rather than posted as a body the endpoint cannot parse. The two CRC32 checksums in each event-stream message are parsed past rather than verified, on the reasoning that transport integrity is TLS’s job and a corrupted frame still fails the length and JSON checks that are enforced.
The OpenAI dialect plus three deltas, and the one that matters to governance is cost. OpenRouter is the only upstream here that returns an authoritative USD figure inline, so when it is present the ledger uses it verbatim instead of pricing token counts against the local table — an aggregator’s blended price cannot be derived from a per-model price table, and the figure is honoured on the streamed path as well as the buffered one, because the transport a client picked must not decide whether the ledger agrees with the invoice. The attribution headers identify the gateway and never the agent or the end user.
The caller-controlled routing fields — models, provider, route, plugins, transforms and web_search_options — are rejected before egress, as are retained :online, :nitro, :floor and :exacto route targets, because each delegates model choice, processing, search egress or charges outside governance. Configure fallbacks and provider selection on route rules instead.
A provider row of kind openai with its base URL pointed at your own endpoint: vLLM, an internal inference gateway, a Bedrock VPC endpoint or an Azure AI Foundry resource. This is the air-gapped path as well as the self-hosted-model one, since a prebuilt image has no runtime dependency on a vendor service or CDN and the dashboard bundles its own assets.
The host must be named in the provider host allowlist — the shipped default contains only vendor endpoints, so name every regional or self-hosted host explicitly before upgrading — and the credential’s environment variable must match an allowed prefix. HTTPS is required, and plain HTTP is accepted only for a literal loopback host.
The one optional built-in outbound path with a fixed public destination, and the only one whose URL is not something an operator typed. It is triggered by a named person at operator rank rather than by a timer, it sends no customer data, and the attempt is audited whether it succeeded or failed — because "we tried to refresh prices and could not" is itself the answer to a billing question later. The catalogue also ships in the image and loads on every boot, so metering works with no internet access at all, and boot-time loading is additive, so a row you have corrected is never overwritten on restart.
There is no POST /api/prices, so correcting a price on an air-gapped install means updating the model_prices row directly. A model with no price row meters at zero for an unbudgeted agent, and an agent with any budget instead receives a typed ACP_BUDGET_UNPRICED refusal before egress, because an unmetered estate and an idle one otherwise look identical on every spend surface.
Agent frameworks and clients
Anything that lets you set a base URL and a key is already compatible, and the change is exactly those two things. The README states the claim with its own qualifier attached: for supported OpenAI-compatible, Anthropic and Gemini ingress this is normally a base-URL change rather than an application refactor. No SDK swap and no code change. One limit belongs beside that, because it is a real scope boundary rather than a footnote: image, audio and PDF inputs are currently rejected on every dialect — Chat image_url, Anthropic image, Responses input_image and Gemini inlineData alike — because media is not yet decoded under the data-loss and injection policies, and a caller-supplied MIME type is not treated as proof that opaque bytes are safe. Those refusals arrive as ACP_INVALID_REQUEST before any upstream call rather than as a request that bypassed governance.
Set the base URL to your host with the trailing /v1, and the key to an agent token this deployment minted. The response is byte-faithful to OpenAI, including cached-token details, and streaming is data-only SSE terminated by the [DONE] sentinel. GET /v1/models returns the registry-filtered list — only the models the calling agent’s roles permit — which is where role-based access control becomes visible to a client SDK rather than a console.
The Responses adapter is a compatibility subset rather than a hosted response store: previous_response_id, conversation, item references, background jobs, prompt templates, opaque file ids and hosted tools are rejected rather than silently ignored.
Set the base URL to your host with no trailing path, and the key to an agent token. max_tokens is required, anthropic-version and anthropic-beta are passed through verbatim, and streaming uses named SSE events from message_start through to message_stop. The token-counting endpoint is carried too, and it doubles as the pre-flight budget primitive.
The trailing /v1 belongs on the OpenAI dialect and not on this one. Getting that wrong is named in the onboarding guide as one of the three commonest causes of a 401 ACP_AUTH_INVALID_KEY.
Native Gemini ingress, so a client already written against the Generative Language API needs its endpoint changed and nothing else; the same methods are accepted under /v1/models as well. The agent credential may be presented on x-goog-api-key, which is what an unmodified Google client will send. A team that prefers one code path can instead point an OpenAI-compatible client at the gateway and ask for a Gemini model by name.
One environment variable for the base URL and one for the credential, rolled out through device management rather than asked for by hand, because a setting someone applies by hand is a setting they can remove.
Tell your developers before you do this. Once Claude Code is given a gateway credential it stops using that developer’s own subscription: the work is billed per token to whichever vendor account this deployment uses. For a governed company fleet that is the point, but it is a change people notice.
The inference gateway URL, distributed by device management, with an agent key as the credential. The same rollout argument applies, and more strongly, because a desktop application’s settings are the developer’s to edit on an unenrolled machine.
The provider base URL setting points at the gateway and the key is an agent token. This is the mode that can be pointed anywhere; a Copilot seat running on a developer’s own subscription cannot be, and is governed through the seat route below instead.
Every one of these accepts an OpenAI-compatible provider base URL — JetBrains calls it the on-premise server URL, the editors call it a custom provider — and every one of them takes an agent key in place of the vendor key it holds today. Anything your own team builds uses the same two settings.
Subscription tools on a developer’s own seat are governed at the machine instead, through the vendors’ own administrator extension points: device management installs a hook that asks the policy engine for a decision before a tool call executes, and a refusal stops the action. The subscription credential is never seen, stored or relayed. The device fetches a signed policy bundle, and every fetch mints — allocating the next monotonic version, signing a fresh issue time and appending to the audit chain — because a cached bundle is a stale bundle and a stale bundle is one the hook refuses.
Preview, and marked not production-eligible in its own responses; an install with any active seat cannot report itself technically ready. Three limits belong to the vendors’ tools rather than to Token Observe: nothing on a seat can stop spend before it happens, nothing can redact a prompt in flight, and Copilot inside the IDE has no gate at all. With no bundle signing key configured the endpoint answers 503 and never degrades to an unsigned bundle, because an unsigned bundle sits on a machine its own subject administers.
A synchronous A2A v1.0 HTTP and JSON interface, advertised on a well-known agent card. The caller presents a one-use capability whose signed task must match the message id, and the request then traverses the ordinary chat-completions governance pipeline under a derived gateway capability, so an agent arriving this way is governed by the same evaluator as one arriving on an SDK.
URL and file dereference, streaming, push callbacks and background tasks are not implemented and are rejected rather than silently weakened. There is no Task store, so a supplied task id returns TASK_NOT_FOUND before the capability is consumed rather than inventing a completed task a later read could not retrieve.
Refused, and recorded on the shadow-AI radar as an unrecognised caller rather than dropped. Relaying a consumer subscription is prohibited by Anthropic’s terms, and the vendor began blocking subscription OAuth in third-party clients in January 2026, which is precisely why the seat-policy route above exists.
MCP tool servers
One endpoint replaces every tool server a client was configured with. The agent points at it, gets the union of every enabled upstream catalogue namespaced server.tool and filtered to its own grants, and each call is authorised again at execution — because filtering a list is a usability feature rather than access control, and a client can guess a tool name. Tool results come back through the same inspection: text and structured JSON are delivered, while image, audio, blob, resource and URI content is withheld after the tool returns, because bounded media decoding does not exist yet. That refusal is terminal and says explicitly that the tool already ran, which is the only honest thing to tell a model whose action has taken effect.
Streamable HTTP: a POST carries exactly one JSON-RPC message, a GET opens the server-to-client event stream, and a DELETE ends the session. Initialisation negotiates the protocol version and issues a session id, but authority is re-derived from the bearer token on every request and sessions carry no permissions at all, so a session opened by one subject and presented with another’s token is refused rather than honoured. Refusals arrive as structured metadata beside model-readable text, carrying the typed code, whether it is retryable and any approval id.
Batching is refused outright: it was removed from the specification in the 2025-06-18 revision and this gateway never accepted it. A request whose Origin header is neither the gateway’s own host nor a loopback name is rejected, which is what stops a page on another origin driving a locally bound gateway.
Each upstream is a row holding a name, a destination and the name of an environment variable containing its Authorization header — never the credential itself, which is resolved at call time, never stored on the row and never logged. On every response the stored URL is shown as an origin only, with its userinfo, path, query and fragment write-only, so reading the registry never returns a destination that carried a secret in it.
This is the weakest of the four credential namespaces and the documentation says so: the named variable’s value is sent raw as an Authorization header, the row is writable at operator rank rather than admin, and the outbound call is triggered by any viewer listing that server’s tools. The host allowlist defaults to loopback only, because the shipped demo upstream runs there — set it before go-live.
One catalogue per process, deliberately, because it owns the long-lived upstream sessions and a second one would double every handshake. It is cached for sixty seconds, refreshed concurrently so that one slow or dead upstream cannot hide another server’s tools, and polled in the background only while somebody is actually listening, since an idle refresh is upstream load with nobody to tell. Each descriptor’s name, description and input schema is hashed at approval and re-checked here, so an upstream that quietly rewrites what a tool claims to do is quarantined rather than served.
A failed refresh serves the last known catalogue and makes the staleness visible — but that fallback is forbidden when durable authority has changed, and the read then fails closed, because it is not possible to know whether another process has just narrowed what this agent may do.
A developer’s editor is configured to allow exactly one tool server, this one, and presents a seat credential rather than an agent key. The governed path does not branch on which of the two arrived: the same evaluator decides a tool call from a seat and a tool call from a registered agent, against the same policy rows.
Identity
Two identity integrations ship, and both are bounded in ways worth stating before a rollout rather than discovering during one. Sign-in is OpenID Connect with Authorization Code and PKCE. Provisioning is SCIM 2.0, and it is a Users lifecycle subset rather than universal CRUD — Groups are not implemented at all, and discovery reports that truth rather than advertising a capability that would fail on first use. Underneath both sits a rule the console never relaxes: role changes are an administrator’s decision, so no directory claim and no provisioning credential can promote anyone here.
The routes always exist and are simply not enabled until all four values are set: issuer, client id, client secret and redirect URI. A partial configuration is refused at boot with a message naming the four, rather than leaving a half-configured sign-in path; ask for sign-in on an unconfigured install and you get that same list back as a typed error rather than a 404. Transactions are single-use and state-bound to the browser that began them, and the status endpoint tells the login page which methods to offer.
Both halves of this one point in different directions, which is why the badge is only half the answer. The browser arrives inwards, but completing a sign-in means this process calling your issuer — the discovery document, the JWKS and the token endpoint — so your identity provider belongs on the outbound list too, and only once you have configured it. A first-time user is provisioned at the configured default role and is never promoted by a later claim from the identity provider. That is the whole point: if a directory group could raise console rank, the directory would be minting privilege inside the audit log.
A bounded machine realm mounted by setting a deployment-scoped bearer token, which accepts neither a human session nor an agent credential. The unique key is the work email, compared case-insensitively; list filtering supports one expression and pagination is capped at 200 results; a PATCH accepts at most twenty operations, and the whole set is validated into one proposed state before any write, so an invalid later operation rejects the patch rather than leaving a partial update. Every create or update appends a durable intent to the audit chain before the user or session tables may change, and an unmatched intent is left as explicit reconciliation evidence rather than a silent claim of success.
Users only, and the boundary is honest in both directions. Groups are out of scope — GET /Groups returns a SCIM 404 — Bulk returns 501, and a hard DELETE returns 405, because account ids stay referenced by the audit trail and the supported leaver operation is setting the account inactive, which also revokes every session before the disabled row is written. Sorting, ETags, password change and extension schemas are absent. The profile is bounded too, not merely the resource list: the one schema is core:2.0:User, one display name is persisted rather than its components, and discovery marks the component inputs write-only and never returned because their exact values are not retained — so a later patch carrying one component replaces the flattened name rather than rebuilding components that were never kept. Every SCIM-created account is a viewer with no evidence scopes, and provisioning can never set a role, an evidence scope or a password. externalId, emails, enterprise-extension fields and any role or team-scope field are rejected rather than ignored, so an Entra mapping that sends externalId is incompatible until that mapping is removed.
Group claims are captured at sign-in and mapped to roles that can narrow an agent’s authority to the intersection with the human it is acting for. Whether they do is a three-position switch, and it ships in the position that changes nothing: with ACP_OBO_ENFORCEMENT at its default of off, no intersection is computed and the on-behalf-of header is attribution and nothing else. Under shadow the intersection is computed and written to the trace as an event whose status is always ok, so a dry run is distinguishable from an outage. Only under enforce do the mapped roles join the delegation chain — where they can only ever narrow what the agent could already do, never widen it. No refresh token is held and the directory is never read live, so a person who has not signed in since you enabled it has no capture at all, and under enforce a capture older than the configured limit (24 hours by default) refuses the request rather than deciding on evidence nobody re-checked. Two role universes are kept apart deliberately: console rank is only ever set by an administrator, while these roles are a permission mask used on the agent path alone.
The header is unauthenticated — there is no signed actor claim — which is exactly why it is a narrowing control and never a granting one, and why an agent can simply omit it unless its own row requires one. Google Workspace emits no group claim at all, so the feature is unavailable there. Entra sends a Graph link instead of groups past roughly 200 and that link is not followed, so those principals capture zero groups. Okta needs both a groups claim and the extra scope. Roll it out in shadow first and watch the intersection events before anything is enforced.
A short-lived, one-use capability exchanged from a configured issuer’s assertion and bound to the registered agent, the human actor, the task, the audience and the scopes. Assertion and capability identifiers are domain-separated and hashed before an atomic claim, so two processes sharing a store cannot both consume the same one-use credential, and the replay table persists no raw issuer, identifier or bearer.
The route exists only where both the workload identity configuration and the exchange key are set. A capability lives a fixed 120 seconds and is not an agent key: it cannot call the model gateway directly.
The control plane is session-only. There is no admin bearer-token mode, because every control-plane action being attributable to a named person is what makes the audit log evidence; an earlier revision of the API contract documented such a mode that was never implemented, and the claim was removed rather than the feature added. Login is throttled on two independent keys, the source address and the account.
The cookie is marked Secure only when the resolved protocol is https, so behind a TLS-terminating proxy the proxy-trust setting is required for that flag to be set at all.
Evidence destinations
This is the group the posture statement above is about. Webhooks go out to receivers you configure; every SIEM connection comes in, from a console you already administer, on a credential minted here. The receivers take the vendor’s own log shape and do the renaming internally, because the alternative — an endpoint taking this product’s row shapes, which no vendor emits — means the customer writes a transform, nobody owns it, and when it breaks the console shows a clean estate. The same bounds apply to every receiver: 10,000 records and 8 MB per delivery, refused whole rather than truncated; 120 deliveries burst and one a second sustained per credential, refused before the body is read; and 50 unconsumed batches per stream, whose refusal names the real cause, which is that detection has stopped rather than that the connector has. Up to 5% of a delivery’s rows may fail to map and are dropped and counted; past that the delivery is refused whole, because half not mapping is what a wrong mapping looks like rather than a dirty feed.
Thirteen event types are subscribable, spanning approvals requested and resolved, policies blocking and flagging, budget warnings and breaches, an agent suspended, a kill switch engaged and released, a radar finding, a scan summary, a rejected connector delivery and a tool descriptor drifting from its pin. The durable delivery row is opened before any network egress, so a failed write means no HTTP request is made, and a webhook with delivery evidence cannot be deleted — only disabled — so its configuration and history stay attributable. Health is decided by the most recent attempt rather than a success ratio: a receiver that worked all last month and has failed since Tuesday is failing, however good the ratio looks.
never_attempted is its own health state and is not folded into a healthy one, because a receiver nothing has been sent to is not a working receiver. Slack, Teams, Discord and any query-bearing destination must name an environment variable holding the complete write-only URL, and production is default-deny when the webhook host allowlist is absent, since a receiver is always third-party and no external host is safe to assume.
Eight documented steps, against the account rather than the zone, because Gateway logs are account-scoped. The Gateway HTTP dataset goes to an HTTP destination with the credential in a header. The mapping needs a timestamp, a device name or source address, a host or SNI, and request and response sizes; selecting more fields is harmless, because nothing unmapped is stored. Keep the job at or below 10,000 records and 30 seconds or more between uploads — Cloudflare’s defaults sit inside both.
Nine steps: an HTTPS forwarding profile posting a JSON array or newline-delimited JSON, with the credential in a header. Strata sends every number as a string, which is expected and coerced, and timestamps are read whether they arrive as RFC 3339 or as an epoch at any scale. Filter to outbound sessions — that is a volume decision rather than a correctness one, since internal flows are ignored but still spend the delivery budget.
Forward Traffic and URL Filtering only. Threat and System carry no source, destination and bytes triple, so every record would be dropped as unmappable — and the raw and Common Event Format payload options are refused loudly rather than misread.
Seven steps, and three shippers all work: a Filebeat http output, a Logstash http output, or an Elasticsearch watcher webhook action. Documents must be ECS, and the fields read are the timestamp, the host name or source address, the destination or URL domain, and either total network bytes or the source and destination byte pair — nested or dotted alike, because documents are flattened before mapping. The same connector feeds the IDE stream from endpoint documents carrying host, user and process names, where an absent URL is read as the vendor default, which is exactly what the IDE detector already means by an empty base URL.
Six steps, and the preferred of the two Splunk routes because the sender can set a header. The collector path is served exactly as real clients hardcode it, the token may arrive as Splunk or as Bearer, and the response is the standard success envelope and code table. What is mapped is the CIM Network Traffic fields inside the event object; a saved search emitting a different shape is refused with the field names it saw rather than guessed at.
Seven steps, and it exists because Splunk’s built-in webhook has no header authentication: it posts JSON to a URL you type and offers nowhere to put a credential. So the credential has to survive being in a URL, and the answer is to make it a narrow, separate, watchable secret rather than to pretend a URL is a safe place for one. It is a different secret from the ingest token, it cannot be presented as a bearer anywhere — not by a rule but by shape, since it resolves only against one table no other resolver reads — it is never wider than the key it hangs off, it is separately revocable, and it dies automatically when its parent is revoked or expires.
This is containment rather than protection. Its use count and last use are reported, which is the compensating control for a secret that cannot be kept secret: you cannot stop the URL being copied out of your proxy logs, but you can see it being used. Prefer the header form wherever the sending product supports one.
Six steps and no vendor code at all: if it can write a file and make an authenticated POST, it is a connector. CSV needs a header row, every value may be a string, and common alternative spellings are accepted. Billing needs a provider, a month and an amount; seats needs a vendor, a user email, a month and an amount; service accounts needs a provider, an account id, a key id and a creation time; the IDE stream needs a host, a user, a tool and a timestamp; egress needs a source host, a destination host, a byte count and a timestamp. A 202 means it landed, and a 200 saying duplicate means you had already sent that window and nothing was double-counted — and it still refreshed the freshness clock, because a retry is proof your exporter ran.
A column that cannot be found is a refusal rather than a default. A byte count quietly defaulted to zero produces a row every detector accepts and none ever fires on, which is worse than no feed at all.
Seven values are accepted — generic, cloudflare, paloalto, zscaler, netskope, elastic and splunk — and the mappings exist only where a vendor really publishes that stream. All seven map egress; elastic and splunk also map the IDE stream; and billing, seats and service accounts are generic only, because no egress product publishes an invoice, a seat spend or a key listing. Where the vendor is omitted it is inferred from fields only one product emits, and inference refuses rather than guesses, because the wrong mapping produces rows that parse, land, and describe flows that never happened.
The stream is required, and several vendors’ consoles cannot add a query string — GitHub audit-log streaming takes only a domain, a port and a token. Where that is true, scope the credential to exactly one stream and the credential carries the stream instead.
An Ed25519 signature over the audit chain head, published on a schedule to a sink URL, a file, or both, and off entirely unless an anchor private key is configured — in which case the status endpoint says so with a reason rather than reporting an empty chain. Delivery is fire-and-forget after the anchor is durable locally, so a dead sink is logged at error level and never fails a governed request, and publication progress is durable per destination: after an outage it resumes at the first unacknowledged anchor and never sends the next before its predecessor. Delivery is at-least-once, so an HTTP receiver must treat the installation id and anchor sequence pair as idempotent — they arrive as x-acp-install-id and x-acp-anchor-seq.
Verification never trusts a key read out of the artefact it is verifying: the public key on the verify route is required and never defaulted, and it has to reach the auditor out of band, newest to oldest across rotations. A signature is only worth its key custody — worth setting when injected from a key manager the database administrator cannot read, worth nothing sitting beside the database it protects. The chain is tamper-evident, not tamper-proof, and the strength of that evidence is a setting rather than a property: the chain digest is unkeyed by default, so links, sequence and a truncated tail are all located, but anyone who can write the database file can alter an entry, recompute every hash after it and still read valid — a limit the product proves against itself in a test on a default install rather than only stating in prose. Set the audit MAC key, and inject it from somewhere the database administrator cannot read.
Telemetry ingest
OpenTelemetry data arrives on the standard OTLP over HTTP endpoints, and both encodings are bounded rather than just accepted: JSON follows the protobuf JSON mapping and is read under caps of 2,000 records and 200 opened traces per export, attribute nesting eight deep, 64 attributes and 8,000 characters of body; protobuf goes through a zero-dependency decoder that reads only the stable fields this recorder consumes, skips unknown fields by wire type and caps nesting at 32, fields at 200,000 and any single string at a megabyte. The response uses whichever encoding the request used. Every write is attributed to the seat or agent credential that carried it and never to a resource attribute, because a resource attribute is a claim the sender makes about itself. Logs and spans become explicitly recorded flight-recorder events after irreversible ingest redaction — recorded rather than inline-enforced, which is the honest description of evidence that arrives after the fact.
Endpoint telemetry from a seat’s hook or an agent’s own instrumentation, redacted irreversibly on ingest — masked rather than tokenised, because there is no conversation downstream to keep coherent — and then recorded against the credential that sent it. Policy is deliberately not evaluated here: this is a record of something that already happened on a machine the gateway was not on, so there is nothing left to refuse, and a blocked verdict against a completed action is the false-enforcement reading the seat census exists to prevent.
Ingest redaction is the same detector the gateway uses, and it has the same bound: matching is regular expressions plus checksums over a named set of kinds, so free-text personal data and identifier formats outside the UK and US ones it knows are not detected at all. It is a compensating control rather than a complete data-loss prevention pass, and the wire may well have carried more than the durable record keeps.
Spans from the agent’s own tracing, joined to the governed record rather than replacing it. What the gateway saw itself is the authoritative trace; this is the caller’s account of the same work.
Accepted so a standard collector does not error, and then not stored. The collector queue drains and every data point comes back as rejected with that explanation in the partial-success response, which is the difference between declining to store something and quietly discarding it.
Nothing is retained. Do not point a metrics pipeline here expecting a store; the partial-success response names every point it refused.
The process exposes its own metrics in Prometheus text format for your scraper to collect, including the circuit-breaker state read through the same instance the request path uses, so the gauge reports the breaker that is actually admitting or refusing traffic rather than a second one kept for display.
Unauthenticated, and it shares the main listener with the dashboard and API while carrying agent identifiers and spend. The reference Compose file publishes on loopback rather than every interface for exactly this reason, and a public ingress must not expose it.
Readiness reports unready until migrations have applied, which is what makes it the correct gate during an upgrade: traffic never reaches a half-migrated process. Both probes stay reachable when process admission is full and new external work is being refused, so an overloaded process is still visible to its orchestrator rather than being restarted for looking dead.
Note the z on the end of each. /health without it is not a real address — it returns the dashboard’s web page with a success code, which looks like a healthy answer and is not. And on a platform whose healthcheck hits readiness, the audit-key ceremonies become impossible, because a ceremony boot deliberately reports unready.
Shadow-AI evidence feeds
Five evidence sources find AI activity that never touched the gateway, and four of them need evidence from outside: vendor bills, network egress, service-account key listings and workstation telemetry. The fifth reads the gateway’s own tables and needs no export at all. Coverage and connector health are two different questions and neither is complete alone — a connector can be perfectly healthy while the stream it feeds is stale, and a stream can be fresh with no connector at all because an operator posted a file by hand. The freshness bounds are per stream and sized to the export’s own cycle: six hours for egress, 48 hours for IDE telemetry, ten days for key listings, and 45 days for bills and seat spend, since a master bill is published once a calendar month. That last figure is why connector health is read beside coverage: a credential revoked this morning would otherwise leave seat coverage reading fresh until September while nothing was being collected.
The endpoint that takes this product’s own row shapes, validated by the detectors’ own parsers — the same code path a manual scan uses, so a row refused at one door is refused at the other with the same message rather than landing through a side entrance.
No vendor emits these shapes, so using this endpoint means somebody writes and owns a transform between the SIEM and here. That script is real work, nobody owns it, and when it breaks the console shows a clean estate — which is the whole reason the vendor-shaped receivers exist.
A GitHub App with exactly one organisation permission, Administration with read access, which is what the usage report requires and nothing more. The private key never leaves its environment variable: it signs a nine-minute assertion, that is exchanged for a one-hour installation token, and only the token goes on the report request — and where the report comes back as a download link, the link is followed without the token. The sweep runs six-hourly into the seats stream.
This is one of the two rows the no-credential claim excludes: Token Observe holds a GitHub App private key you supplied and reaches into your organisation with it. Off by default behind ACP_CONNECTORS_ENABLED, and it has never met a live tenant: every assertion in its test suite runs against recorded-shape fixtures behind an injected HTTP boundary. If a vendor’s field names or endpoint paths differ from those fixtures the run fails with ACP_CONNECTOR_SCHEMA and the connector shows as failing, which is the intended outcome rather than a silent partial result.
A Reporting-scope, read-only API key and secret. Not a management or admin key — nothing here needs one, and a reporting key cannot change your policy, which is the point of naming the scope in the setup steps rather than leaving it to judgement. The connector runs hourly, collecting the previous hour and folding events into rows keyed on identity, destination and hour, into the egress stream.
The second row the no-credential claim excludes, and it is a credential into a security product: off by default behind ACP_CONNECTORS_ENABLED, carrying the same unproven-against-a-live-tenant caveat as the Copilot connector. Both read credentials live in a namespace of their own, bounded by its own host allowlist — defaulting to api.github.com, *.githubusercontent.com and api.umbrella.com and nothing else — and the variable each connector reads is a constant on its definition rather than a field an operator types, so there is no row to point at an arbitrary variable. The namespace exists because a vendor admin credential is far more powerful than a model key: an Anthropic Console admin key has no selectable scopes at all, so the key that reads your spend can set every API key in the organisation to inactive.
Rows of host, user, tool and timestamp, with an optional base URL, posted on your own schedule. This is the feed that names every workstation running agent tooling against a vendor endpoint instead of yours, so you can size the exposure and convert what is convertible. An absent base URL is read as the vendor default, which is exactly what the IDE detector already means by an empty base URL.
Three streams have no network source at all and are fed by exports from systems that already produce them: a finance export for vendor bills, a vendor administration export for per-user seat spend, and a key listing for service accounts. Each is a scheduled drop naming its window, so a re-run is not a second month.
Collection and detection are two switches, and in that order of importance: with collection on and scanning off, evidence lands, nothing reads it, and after fifty unconsumed batches the connector starts failing with a refusal that names detection rather than the connector as the thing that stopped.
What your agents actually point at.
A gateway that lists a provider and leaves an engineer to read the source for the path has not answered the question they arrived with.
OPENAI_BASE_URL="https://tokenobserve.company.com/v1" # was https://api.openai.com/v1
ANTHROPIC_BASE_URL="https://tokenobserve.company.com" # was https://api.anthropic.com- /v1/chat/completions
- OpenAI Chat Completions, streaming and buffered
- /v1/responses
- OpenAI Responses
- /v1/messages
- Anthropic Messages
- /v1/embeddings
- Embeddings
- /v1beta/models/{model}:generateContent
- Native Gemini, streaming on :streamGenerateContent; the same two methods are also accepted under /v1/models
- /v1/models
- Model listing, filtered to what the agent may reach; the same prefix also carries the Gemini generateContent methods
- /mcp
- MCP gateway over Streamable HTTP
- /api/*
- Control-plane REST API
- /scim/v2/*
- Bounded SCIM Users provisioning
A base-URL change is the normal case for the OpenAI-compatible, Anthropic and Gemini dialects. Whether your own SDK and version behave that way is the first thing to check, and the first thing a proof of concept settles.
Whether your own SDK and version behave the way the dialect does is the first thing to check, and the first thing a proof of concept settles.
See the request pathWhat actually changes in my stack when I put Token Observe in front of an agent?
Two settings: the base URL and the credential. Point the client at your Token Observe host and give it an agent token this deployment minted, in place of the vendor key it holds today — no SDK swap and no code change, and the README states that claim with its qualifier attached, which is that for supported OpenAI-compatible, Anthropic and Gemini ingress this is normally a base-URL change rather than an application refactor. One detail catches people: the OpenAI dialect wants the trailing /v1 and the Anthropic one does not, and getting that wrong is named in the onboarding guide as one of the three commonest causes of a 401 ACP_AUTH_INVALID_KEY. Two limits belong beside the claim rather than under it. Image, audio and PDF inputs are currently rejected on every dialect, because media is not yet decoded under the data-loss and injection policies and a caller-supplied MIME type is not proof that opaque bytes are safe. And a subscription is not accepted as a credential: a signed-in Claude Code or Cursor pointed here without an agent key is refused and recorded on the shadow-AI radar as an unrecognised caller, because relaying a consumer subscription is prohibited by the vendor’s terms.
Does Token Observe need a credential into my SIEM?
No — and for every SIEM, proxy and log platform on this page that is the design rather than an omission. Token Observe holds no credential into your security stack: the connection points inwards, which is both the shorter security review and the smaller blast radius; the worst a compromised deployment can do to your SIEM is stop receiving from it. You configure an outbound feed in a console you already administer — a Cloudflare Logpush job, a Strata forwarding profile, a Filebeat output, a Splunk forwarder — and it delivers here on a scoped, revocable credential that can submit exactly the streams named on it and cannot read a finding, call a model or reach a tool. The honest exception is the two vendor pull connectors, GitHub Copilot and Cisco Umbrella, which do hold a read credential into somebody else’s console. Both are off until you switch them on, both are scoped as narrowly as the vendor allows — one organisation permission with read access, or a reporting key that cannot change your policy — and their credentials live in a namespace of their own with its own host allowlist, because a vendor administration credential is far more powerful than a model key. Neither has met a live tenant: every assertion in their test suites runs against recorded-shape fixtures, and a vendor whose field names or paths have moved fails the run loudly rather than landing a half-mapped row. One more connection belongs on the outbound side for completeness, though it is not your SIEM: if you configure single sign-on, this process calls your identity provider’s discovery, JWKS and token endpoints.
Which model providers are supported, and can I use my own endpoint?
Six kinds are first-class — openai, anthropic, google, azure, bedrock and openrouter — plus a mock that fabricates output for the offline demo and is not built at all when any enabled row has a different kind. Your own endpoint is the openai kind with its base URL pointed wherever you run: vLLM, an internal inference gateway, a Bedrock VPC endpoint or an Azure AI Foundry resource, which is also the air-gapped path, since a prebuilt image has no runtime dependency on a vendor service or CDN. Two allowlists bound every provider row and neither may be empty in production: which hosts a base URL may name, and which environment variables it may read. The shipped host default contains only vendor endpoints, so name your own hosts explicitly before upgrading. Three limits are worth knowing before a rollout. Embeddings are proxied for openai and openrouter rows only, and the other four fail closed before credential resolution rather than being sent a request in the wrong dialect. Bedrock speaks the Anthropic-on-Bedrock body shape and refuses other model families with a named reason. And the per-provider data-policy flags — zero retention, no training, a required region — are operator assertions that are recorded and audited but not verified against the contract they describe.
How does SCIM provisioning work here, and what about Groups?
It is a Users lifecycle subset, mounted by setting a deployment-scoped bearer token that accepts neither a human session nor an agent credential. Create, read, replace and patch a user; the unique key is the work email, compared case-insensitively; list filtering supports one expression, pagination caps at 200 results, and a patch accepts at most twenty operations validated into a single proposed state before anything is written. Groups are out of scope and the discovery documents say so rather than advertising a capability that would fail on first use — GET /Groups returns a SCIM 404 and Bulk returns 501. A hard delete returns 405 on purpose: account ids stay referenced by the audit trail, so the supported leaver operation is setting the account inactive, which disables it and revokes every session before the disabled row is written, and the same guard refuses to disable the last active administrator. The authority boundary is the part worth reading twice. Every SCIM-created account is a viewer with no evidence scopes, and provisioning can never set a role, an evidence scope or a password; a full rename or a reactivation is accepted only while the account is still a viewer with no scopes, so an email-first sign-in cannot turn the provisioning credential into authority somebody else already holds.
What happens when a feed I configured quietly stops working?
You are told, by two surfaces that answer different questions, because a dead feed must never be indistinguishable from a clean estate. Coverage answers whether a source has been fed and how recently — connected and fresh, connected but stale, connected and carrying nothing, failing, or never connected — and it keeps two clocks rather than one, because a delivery proves only that the connector is alive, while a delivery carrying at least one row proves the estate was actually observed. Connector health answers whether anything is knocking and what happens when it does: healthy, dropping rows, delivering but rejected, never accepted, or silent. The two states that matter most are the ones this whole mechanism exists to produce — delivering but rejected, and never accepted — where somebody is posting on schedule with a valid credential and none of it is being read, which produces an empty findings list and an empty arrival record, exactly like a connector nobody ever configured. A rejected delivery deliberately does not advance the arrival clock, every refusal carries the typed code and the field names the delivery held but never its data, and a rejection event is published at most hourly per connector and code. No caller can read a zero-findings result from this API without also being handed which sources were silent when it said so.
Name the SDK and the version. That is the first thing to settle.
Most integrations here are a base-URL change, and the ones that are not are named as such on their own row. Say which client libraries your agents use and which identity provider you run, and you will get a straight answer about what would need to change.
no form · no qualification step · no sales desk · the other three ways in