Skip to content

v0.3.0

Choose a tag to compare

@github-actions github-actions released this 09 Oct 20:37
· 161 commits to main since this release
7d11280

[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 new GET /api/v1/usage) no longer carries only a bare totalTokens / totalCost. It now also returns usage: UsageSummary (server/usageAggregates.ts): per (currency, costSource) cost buckets, a sum/unreportedCount pair per token kind, and the same breakdown by agent and by model. totalTokens and totalCost are kept for one minor as deprecated projections (removed in 1.0): totalCost is a number only when every successful call reported a cost in exactly one (currency, costSource) pair, and is null otherwise, 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 (or GET /api/v1/usage) instead of totalTokens/totalCost; see docs/usage-semantics.md section 2 for every field.

  • PATCH /api/v1/agents/:agentId allow-list. Only descriptive profile and status fields are accepted (name, roleTitle, provider, model, status, statusText, workspace). tokensInput, tokensOutput, cachedTokens, reasoningTokens, cost and any other unlisted key are rejected with 400 validation_failed (issues[].path names the field), instead of silently changing spend. Migration: report usage with an llm.usage event through POST /api/v1/events, or the SDK usage() / agent.usage(...) helper.

  • Contract nullish fields. llm.usage (and llm.failed) no longer default cachedTokens or reasoningTokens to 0: an omitted or null value now stays null/absent instead of becoming a zero that looked like "the provider reported zero". New cacheReadTokens and cacheWriteTokens replace the deprecated cachedTokens (kept as an alias; a differing value in both is rejected). New llm.failed event 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": 0 explicitly.

    Migration: readers of stored or exported events must handle null/absent as "unknown", not "zero".

  • SDK costSource default. Passing a cost with no costSource no longer sends "provider-reported"; it now sends "unknown" and prints one warning per client instance. currency and a task id are newly accepted. Migration: pass cost_source="provider-reported" (Python) or costSource: 'provider-reported' (TypeScript) when the cost really came from the provider, and pass currency.

  • Totals no longer include PATCH amounts or unknown-as-zero. A figure the 0.2.x snapshot showed (built from PATCH-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. usage blocks without required fields (provider, model, inputTokens, outputTokens) are rejected with 400 validation_failed. Empty usage objects ({}) are now rejected, not accepted as zero-filled events. Usage with an unknown costSource is accepted as an unknown cost 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 duplicate and now gets 409 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 answering 202 and report the item as status: "conflict".

  • Raw HTTP clients that retry by rebuilding the body with a fresh timestamp now get 409 on the retry, because timestamp is 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 gets 409. 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 timestamp of each hook run, so a hook that fires twice (installed in both user and project settings) now reports the second copy as conflict instead of duplicate. 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.failed and drops cacheReadTokens and cacheWriteTokens.

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 (default 10000, 0 means 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 named resync frame (reason: cursor_unknown, gap_too_large or buffer_overflow, missed as a count or null when truly unknown, never 0) 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 named replayed frame (replayed, lastEventId). GET /health gains sse: { replayMax, replaysSinceStart, eventsReplayedSinceStart, resyncsSinceStart }. connectEventStream gains lastEventId (resume from a cursor, typically snapshot.lastEventId), onResync and onReplayed options, a new resyncing RealtimeStatus, and resyncCount() on the returned connection. The demo app rebuilds its live office from GET /api/v1/snapshot on a resync through the new rebuildFromSnapshot helper (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 repeated requestId, a failed call) run through the memory store, the SQLite store across a restart, the portal reducer and the embeddable library's summarizeUsage, 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 the PATCH rejection path), memory eviction, and a library-privacy Vitest suite; none are blocked on a missing dependency, see the fixture's README.md.
  • agent-viewer install claude-code --telemetry points 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 an otelHeadersHelper command that looks the running office's token up on every export; static mode (--telemetry --token <token>) writes a fixed OTEL_EXPORTER_OTLP_LOGS_HEADERS instead, for an office on another machine or in Docker, with the token always masked as <token hidden> in the diffs the installer prints. The five OTEL_LOG_* content flags are always pinned to "0", and no metrics or traces exporter is ever configured. install refuses 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-telemetry removes only the telemetry block, keeping the hooks; uninstall claude-code removes both, byte for byte, the same way the hooks round-trip today. New agent-viewer otel-headers --url <office-url> helper command (used by otelHeadersHelper, 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 the POST /v1/logs receiver 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: turns claude_code.api_request and claude_code.api_error into llm.usage/llm.failed on 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 carries Retry-After); its own body parser accepts gzip, deflate and brotli up to AGENT_VIEWER_OTLP_MAX_BODY (default 5mb) and AGENT_VIEWER_OTLP_MAX_RECORDS (default 5000) 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/). New GET /api/v1/otlp/stats counters (received, mapped, duplicates, ignored, unknown, unattributed, invalid, by type). Added network to the LlmErrorKind vocabulary (issue #46) for a Claude Code failure with no HTTP response at all, and made llm.failed.model optional (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 answered 200 { duplicate: true } instead of being counted again. The retained window is now a configurable ring buffer, AGENT_VIEWER_MAX_EVENTS (default 10000, must be a positive integer; an invalid value stops the server at startup). GET /api/v1/snapshot, GET /api/v1/events and the SSE heartbeat now carry a retention block (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, suggesting AGENT_VIEWER_STORAGE=sqlite for long-running servers.
  • The plain server warns at startup when /api/v1 is open (no AGENT_VIEWER_API_TOKEN), naming the port, the bind address and whether webhooks are open too. /health reports the current auth (token or open) and webhookAuth (signature, token or open) modes, computed per request and never including the token. The live portal shows a persistent, localized banner and an OPEN API indicator next to the LIVE badge 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 for llm.usage and llm.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; requestId matches exactly after trimming; a missing or blank requestId behaves exactly as before. llm.usage and llm.failed share 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 (duplicateOf points 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 on Last-Event-ID, and is excluded from GET /api/v1/events. POST /api/v1/events and /api/v1/events/batch responses add duplicateReason (event_id or request_id), submittedId (only when it differs from id) and matchesOriginal (only for a request_id duplicate: whether the usage-relevant fields, including every token field, cost, currency and costSource, match the original; null equals null, but null never equals 0). A mismatch logs one warning with both event ids, the provider and a truncated request id, never payload text. New read-only GET /api/v1/usage/duplicates lists duplicate references (limit, provider, requestId, duplicateOf filters), behind the same /api/v1 auth and rate limiting. GET /api/v1/snapshot gains usageDuplicates: { count, mismatched, unverified }. SQLite migration 3 (request-key-dedup) adds request_provider, request_id, duplicate_of and matches_original to events, backfills them from the stored payload, and marks pre-existing same-key rows as duplicates of the earliest one (matches_original stays NULL for 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-viewer command, 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 on 127.0.0.1 by default (--host or AGENT_VIEWER_HOST; port from --port or PORT), generates a session token unless --token (or a non-blank AGENT_VIEWER_API_TOKEN, or the deprecated AGENT_VIEWER_API_KEY) is given, so it never runs without one, prints the office URL, the token and a ready curl command, and opens the browser. Options --port, --host, --token, --demo, --no-open and --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 in engines. 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-hook translates 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-summaries adds 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-code and uninstall claude-code edit only <project>/.claude/settings.local.json, after showing the change and asking. Each handler sets a 1 second timeout, so SessionEnd never 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-viewer notes 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 default app target of docker/Dockerfile) and the -api tags (API only). Multi-architecture (amd64, arm64), non-root, with a health check. Flags passed to docker run IMAGE ... are added to the defaults instead of replacing them, and PORT sets the port. The -api image never runs open: without AGENT_VIEWER_API_TOKEN it generates a token on first start, saves it in /app/data/api-token and 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 the token query parameter, removes it from the address bar and the history entry right away, and keeps it in the tab's sessionStorage. VITE_AGENT_VIEWER_API_URL=same-origin streams from the server that serves the page.
  • The live stream sends the token in an Authorization: Bearer header over a streamed fetch; EventSource with ?token= is only the fallback for browsers that cannot stream with fetch.
  • Canonical event llm.failed for one failed model call attempt: provider, model, errorKind (one of LLM_ERROR_KINDS, default unknown), httpStatus, retryable, requestId, providerErrorCode and latencyMs, 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.usage fields cacheReadTokens (tokens served from the prompt cache) and cacheWriteTokens (tokens written to it), both part of inputTokens. Validation keeps them and rejects a cacheReadTokens + cacheWriteTokens sum above inputTokens.
  • Library exports LLM_ERROR_KINDS, isLlmErrorKind and the type LlmErrorKind. CanonicalEventType includes 'llm.failed'.
  • SDK usage parameters: Python usage() and the legacy llm_usage() take the keyword-only cache_read_tokens, cache_write_tokens, currency and task_id (llm_usage() also reasoning_tokens and request_id); TypeScript UsageOptions takes cacheReadTokens, cacheWriteTokens, currency and taskId, and the token fields, costSource and currency accept null. currency is forwarded as given, with no default, and task_id / taskId goes to the envelope taskId. The TypeScript SDK exports the CostSource type, and its usage payload is typed against the contract, so a renamed contract field fails npm run lint.
  • Server usage aggregates, computed call by call by one pure reducer (server/usageAggregates.ts): every llm.usage call 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 under failed. Unknown stays visible: each token kind has a sum that is null until a call reports it, plus unreportedCount; cost is kept per (currency, costSource) pair in exact fixed point, with costMissingCount and currencyMissingCount for calls that cannot be priced. Nothing is priced, converted or summed across currencies. Guide: Usage aggregates.
  • usage block in GET /api/v1/snapshot and new GET /api/v1/usage, which returns the same UsageSummary without 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 by server/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 new 409 conflicting_duplicate, and that event is not stored, not aggregated and not streamed. The 202, 200 and 409 bodies include fingerprint (and storedFingerprint for a conflict).
  • POST /api/v1/events answers 400 idempotency_key_mismatch when the Idempotency-Key header and a non-empty body id differ, instead of silently ignoring the header. A header with no body id still becomes the id.
  • POST /api/v1/events/batch results carry status (accepted, duplicate or conflict) and fingerprint per item, plus error and storedFingerprint for a conflict, and the response adds a conflicts count. The status stays 202 when validation passes, even if every item is a conflict. The generic webhook response adds duplicateCount and conflictCount.
  • GET /ready reports ingestion: { conflicts, legacyUnverifiedDuplicates } since the process started, and each conflict writes one warn log line with the id, type, source, agent id and both fingerprints (never the payload).
  • SQLite migration 2 (content-hash) adds events.content_hash and backfills it from event_json. Rows written before 0.2.0, or with unreadable event_json, keep NULL: a repeated id that matches one is treated as a duplicate, counted in legacyUnverifiedDuplicates and warned about once per process.
  • SDK AgentViewerError has a code (the response body error): check err.code === 'conflicting_duplicate' in TypeScript or err.code == "conflicting_duplicate" in Python. Neither SDK retries a 409. TypeScript emitBatch() returns { accepted, duplicates, conflicts, results } with each item's status (conflicts is 0 against a 0.2.x server); Python emit_batch() returns the server response, which now has them too.
  • TypeScript SDK usageSummary() and Python SDK usage_summary(). The TypeScript SDK types snapshot() as Promise<ViewerSnapshot> and exports UsageSummary, UsageAggregate, UsageBucket, ModelUsage, AgentUsage, TokenFigure, CurrencyCost and ViewerSnapshot.
  • docs/usage-semantics.md and docs/usage-semantics.es.md: the single binding reference for what a token or cost figure means at every layer (rules U1 to U5, the llm.usage field 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

  • RealtimeStatus gains a new member, resyncing. Additive, but a host with an exhaustive switch over the union gets a TypeScript error and needs to handle it. Against a server without the new SSE frames (0.2.x), connectEventStream behaves 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 /ready answers 503 with Retry-After: 1 and a rebuild object while a SQLite-backed server replays its stored events at startup, then 200 once the replay finishes. GET /health keeps answering 200 the 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/:id and POST /api/v1/runtimes answer 503 store_rebuilding; POST /api/v1/events and /events/batch stay open and are applied once the rebuild reaches them. Memory storage is unaffected: /ready answers 200 immediately.
  • PATCH /api/v1/agents/:agentId now emits an agent.updated event for any changed profile field (name, roleTitle, role, provider, model, workspace, statusText), not only for a status change. POST /api/v1/agents and POST /api/v1/runtimes now 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 from rowid; the startup rebuild and the afterId cursor use it instead of rowid, which SQLite can renumber on VACUUM.
  • Docker Compose now publishes the API and viewer ports on 127.0.0.1 only. To restore network-wide publishing, change the mappings back to "8787:8787" and "3000:3000", update AGENT_VIEWER_CORS_ORIGIN and VITE_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 suggesting localhost.
  • Model Ops' simulator types: SimulatedTokenBurst and InferenceLogItem are replaced by SimulatedCall (src/engine/modelOps.ts), always carrying simulated: true, an estimatedCost/currency that are null for an uncatalogued model, and living outside SimulationState (never persisted, exported or counted). calculateModelCost now returns number | null instead of always pricing against GPT-4o's rate. These are demo-app internals, not part of the published @warlockcode/agent-viewer library 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 as source: "none" (not safe to retry).
  • Webhook responses add duplicate (true only when every event is a duplicate), duplicates (count of duplicate events), idempotency (object with source: body, header, signature, requestId, or none), and a results array with per-event duplicate flag.
  • Same delivery by signature now gets 200 with duplicate: true instead of 409 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_conflict is returned when any event conflicts (same id, different content), 200 when all are duplicates, 202 when at least one is new. Conflicting batches are stored atomically: all or nothing.
  • express is now a runtime dependency (the CLI runs the server from the installed package). dotenv stays a development dependency: the server loads .env only when run directly.
  • startServer(port, host?) accepts the interface to bind, and onEventAccepted(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.
  • --record creates 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) sends token in an Authorization: Bearer header over a streamed fetch, with the token query parameter only as a fallback, and accepts a fetch option.
  • 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 invents inputTokens: 0 or outputTokens: 0 for llm.usage, keeps llm.failed instead of rewriting it to agent.status.changed, and sets an unlisted llm.failed errorKind to unknown.
  • Exhaustive switch statements over CanonicalEventType in host TypeScript code must handle 'llm.failed'.
  • summarizeUsage (correctness fix, same types): it counts each event id once (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 adding 0, and totalTokens is known only when input and output tokens are. The cost is null when 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; currency is set only when the cost is known. Some figures that were numbers are now null, and some totals are lower because duplicates no longer count. Migration: if you relied on missing token counts being treated as 0, send explicit 0 values in your llm.usage events.
  • Example adapters (examples/) no longer fill unknown token counts with 0 and no longer infer provider-reported from the presence of a cost. The OpenAI Agents, LangGraph and CrewAI adapters take the cost source as an argument (default unknown), and a cost of 0 is kept.
  • llm.usage calls without an agent (from runtime:*, system or external-runtime) now count in totalTokens and in usage.total under agentId: null; they were dropped before. Per-agent token fields are unchanged for well-formed events.
  • Server-generated event ids (agent registration, PATCH status and profile changes, runtimes and the generic webhook) are evt_<kind>_<uuid> from crypto.randomUUID(), for example evt_reg_0b9f1c2e-..., instead of the agent or runtime id plus Date.now() (and Math.random for 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 at error and answered with 500 internal_id_collision instead 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 plus Math.random), evt_<32 hex> in the Python SDK (was 12 hex characters, 48 bits), and evt_<uuid> from normalizeCanonicalEvent where crypto.randomUUID exists (older browsers keep the previous fallback).
  • A batch that repeats an id reports duplicate: true for the dropped copy; both copies used to report duplicate: 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 events table 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: upsertAgent ignores tokensInput, tokensOutput, cachedTokens, reasoningTokens and cost (its input type no longer has them), so usage fields sent to POST or PATCH /api/v1/agents never move a figure. See Breaking changes above for the PATCH allow-list itself.

Deprecated

  • llm.usage cachedTokens: send cacheReadTokens instead. It is still accepted, kept as sent and copied into cacheReadTokens when that field is absent; a different number in both fields is rejected at payload.cachedTokens.
  • Snapshot totalTokens and totalCost, and the AgentRecord usage fields tokensInput, tokensOutput, cachedTokens, reasoningTokens and cost: read usage (or GET /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.usage or llm.failed event (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 a SimulatedCall, tagged with a visible SIMULATED / SIMULADO chip; it never touches an agent's token or cost counters, the office totals, or state.events (no more fake llm.usage events). 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. calculateModelCost and the new getModelSpec have no fallback: an uncatalogued model (for example a live agent's real claude-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: triggerCustomTaskSimulation takes a countUsage option, and the demo app passes countUsage: false in 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-Key header, body idempotencyKey field, or signature gets a 200 duplicate response instead of 202 new events. A sender that re-signs a retry needs to include idempotencyKey in the body for the same effect.
  • Webhook usage no longer drops currency, costSource, requestId and the cache fields. Validation now uses the same LlmUsagePayloadSchema as the events API, and these fields are kept exactly as sent.