Repository navigation
v0.3.0
[0.3.0] - 2026-10-09
"Cifras ciertas" (true figures): the server, the portal, the CLI and the SDKs stop treating an unknown token count, cost or currency as zero, split cache reads from cache writes, add llm.failed, and Claude Code finally reports tokens through install claude-code --telemetry. First release published to npm, GHCR and (pending the owner's PyPI trusted-publisher setup) PyPI. The full semantics, field by field, are written down once in docs/usage-semantics.md (español); every section below links back to it instead of restating the rules. While the package is at 0.x, its API may still change between minor versions.
Breaking changes
-
Snapshot usage block.
GET /api/v1/snapshot(and the newGET /api/v1/usage) no longer carries only a baretotalTokens/totalCost. It now also returnsusage: UsageSummary(server/usageAggregates.ts): per(currency, costSource)cost buckets, asum/unreportedCountpair per token kind, and the same breakdown by agent and by model.totalTokensandtotalCostare kept for one minor as deprecated projections (removed in 1.0):totalCostis a number only when every successful call reported a cost in exactly one(currency, costSource)pair, and isnullotherwise, instead of a sum across currencies.Before (0.2.x):
{ "totalTokens": { "input": 1200, "output": 340, "cached": 0, "reasoning": 0 }, "totalCost": 0.42 }After (0.3.0), alongside the same (now honest) deprecated fields:
{ "totalTokens": { "input": 1200, "output": 340, "cached": 0, "reasoning": 0 }, "totalCost": null, "usage": { "total": { "calls": 2, "tokens": { "input": { "sum": 1200, "unreportedCount": 0 }, "output": { "sum": 340, "unreportedCount": 0 } }, "byCurrency": [{ "currency": "USD", "costSource": "provider-reported", "amount": 0.3, "calls": 1 }], "costUnknownCount": 1 } } }Migration: read
usage.total(orGET /api/v1/usage) instead oftotalTokens/totalCost; see docs/usage-semantics.md section 2 for every field. -
PATCH /api/v1/agents/:agentIdallow-list. Only descriptive profile and status fields are accepted (name,roleTitle,provider,model,status,statusText,workspace).tokensInput,tokensOutput,cachedTokens,reasoningTokens,costand any other unlisted key are rejected with400 validation_failed(issues[].pathnames the field), instead of silently changing spend. Migration: report usage with anllm.usageevent throughPOST /api/v1/events, or the SDKusage()/agent.usage(...)helper. -
Contract nullish fields.
llm.usage(andllm.failed) no longer defaultcachedTokensorreasoningTokensto0: an omitted ornullvalue now staysnull/absent instead of becoming a zero that looked like "the provider reported zero". NewcacheReadTokensandcacheWriteTokensreplace the deprecatedcachedTokens(kept as an alias; a differing value in both is rejected). Newllm.failedevent type (canonical event count: 22 to 23).Before:
{ "inputTokens": 500, "outputTokens": 120 }meant "cached and reasoning tokens are zero".
After: the same payload means "cached and reasoning tokens were not reported"; a provider that truly billed zero cached tokens must send"cacheReadTokens": 0explicitly.Migration: readers of stored or exported events must handle
null/absent as "unknown", not "zero". -
SDK
costSourcedefault. Passing acostwith nocostSourceno longer sends"provider-reported"; it now sends"unknown"and prints one warning per client instance.currencyand a task id are newly accepted. Migration: passcost_source="provider-reported"(Python) orcostSource: 'provider-reported'(TypeScript) when the cost really came from the provider, and passcurrency. -
Totals no longer include
PATCHamounts or unknown-as-zero. A figure the 0.2.x snapshot showed (built fromPATCH-edited numbers and zero-filled unknowns) can differ from the 0.3.0 figure for the exact same stored data; the 0.3.0 number is the correct one. See docs/usage-semantics.md for how to reconcile it against a provider invoice. -
Webhook usage validation is now stricter.
usageblocks without required fields (provider,model,inputTokens,outputTokens) are rejected with400 validation_failed. Empty usage objects ({}) are now rejected, not accepted as zero-filled events. Usage with an unknowncostSourceis accepted as anunknowncost source, not silently mapped to an assumed value. -
Ingestion behavior change: a client that reuses an event id for different content used to get
200 duplicateand now gets409 conflicting_duplicate. The new event is still not stored, so totals do not change; the difference is that the loss is now visible. Batches keep answering202and report the item asstatus: "conflict". -
Raw HTTP clients that retry by rebuilding the body with a fresh
timestampnow get409on the retry, becausetimestampis part of the event. A retry must resend the identical event. The SDKs are not affected: they build each event once and resend the same object. -
Retries across the upgrade: an event stored by 0.2.x with a default the 0.2.x validator filled in (for example
cachedTokens: 0) has a different fingerprint than the same event validated by 0.3.0, so a retry of it after upgrading gets409. This fails closed: the stored row stays and nothing is counted twice. -
The Claude Code hook gives a tool event a stable id but the
timestampof each hook run, so a hook that fires twice (installed in both user and project settings) now reports the second copy asconflictinstead ofduplicate. The hook still exits 0 and the office keeps the first copy; the count shows in/ready. -
A 0.3.0 SDK needs a 0.3.0 server: a 0.2.x server rejects
llm.failedand dropscacheReadTokensandcacheWriteTokens.
Added
- SSE reconnect now replays every missed event or sends an explicit resync, never a silent partial replay (issue #54). New
AGENT_VIEWER_SSE_REPLAY_MAX(default10000,0means any reconnect that missed something always resyncs) bounds how many missed events a reconnect replays in full; past it, or when the cursor is unknown (never stored, evicted in memory mode, or lost after a memory-mode restart), the server sends a namedresyncframe (reason:cursor_unknown,gap_too_largeorbuffer_overflow,missedas a count ornullwhen truly unknown, never0) instead of event frames, and keeps the connection open and streaming live events afterward so older clients keep working. A full replay ends with a namedreplayedframe (replayed,lastEventId).GET /healthgainssse: { replayMax, replaysSinceStart, eventsReplayedSinceStart, resyncsSinceStart }.connectEventStreamgainslastEventId(resume from a cursor, typicallysnapshot.lastEventId),onResyncandonReplayedoptions, a newresyncingRealtimeStatus, andresyncCount()on the returned connection. The demo app rebuilds its live office fromGET /api/v1/snapshoton a resync through the newrebuildFromSnapshothelper (src/integrations/snapshotRebuild.ts), shows the server's own usage totals (never re-added from the snapshot's 100 events) and a dismissible notice in English and Spanish. See docs/integration.md and docs/library.md. - Golden reconciliation suite (
npm run test:golden, issue #62): one hand-checked fixture (tests/fixtures/reconciliation/, mixed currencies, a missing cost, a missing currency, missing cache tokens, a duplicate id, a conflicting duplicate, a repeatedrequestId, a failed call) run through the memory store, the SQLite store across a restart, the portal reducer and the embeddable library'ssummarizeUsage, asserting all four reach the same figures. Runs as its own CI step, right after typecheck, and fails on the field and the path that diverged. Ships with an independent reference reducer that cross-checks the hand-worked expected figures. Deferred to a follow-up: the HTTP routes (including signed webhook retries and thePATCHrejection path), memory eviction, and alibrary-privacyVitest suite; none are blocked on a missing dependency, see the fixture'sREADME.md. agent-viewer install claude-code --telemetrypoints a project's Claude Code at the office's OpenTelemetry logs, so token and cost per model call show up (opt-in, off by default; needs a new Claude Code session). Helper mode (default) writes anotelHeadersHelpercommand that looks the running office's token up on every export; static mode (--telemetry --token <token>) writes a fixedOTEL_EXPORTER_OTLP_LOGS_HEADERSinstead, for an office on another machine or in Docker, with the token always masked as<token hidden>in the diffs the installer prints. The fiveOTEL_LOG_*content flags are always pinned to"0", and no metrics or traces exporter is ever configured.installrefuses outright on a conflicting existing setting (naming the file and the key) and warns, without refusing, on a setting it only overrides by Claude Code's own precedence.--no-telemetryremoves only the telemetry block, keeping the hooks;uninstall claude-coderemoves both, byte for byte, the same way the hooks round-trip today. Newagent-viewer otel-headers --url <office-url>helper command (used byotelHeadersHelper, not meant to be run by people): never touches the network, prints the office's bearer token as JSON only when a running office has that exact origin, always exits 0. Guide: docs/claude-code.md. Refs #60; the server now has thePOST /v1/logsreceiver this telemetry needs (#59), so this ingests for real once both are on the same release.POST /v1/logs, a server-side OTLP/HTTP logs receiver for Claude Code's native OpenTelemetry export: turnsclaude_code.api_requestandclaude_code.api_errorintollm.usage/llm.failedon the same main agent the Claude Code hooks already draw, so a session's tokens and cost show up without reading the transcript. Shares/api/v1's auth and rate limiter (every 429 on either route now carriesRetry-After); its own body parser accepts gzip, deflate and brotli up toAGENT_VIEWER_OTLP_MAX_BODY(default5mb) andAGENT_VIEWER_OTLP_MAX_RECORDS(default5000) log records. Ids are deterministic content hashes (evt_cc_otlp_...), so an exporter retry or a redelivered batch is never double counted. Reads a fixed attribute allowlist only: raw session ids are hashed through the same identity the hooks use, and emails, account ids, organization ids, prompts, responses and tool content are never read, logged or stored, confirmed against real fixtures recorded from Claude Code 2.1.273 (tests/fixtures/claude-code-otlp/). NewGET /api/v1/otlp/statscounters (received, mapped, duplicates, ignored, unknown, unattributed, invalid, by type). Addednetworkto theLlmErrorKindvocabulary (issue #46) for a Claude Code failure with no HTTP response at all, and madellm.failed.modeloptional (Claude Code can fail before choosing a model). Reference: docs/otlp.md. Closes #59.- Memory mode no longer forgets an accepted event id (or, for
llm.usage/llm.failed, a(provider, requestId)key) when it falls off the retained event window: dedup now covers every id accepted since the process started, so a late retry of an evicted event is still answered200 { duplicate: true }instead of being counted again. The retained window is now a configurable ring buffer,AGENT_VIEWER_MAX_EVENTS(default10000, must be a positive integer; an invalid value stops the server at startup).GET /api/v1/snapshot,GET /api/v1/eventsand the SSE heartbeat now carry aretentionblock (storage,maxEvents,retainedEvents,acceptedEvents,droppedEvents,since,totalsSince) so a reader can tell when the event list is truncated even though the totals still cover everything. The server logs one warning when the dedup index crosses 1,000,000 known ids, suggestingAGENT_VIEWER_STORAGE=sqlitefor long-running servers. - The plain server warns at startup when
/api/v1is open (noAGENT_VIEWER_API_TOKEN), naming the port, the bind address and whether webhooks are open too./healthreports the currentauth(tokenoropen) andwebhookAuth(signature,tokenoropen) modes, computed per request and never including the token. The live portal shows a persistent, localized banner and anOPEN APIindicator next to theLIVEbadge when the connected server is open; it never shows in demo mode or when the state cannot be read. (provider, requestId)is now a second deduplication key forllm.usageandllm.failed, on top of the existing event id key: a provider call reported twice under two different event ids (an application retry, a replayed buffer, two reporting layers, a retried webhook delivery) is counted once. Provider matching ignores case and surrounding whitespace;requestIdmatches exactly after trimming; a missing or blankrequestIdbehaves exactly as before.llm.usageandllm.failedshare one key space, so a call reported as failed and then as used is not double counted. A second report is stored as an auditable duplicate reference (duplicateOfpoints to the original): it is never added to any total, never changes an agent's status, provider or model, is never broadcast over SSE or replayed onLast-Event-ID, and is excluded fromGET /api/v1/events.POST /api/v1/eventsand/api/v1/events/batchresponses addduplicateReason(event_idorrequest_id),submittedId(only when it differs fromid) andmatchesOriginal(only for arequest_idduplicate: whether the usage-relevant fields, including every token field, cost, currency and costSource, match the original;nullequalsnull, butnullnever equals0). A mismatch logs one warning with both event ids, the provider and a truncated request id, never payload text. New read-onlyGET /api/v1/usage/duplicateslists duplicate references (limit,provider,requestId,duplicateOffilters), behind the same/api/v1auth and rate limiting.GET /api/v1/snapshotgainsusageDuplicates: { count, mismatched, unverified }. SQLite migration 3 (request-key-dedup) addsrequest_provider,request_id,duplicate_ofandmatches_originaltoevents, backfills them from the stored payload, and marks pre-existing same-key rows as duplicates of the earliest one (matches_originalstaysNULLfor those: the legacy content was never compared under this rule). Totals rebuilt later from an upgraded database (#52) will be lower wherever such duplicates existed; that is the correction, not a regression.- SQLite databases now carry a schema version and migrate automatically with a backup; servers refuse databases newer than themselves.
agent-viewercommand, shipped in the npm package (npx @warlockcode/agent-viewer): starts the ingestion server and serves the prebuilt office in live mode from the same port. It listens on127.0.0.1by default (--hostorAGENT_VIEWER_HOST; port from--portorPORT), generates a session token unless--token(or a non-blankAGENT_VIEWER_API_TOKEN, or the deprecatedAGENT_VIEWER_API_KEY) is given, so it never runs without one, prints the office URL, the token and a readycurlcommand, and opens the browser. Options--port,--host,--token,--demo,--no-openand--record <file.jsonl>(canonical JSONL V1). The browser it opens gets a single-use launch code instead of the token, so the token never shows up in the browser's command line. Requires Node.js 22.13 or later, declared inengines. Guide: docs/cli.md.agent-viewer send --agent <id> --status <status> [--message <text>]posts a canonical event without writing JSON, and finds the running viewer on its own.- Claude Code adapter:
agent-viewer claude-hooktranslates the official Claude Code hooks (sessions, prompts, tools, notifications, subagents, stop) into V1 events, with each subagent as its own agent. Only tool names, agent types, timings and statuses are sent;--include-summariesadds a trimmed final message on request. The hook never blocks Claude Code: it writes nothing to stdout, always exits 0, and stops within 400 ms when the office is down.agent-viewer install claude-codeanduninstall claude-codeedit only<project>/.claude/settings.local.json, after showing the change and asking. Each handler sets a 1 secondtimeout, soSessionEndnever delays Claude Code's exit. Installing twice changes nothing and says so; the file is written atomically and only if it did not change while the prompt waited; uninstall keeps a settings file or.claude/folder that existed before install (a small record in~/.agent-viewernotes what install created). Guide: docs/claude-code.md. - Docker image on GitHub Container Registry, published by the release workflow on every
v*tag:ghcr.io/jmmana/agent-viewer(office and API in one container, new defaultapptarget ofdocker/Dockerfile) and the-apitags (API only). Multi-architecture (amd64, arm64), non-root, with a health check. Flags passed todocker run IMAGE ...are added to the defaults instead of replacing them, andPORTsets the port. The-apiimage never runs open: withoutAGENT_VIEWER_API_TOKENit generates a token on first start, saves it in/app/data/api-tokenand prints it. GHCR creates the package as private: after the first publish, the owner makes it public once (Package settings > Danger Zone > Change visibility); the release summary shows the visibility and warns while it is not public. - The demo app reads the API token from the URL fragment (
#token=...) or thetokenquery parameter, removes it from the address bar and the history entry right away, and keeps it in the tab'ssessionStorage.VITE_AGENT_VIEWER_API_URL=same-originstreams from the server that serves the page. - The live stream sends the token in an
Authorization: Bearerheader over a streamedfetch;EventSourcewith?token=is only the fallback for browsers that cannot stream withfetch. - Canonical event
llm.failedfor one failed model call attempt:provider,model,errorKind(one ofLLM_ERROR_KINDS, defaultunknown),httpStatus,retryable,requestId,providerErrorCodeandlatencyMs, plus tokens and cost only when the provider billed the attempt (none defaults to 0). It has no free-text error field on purpose. The server stores, lists and streams it and registers an unseen agent from it, but it never changes token or cost totals; the office only stores the agent's provider and model and never changes its status. Mapping guide in docs/integration.md. llm.usagefieldscacheReadTokens(tokens served from the prompt cache) andcacheWriteTokens(tokens written to it), both part ofinputTokens. Validation keeps them and rejects acacheReadTokens+cacheWriteTokenssum aboveinputTokens.- Library exports
LLM_ERROR_KINDS,isLlmErrorKindand the typeLlmErrorKind.CanonicalEventTypeincludes'llm.failed'. - SDK usage parameters: Python
usage()and the legacyllm_usage()take the keyword-onlycache_read_tokens,cache_write_tokens,currencyandtask_id(llm_usage()alsoreasoning_tokensandrequest_id); TypeScriptUsageOptionstakescacheReadTokens,cacheWriteTokens,currencyandtaskId, and the token fields,costSourceandcurrencyacceptnull.currencyis forwarded as given, with no default, andtask_id/taskIdgoes to the envelopetaskId. The TypeScript SDK exports theCostSourcetype, and its usage payload is typed against the contract, so a renamed contract field failsnpm run lint. - Server usage aggregates, computed call by call by one pure reducer (
server/usageAggregates.ts): everyllm.usagecall is added to the total, to its agent and to the(provider, model)of the call itself, so switching models never moves past calls. Failed calls (llm.failed) are counted apart underfailed. Unknown stays visible: each token kind has asumthat isnulluntil a call reports it, plusunreportedCount; cost is kept per(currency, costSource)pair in exact fixed point, withcostMissingCountandcurrencyMissingCountfor calls that cannot be priced. Nothing is priced, converted or summed across currencies. Guide: Usage aggregates. usageblock inGET /api/v1/snapshotand newGET /api/v1/usage, which returns the sameUsageSummarywithout the event list (same token and rate limits as the rest of/api/v1).- Ingestion integrity: every event gets a content
fingerprint(sha256:over the event as validated, keys sorted, defaults filled in, computed byserver/eventFingerprint.ts), so a repeated id is told apart by content. Same id and same content is a duplicate (200, as before); same id and different content is the new409 conflicting_duplicate, and that event is not stored, not aggregated and not streamed. The202,200and409bodies includefingerprint(andstoredFingerprintfor a conflict). POST /api/v1/eventsanswers400 idempotency_key_mismatchwhen theIdempotency-Keyheader and a non-empty bodyiddiffer, instead of silently ignoring the header. A header with no bodyidstill becomes the id.POST /api/v1/events/batchresults carrystatus(accepted,duplicateorconflict) andfingerprintper item, pluserrorandstoredFingerprintfor a conflict, and the response adds aconflictscount. The status stays202when validation passes, even if every item is a conflict. The generic webhook response addsduplicateCountandconflictCount.GET /readyreportsingestion: { conflicts, legacyUnverifiedDuplicates }since the process started, and each conflict writes onewarnlog line with the id, type, source, agent id and both fingerprints (never the payload).- SQLite migration 2 (
content-hash) addsevents.content_hashand backfills it fromevent_json. Rows written before 0.2.0, or with unreadableevent_json, keepNULL: a repeated id that matches one is treated as a duplicate, counted inlegacyUnverifiedDuplicatesand warned about once per process. - SDK
AgentViewerErrorhas acode(the response bodyerror): checkerr.code === 'conflicting_duplicate'in TypeScript orerr.code == "conflicting_duplicate"in Python. Neither SDK retries a 409. TypeScriptemitBatch()returns{ accepted, duplicates, conflicts, results }with each item'sstatus(conflictsis0against a 0.2.x server); Pythonemit_batch()returns the server response, which now has them too. - TypeScript SDK
usageSummary()and Python SDKusage_summary(). The TypeScript SDK typessnapshot()asPromise<ViewerSnapshot>and exportsUsageSummary,UsageAggregate,UsageBucket,ModelUsage,AgentUsage,TokenFigure,CurrencyCostandViewerSnapshot. docs/usage-semantics.mdanddocs/usage-semantics.es.md: the single binding reference for what a token or cost figure means at every layer (rules U1 to U5, thellm.usagefield reference, unknown versus zero, currencies,costSource, cache read versus write,llm.failed, deduplication, what a total covers, where each on-screen figure comes from, and how to reconcile against an invoice).
Changed
RealtimeStatusgains a new member,resyncing. Additive, but a host with an exhaustiveswitchover the union gets a TypeScript error and needs to handle it. Against a server without the new SSE frames (0.2.x),connectEventStreambehaves exactly as before.- A reconnect that misses more events than
AGENT_VIEWER_SSE_REPLAY_MAX(or whose cursor is unknown) no longer silently replays "the newest 100" events (wrong events entirely for an unknown cursor, a hidden hole in the middle of the history otherwise, issue #54). Gaps up to the limit are now replayed in full instead of capped at 100. A 0.2.x client that ignores the new named frames gets no warning about the gap; upgrade the helper to see it. GET /readyanswers503withRetry-After: 1and arebuildobject while a SQLite-backed server replays its stored events at startup, then200once the replay finishes.GET /healthkeeps answering200the whole time. While the rebuild runs,GET /api/v1/snapshot,GET /api/v1/runtimes,GET /api/v1/sessions,GET /api/v1/sessions/:id,POST /api/v1/agents,PATCH /api/v1/agents/:idandPOST /api/v1/runtimesanswer503 store_rebuilding;POST /api/v1/eventsand/events/batchstay open and are applied once the rebuild reaches them. Memory storage is unaffected:/readyanswers200immediately.PATCH /api/v1/agents/:agentIdnow emits anagent.updatedevent for any changed profile field (name, roleTitle, role, provider, model, workspace, statusText), not only for a status change.POST /api/v1/agentsandPOST /api/v1/runtimesnow put every resolved default in their event payload (agent.registered,runtime.connected), so the state a startup rebuild reaches from stored events matches the live response exactly. Event ids built by these three routes now include a random suffix, so two changes to the same id in the same millisecond are never deduplicated against each other. SQLite migration 4 (events-seq) adds a durable insertion-order column (events.seq), backfilled fromrowid; the startup rebuild and theafterIdcursor use it instead ofrowid, which SQLite can renumber onVACUUM.- Docker Compose now publishes the API and viewer ports on
127.0.0.1only. To restore network-wide publishing, change the mappings back to"8787:8787"and"3000:3000", updateAGENT_VIEWER_CORS_ORIGINandVITE_AGENT_VIEWER_API_URL, and put the services behind TLS. The plain server's listening log now accurately says it listens on every interface instead of suggestinglocalhost. - Model Ops' simulator types:
SimulatedTokenBurstandInferenceLogItemare replaced bySimulatedCall(src/engine/modelOps.ts), always carryingsimulated: true, anestimatedCost/currencythat arenullfor an uncatalogued model, and living outsideSimulationState(never persisted, exported or counted).calculateModelCostnow returnsnumber | nullinstead of always pricing against GPT-4o's rate. These are demo-app internals, not part of the published@warlockcode/agent-viewerlibrary API, so there is no semver impact on the package. - Webhook generic route now produces deterministic event IDs instead of random ones when an idempotency key is detected. IDs have format
evt_wh_<kind>_<32-char-hex>(SHA-256 based, do not reveal the key or signature) and match the same collision-free guarantee as server ids. Deliveries with no idempotency key get server-generated random ids and are marked assource: "none"(not safe to retry). - Webhook responses add
duplicate(true only when every event is a duplicate),duplicates(count of duplicate events),idempotency(object withsource:body,header,signature,requestId, ornone), and aresultsarray with per-eventduplicateflag. - Same delivery by signature now gets
200withduplicate: trueinstead of409 webhook_replay_detected, because the signature cache now records the delivery after store success, not before validation. - Webhook responses change conflict handling:
409 event_id_conflictis returned when any event conflicts (same id, different content),200when all are duplicates,202when at least one is new. Conflicting batches are stored atomically: all or nothing. expressis now a runtime dependency (the CLI runs the server from the installed package).dotenvstays a development dependency: the server loads.envonly when run directly.startServer(port, host?)accepts the interface to bind, andonEventAccepted(listener)reports every accepted event of the six ingestion paths (never a duplicate). A listener that throws or returns a rejected promise never breaks ingestion.--recordcreates its file readable only by the owner (mode 0600), stops the CLI before it listens when the path cannot be written, and on a write error mid-run (full disk) stops recording with one warning while the server keeps running.connectEventStream(library export) sendstokenin anAuthorization: Bearerheader over a streamedfetch, with thetokenquery parameter only as a fallback, and accepts afetchoption.- The package declares
engines.node >=22.13. - CI builds the CLI, runs it from the packed tarball and builds both Docker targets.
normalizeCanonicalEvent(used by the library and the generic webhook) no longer inventsinputTokens: 0oroutputTokens: 0forllm.usage, keepsllm.failedinstead of rewriting it toagent.status.changed, and sets an unlistedllm.failederrorKindtounknown.- Exhaustive
switchstatements overCanonicalEventTypein host TypeScript code must handle'llm.failed'. summarizeUsage(correctness fix, same types): it counts each eventidonce (the first occurrence wins, whatever its type or agent; events without an id are each counted, the same object only once), so repeated events from reconnects, retries or merged replays are no longer counted twice. A token count that an event does not report as a non-negative integer makes that figure unknown (null) instead of adding0, andtotalTokensis known only when input and output tokens are. The cost isnullwhen events report different ISO 4217 currencies, when an ISO currency is mixed with a cost without a valid currency (such as'usd'or none), or when a cost is negative, non-finite or text;currencyis set only when the cost is known. Some figures that were numbers are nownull, and some totals are lower because duplicates no longer count. Migration: if you relied on missing token counts being treated as 0, send explicit0values in yourllm.usageevents.- Example adapters (
examples/) no longer fill unknown token counts with0and no longer inferprovider-reportedfrom the presence of a cost. The OpenAI Agents, LangGraph and CrewAI adapters take the cost source as an argument (defaultunknown), and a cost of0is kept. llm.usagecalls without an agent (fromruntime:*,systemorexternal-runtime) now count intotalTokensand inusage.totalunderagentId: null; they were dropped before. Per-agent token fields are unchanged for well-formed events.- Server-generated event ids (agent registration,
PATCHstatus and profile changes, runtimes and the generic webhook) areevt_<kind>_<uuid>fromcrypto.randomUUID(), for exampleevt_reg_0b9f1c2e-..., instead of the agent or runtime id plusDate.now()(andMath.randomfor webhooks). Two requests in the same millisecond used to produce the same id: the second event was dropped as a duplicate but still streamed, so the office showed something the store never kept. Now each request stores and streams its own event, and a server-generated id that is not accepted is logged aterrorand answered with500 internal_id_collisioninstead of being hidden. Nothing parsed the old id format. - Default event ids are 128 random bits:
evt_<uuid>in the TypeScript SDK (was the clock plusMath.random),evt_<32 hex>in the Python SDK (was 12 hex characters, 48 bits), andevt_<uuid>fromnormalizeCanonicalEventwherecrypto.randomUUIDexists (older browsers keep the previous fallback). - A batch that repeats an id reports
duplicate: truefor the dropped copy; both copies used to reportduplicate: false. - With SQLite, a batch is stored all or nothing (
BEGIN IMMEDIATE ... COMMIT, rolled back on any error), and the in-memory aggregates are fed only after the commit, with the accepted events. A failure part way through used to leave a partial batch on disk. - SQLite migrations skip the pre-migration backup when the
eventstable has no rows yet. This also keeps a server that starts while another one is migrating a fresh file from writing a backup of it. - The server store no longer accepts usage figures outside stored events:
upsertAgentignorestokensInput,tokensOutput,cachedTokens,reasoningTokensandcost(its input type no longer has them), so usage fields sent toPOSTorPATCH /api/v1/agentsnever move a figure. See Breaking changes above for thePATCHallow-list itself.
Deprecated
llm.usagecachedTokens: sendcacheReadTokensinstead. It is still accepted, kept as sent and copied intocacheReadTokenswhen that field is absent; a different number in both fields is rejected atpayload.cachedTokens.- Snapshot
totalTokensandtotalCost, and theAgentRecordusage fieldstokensInput,tokensOutput,cachedTokens,reasoningTokensandcost: readusage(orGET /api/v1/usage) instead. The token fields are sums of reported values only, so they are lower bounds when some call did not report a kind. They will be removed in 1.0.
Fixed
- SQLite-backed server state (agents, runtimes, sessions, tasks, meetings and usage totals) no longer resets to empty on restart. At startup the server now replays every stored event, in insertion order, through the same reducer the live path uses, so the state after a restart is identical to the state before it. An upgrade that changes the reducer recomputes the whole history with the new reducer at the next startup; that is intended, not a bug.
- A retried
llm.usageorllm.failedevent (or any event id) that had already fallen off the memory-mode retained window is no longer double counted. Before this fix, eviction deleted the id from the dedup index too, so a late retry (a timeout retry, an SDK queue flush, a webhook redelivery) was accepted as new: its tokens and cost were added a second time to the agent and global totals, and it was broadcast again. The fix only changes behavior for an id that had already fallen out of the retained window; an ordinary retry of a still-retained event was already deduplicated correctly. - Model Ops no longer shows simulated calls as if they were real usage. The hard-coded "Live inference feed" seed rows (
Elena Rostova,Kenji Sato,Dr. Maya Chen) are gone, and every simulator trigger (header quick-burst, per-provider quick request, per-model simulate, the Simulator tab's fire button, feed "Send request") now only records aSimulatedCall, tagged with a visibleSIMULATED/SIMULADOchip; it never touches an agent's token or cost counters, the office totals, orstate.events(no more fakellm.usageevents). In live mode, the four buttons that used to inject fake usage into a real agent are not rendered; the Simulator tab stays available as a labelled what-if calculator with a persistent banner, and the Agents tab's model reassignment is disabled (the portal cannot change a remote agent's model). - A model the demo catalog does not know is no longer priced and labelled as GPT-4o.
calculateModelCostand the newgetModelSpechave no fallback: an uncatalogued model (for example a live agent's realclaude-sonnet-4-5) shows its own id, "Not in the demo catalog", and "Unknown" instead of GPT-4o's name, context window and per-1M rates. The Simulator tab shows "Rate: unknown" and "Estimated cost: Unknown" for it. - New Task no longer invents usage in live mode:
triggerCustomTaskSimulationtakes acountUsageoption, and the demo app passescountUsage: falsein live mode, so submitting the dialog creates the task without adding any token or cost figure to the target agent or to the office totals. Demo mode behavior (countUsage: true) is unchanged. - A v1 demo session (0.2.x) saved with a
evt-burst-*simulated event now migrates to v2 on load: the event is removed and its token/cost values are subtracted from the agent and from the office totals, clamped at 0. Sessions already evicted past the stored event cap cannot be reversed and keep their old counters, a known limitation. A v2 session loads unchanged. - Webhook usage retries no longer double-count. The generic webhook now uses deterministic event IDs derived from idempotency keys, so identical retries are deduplicated inside the store. Same delivery with same
Idempotency-Keyheader, bodyidempotencyKeyfield, or signature gets a200duplicate response instead of202new events. A sender that re-signs a retry needs to includeidempotencyKeyin the body for the same effect. - Webhook usage no longer drops
currency,costSource,requestIdand the cache fields. Validation now uses the sameLlmUsagePayloadSchemaas the events API, and these fields are kept exactly as sent.