A multi-agent run is a conversation you cannot see. The planner delegates, the coder calls tools, the reviewer objects, two agents meet and settle on a decision, and all you get is a wall of JSON scrolling past. Something blocks, and you go hunting through logs to find out who was waiting on whom.
Agent Viewer turns that event stream into a living office. Every agent gets a desk, a status and a name card. Tool calls show up as they happen. Messages become speech bubbles headed by what they do: PROPOSES, OBJECTS, AGREES, DECIDES. Meetings fill a room and leave decisions behind. Who is working, who is blocked and who is talking to whom: one look.
It is open source (MIT), runs on your machine, needs no model API key, and drops into any React 19 app as a single component.
🎯 What you get
| Piece | What it does |
|---|
| 🏢 Living office | React + Canvas2D scene with 9 rooms. Agents walk to their desks and to meeting rooms, show their status and the tool they use, and talk in speech bubbles headed by the kind of message. Rotate, fit, pan, zoom and click to select. |
| 🧩 Embeddable library | @warlockcode/agent-viewer: <AgentOffice events={events} /> in any React 19 app. Professional mode by default, isolated av- CSS, English and Spanish built in, replay controls and video export. Optional, opt-in per-agent usage badges, a per-call detail panel and meeting spend figures, fed only by host props: the component never computes, sums or prices anything. |
| 📡 Ingestion server | Express API on port 8787: single and batch ingestion with idempotency, a Server-Sent Events stream that resumes from the last event, a generic webhook with HMAC signatures, in-memory or SQLite storage. |
| 🐍 SDKs and adapters | Python and TypeScript clients, LangGraph and OpenAI Agents SDK adapter packages (tested in CI, not yet on npm), plus example adapters for CrewAI, AutoGen and Google ADK. |
| 🎬 Replay and export | Load a JSONL V1 log, replay it at its original pace with seek and speed, and record it to WebM or MP4 in the browser. |
| 📊 Model Ops console | In the demo app: Matrix, Agents and Feed read the server's usage ledger (GET /api/v1/usage/rollup and /usage/calls), so figures reconcile call by call; a value the ledger never reported shows as "n/a", never as zero. Each model card also shows measured latency percentiles (p50/p95, nearest-rank, from real reported latencyMs values only) and failure rate with the sample size next to every figure (n = 1,102, 78 without latency); with no samples it reads "No latency reported" and with no attempts "No data", never 0. A separate Simulator tab stays, clearly labelled, with no effect on real data. Two more tabs manage server-owned state directly: Pricing edits the versioned price table (GET/POST /api/v1/pricing*) with per-row save, an add-model form and a version-history drawer; Budgets lists, creates, edits and deletes server-owned budgets (GET/POST/PUT/DELETE /api/v1/budgets*), with each one's live spent/ratio/state read straight from the server. Neither tab computes a price or a budget verdict in the browser: prices and limits live on the server, never invented or recomputed by the portal. Without a connected server, Model Ops falls back to a labelled, simulated view over the local demo state, and the Pricing/Budgets tabs show a demo-mode explanation instead of editable fields. |
🧠 Principles
These are design rules, enforced in code, not marketing:
- Your runtime is the source of truth. Agent Viewer projects events; it never decides what an agent is doing. Animation never overwrites a real work status (
IDLE, THINKING, CODING...).
- Observable only. Statuses, tool names, explicit messages, meetings and reported usage. Private chain-of-thought is never required and never shown.
- Nothing invented in professional mode. The embedded office has no ambient life, no invented lines, no sounds. Showcase mode adds simulated office life, and every simulated bubble says so (
SOCIAL · SIMULATED).
- Unknown is not zero. The component never computes, adds up or prices usage. A missing cost is shown as "unknown", never as
0. Meeting figures (meetingUsage) follow the exact same rule: they come from your host, never from the deprecated Meeting.tokensAccumulated/costAccumulated.
- A good guest in your app. No injected styles, no global selectors, no
localStorage, no global keyboard shortcuts, nothing runs on import. Two offices on one page never share state.
- Unknown is not zero, anywhere. The server, the portal, the CLI and the SDKs never store or show an unknown figure as zero and never add up different currencies. Unknown stays unknown for budgets too: a budget with an uncountable call is
unknown, never a guessed ok (docs/budgets.md). Full field-by-field semantics: docs/usage-semantics.md (español).
What it is not: a tracing backend, a log store or an APM replacement. POST /v1/traces converts GenAI spans into events and does not store spans or offer a trace explorer; parseEventLog can replay a saved OTLP JSON trace export through that same mapper, but neither path is a tracing backend. It does not call model providers. The LangGraph and OpenAI Agents SDK adapters are real workspace packages tested against those frameworks in CI but not yet published to npm; the CrewAI, AutoGen and Google ADK adapters are still examples to adapt. See the maturity table.
⚡ Quickstart
One command
npx @warlockcode/agent-viewer
Starts the ingestion server and the office together on http://127.0.0.1:8787, opens your browser and prints the session token and a curl command you can paste to make your first agent appear. Then, without JSON:
npx @warlockcode/agent-viewer send --agent demo --status working --message "Hello"
Options (--port, --host, --token, --demo, --no-open, --record run.jsonl) are in the CLI guide. To run the Docker image on this machine:
docker run --rm -p 127.0.0.1:8787:8787 \
-e AGENT_VIEWER_API_TOKEN="$(openssl rand -base64 32)" \
-v agent-viewer-data:/app/data \
ghcr.io/jmmana/agent-viewer
Drop 127.0.0.1: only to reach it from other machines, and put it behind TLS. Flags after the image name are added to its defaults; image tags and tokens are in the CLI guide.
Watch Claude Code work
npx @warlockcode/agent-viewer install claude-code # in your project, shows the change and asks first
Your Claude Code sessions and their subagents appear in the office: tools, handoffs, and when Claude waits for you. Only tool names, agent types, timings and statuses are sent, never arguments, prompts, code or paths. Token and cost telemetry is opt-in, through install claude-code --telemetry; without it, the promise above holds exactly as written. Setup, privacy and uninstall: docs/claude-code.md.
From the repository
Working on the repository requires Node.js 24 or later. Apps that only install the library need React 19, nothing else.
git clone https://github.com/jmmana/Agent-Viewer.git
cd Agent-Viewer
npm ci
npm run dev:full # ingestion server on :8787 + office on :3000 in live mode
| Command | Open | What you get |
|---|
npm run dev:full | http://localhost:3000 | Live mode: an empty office (no seeded agents, no synthetic tokens) streaming from the server on port 8787. Events you send appear right away. |
npm run dev | http://localhost:3000 | The demo: the full living office with a simulated team and simulated data. Press Space (or Play) to run the scripted demonstration. Add ?mode=live (or set VITE_AGENT_VIEWER_MODE=live) to switch it to live mode. |
Send your first events
With npm run dev:full running, send these from another terminal and watch them land:
# 1. A planner joins the office and sits in Architecture
curl -X POST http://localhost:8787/api/v1/events \
-H "Content-Type: application/json" \
-d '{
"schemaVersion": "1.0",
"id": "evt_hello_1",
"type": "agent.registered",
"timestamp": 1767225600000,
"source": "cli",
"agentId": "nova",
"summary": "Nova joined the office",
"payload": { "name": "Nova", "roleTitle": "Planner", "workspace": "leads_area" }
}'
# 2. Nova proposes something: a speech bubble headed "PROPOSES"
curl -X POST http://localhost:8787/api/v1/events \
-H "Content-Type: application/json" \
-d '{
"schemaVersion": "1.0",
"id": "evt_hello_2",
"type": "agent.message.sent",
"timestamp": 1767225601000,
"source": "cli",
"agentId": "nova",
"summary": "Nova proposes a plan",
"payload": { "text": "Ship the parser first, then the exporter.", "kind": "proposal" }
}'
No envelope at hand? The generic webhook takes a flat body and builds the events for you:
curl -X POST http://localhost:8787/api/v1/webhooks/generic \
-H "Content-Type: application/json" \
-d '{ "agent": "atlas", "status": "coding", "message": "Writing the parser", "tool": "editor" }'
Or with Docker
docker compose -f docker/compose.yml up --build
This starts the API on :8787 with SQLite on a named volume, and the built demo on :3000. Compose publishes both ports on 127.0.0.1 only. To reach the office from other machines, restore the port mappings to "8787:8787" and "3000:3000", update AGENT_VIEWER_CORS_ORIGIN and VITE_AGENT_VIEWER_API_URL, and put the services behind TLS. The API never runs without a token: set AGENT_VIEWER_API_TOKEN before up to choose it, or read the one it generates with docker compose -f docker/compose.yml logs api. Then open http://localhost:3000/?mode=live#token=<token> to watch the stream (the office removes the token from the address bar once it has read it).
Maintainers: GitHub Container Registry creates the ghcr.io/jmmana/agent-viewer package as private, and the release workflow cannot change that. After the first release that publishes it, make it public once in the package page: Package settings > Danger Zone > Change visibility > Public. The release run summary shows the current visibility.
🧩 Embed the office in your React app
Agent Viewer is also a React library: @warlockcode/agent-viewer 0.8.0. ES modules only, React and React DOM 19 as peer dependencies, express, lucide-react and zod as runtime dependencies (express runs the server of the agent-viewer command; the library modules never import it), TypeScript declarations included.
Publication on npm is coming soon. Until then, install it from the GitHub release asset (the package name and your imports stay the same when you switch to npm):
npm install https://github.com/jmmana/Agent-Viewer/releases/download/v0.8.0/warlockcode-agent-viewer-0.8.0.tgz
Minimal example
import { AgentOffice, type AgentProfile, type OfficeEventInput } from '@warlockcode/agent-viewer';
import '@warlockcode/agent-viewer/style.css';
// Defined outside the component, so the arrays keep the same identity on every render.
const agents: AgentProfile[] = [
{ id: 'planner', name: 'Nova', roleTitle: 'Planner', workspace: 'leads_area', avatarColor: '#38bdf8' },
{ id: 'builder', name: 'Atlas', roleTitle: 'Builder', workspace: 'development', avatarColor: '#f472b6' },
];
const events: OfficeEventInput[] = [
{
schemaVersion: '1.0',
id: 'evt-001',
type: 'agent.status.changed',
timestamp: 1767225600000,
source: 'agent:builder',
agentId: 'builder',
summary: 'Builder started coding',
payload: { status: 'CODING', statusText: 'Writing the parser' },
},
{
schemaVersion: '1.0',
id: 'evt-002',
type: 'agent.message.sent',
timestamp: 1767225601000,
source: 'agent:planner',
agentId: 'planner',
summary: 'Planner proposes an order',
payload: { text: 'Ship the parser first, then the exporter.', kind: 'proposal', targetAgentId: 'builder' },
},
];
export function TeamOffice() {
return (
<div style={{ height: 520 }}>
<AgentOffice agents={agents} events={events} locale="en" theme="dark" />
</div>
);
}
Atlas appears at a desk in Engineering with the status "Coding". Nova appears in Architecture with a speech bubble headed Nova → Atlas · PROPOSES for 6.5 seconds. The office fills its container (at least 320 px tall), so give the container a height.
Live events
<AgentOffice> is controlled by events: append each new event and pass the new array. Events can come from anywhere (your WebSocket, a store, a polling loop). connectEventStream is a helper for the SSE stream of an Agent Viewer server:
import { useEffect, useState } from 'react';
import { AgentOffice, connectEventStream, type OfficeEventInput } from '@warlockcode/agent-viewer';
export function LiveOffice() {
const [events, setEvents] = useState<OfficeEventInput[]>([]);
useEffect(() => {
const connection = connectEventStream('http://localhost:8787', (event) => {
setEvents((list) => [...list, event]);
});
return () => connection.close();
}, []);
return (
<div style={{ height: 600 }}>
<AgentOffice events={events} />
</div>
);
}
Give every event a stable, unique id: the office ignores ids it has already applied. Pass a new array when events change (mutating in place is not detected), and keep agents, messages and t stable between renders.
Two modes
| Mode | What the office shows |
|---|
professional (default) | Only what the events say. No ambient life, no invented lines, no hidden second floor, no sounds. When a meeting is requested, participants just walk to the room. |
showcase | Adds simulated office life for demos: idle agents go for coffee and chat in short simulated conversations, headed SOCIAL · SIMULATED. They never change an agent's work status. |
Dark or light, English or Spanish
Every visible text comes from a catalog of 116 keys. Rename a room with messages, or plug in i18next or FormatJS with t. The --av-* CSS custom properties restyle the toolbar and panels with zero specificity.
Accessible by default
- A live, polite list of every agent with its role and status. It opens when it receives keyboard focus (Tab), and each agent is a button that selects it and moves the camera to it.
- A labeled canvas, a real toolbar with named buttons and a visible focus ring.
- With
prefers-reduced-motion: reduce, nothing animates: agents move without walking.
| Area | Values |
|---|
| Office | AgentOffice |
| Office model without React | OfficeStore, buildOfficeSnapshot |
| Replay | useEventReplay, ReplayControls |
| Usage (display only) | formatUsage, formatTokens, formatCost, summarizeUsage |
| Budget state (display only) | normalizeBudgetState, formatBudgetState, MAX_BUDGET_LABEL_LENGTH |
| Texts | OFFICE_MESSAGES, createOfficeTranslator, formatMessage, builtInMessages, isOfficeMessageKey |
| Event contract V1 | SCHEMA_VERSION, CANONICAL_EVENT_TYPES, EVENT_TYPE_ALIASES, MESSAGE_KINDS, isMessageKind, LLM_ERROR_KINDS, isLlmErrorKind, normalizeCanonicalEvent, validateCanonicalEvent |
| Live stream | connectEventStream |
| Log files | parseEventLog, MAX_EVENT_LOG_SIZE_BYTES |
| Video | recordReplay, computeReplaySchedule, isRecordingSupported, getSupportedMimeType |
Types for all of them are included. The stylesheet is @warlockcode/agent-viewer/style.css.
useEventReplay plays a run at its own pace and returns the visible slice. It only reveals events; it never creates any.
import { AgentOffice, ReplayControls, useEventReplay, type OfficeEventInput } from '@warlockcode/agent-viewer';
export function RunReplay({ run }: { run: readonly OfficeEventInput[] }) {
const replay = useEventReplay(run, { speed: 2, maxGapMs: 3000 });
return (
<div style={{ display: 'grid', gridTemplateRows: '1fr auto', gap: 8, height: 600 }}>
<AgentOffice events={replay.events} />
<ReplayControls replay={replay} speeds={[1, 2, 4, 8]} />
</div>
);
}
Load a JSONL V1 log with parseEventLog(file) (up to 25 MB, invalid lines reported without stopping) and pass result.events as the run. Export it to video with recordReplay({ events, speed: 4 }), which draws on its own offscreen canvas.
import { useMemo } from 'react';
import { AgentOffice, type OfficeEventInput, type OfficeUsage } from '@warlockcode/agent-viewer';
export function OfficeWithUsage({ events }: { events: readonly OfficeEventInput[] }) {
const usage = useMemo<OfficeUsage>(() => ({
total: { totalTokens: 18400, inputTokens: 15200, outputTokens: 3200, cost: 0.42, currency: 'USD' },
byAgent: {
planner: { totalTokens: 6100, cost: null },
builder: { totalTokens: 12300, cost: 0.42, currency: 'USD' },
},
}), []);
return (
<div style={{ height: 520 }}>
<AgentOffice events={events} showUsage usage={usage} />
</div>
);
}
showUsage is off by default. A missing value is shown as "unknown", never as zero. No usage service? summarizeUsage(events) is an explicit opt-in that only adds up what llm.usage events reported: it ignores repeated event ids, keeps a token count unknown when an event does not report it, and returns an unknown cost rather than a partial sum or a sum of mixed or missing currencies.
Want the spend visible on the canvas itself, not only in the hidden agent list? Add showUsageBadges (also off by default, independent of showUsage):
<AgentOffice events={events} usage={usage} showUsageBadges />
Each visible agent card with a usage.byAgent entry gets a compact badge (tokens, cost, an est. mark when costSource is 'estimated', and a failed-call chip); an agent with no entry gets no badge. The badge is display only: every figure still comes from usage, and UsageFigures now also carries cacheReadTokens, cacheWriteTokens, reasoningTokens, costSource and failedCalls for hosts that have them.
Want to answer "which calls made up that number" for one agent? Add showCallDetails with agentCallDetails (off by default, independent of showUsage/showUsageBadges):
<AgentOffice events={events} showCallDetails agentCallDetails={agentCallDetails} />
Clicking an agent opens a read-only panel of its calls (provider, model, tokens by kind, request id, latency, status, cost source), exactly as agentCallDetails[agentId] reports them: the office never fetches, sums, prices or sorts any of it, and AgentCallDetail has no field that can carry a prompt, a completion or any other free text.
Want to answer "what did this meeting cost"? Add meetingUsage, keyed by meeting id (shown only alongside showUsage; see Meeting figures):
<AgentOffice events={events} showUsage usage={usage} meetingUsage={meetingUsage} />
import { useMemo } from 'react';
import { AgentOffice, type AgentBudgetState } from '@warlockcode/agent-viewer';
const budgetStateByAgent = useMemo<Record<string, AgentBudgetState>>(() => ({
planner: { state: 'warning', label: 'Daily budget at 82%' },
builder: { state: 'exceeded' },
reviewer: { state: 'unknown' },
}), []);
<AgentOffice events={events} showBudget budgetStateByAgent={budgetStateByAgent} />
showBudget is off by default. The office never computes a budget: it never holds a limit, never receives a spend figure and never compares one against the other, so AgentBudgetState has only state ('ok' | 'warning' | 'exceeded' | 'unknown') and an optional already-translated label, nothing numeric. ok draws no marker (quiet when healthy); warning an amber triangle; exceeded a red octagon; unknown (or any invalid value) a grey circle, never ok. Shape and glyph carry the state, not only color. An agent id absent from the map shows no marker and no text: absence is never read as ok.
Full guide: props, event effects, workspaces, translations, theming, usage rules, replay, video export and the store without React are in the library guide (español).
🏢 Inside the office
Agents sit in the room named by their workspace. Rooms are stable API values; their labels are translated.
workspace | Room | Good for |
|---|
boss_office | DIRECTOR SUITE | Orchestration, escalation and decisions. Fallback meeting room. |
leads_area | ARCHITECTURE | Planning, delegation and technical review. |
development | ENGINEERING | Coding, implementation and tool execution. |
qa_lab | QA LAB | Testing and validation. |
research_area | RESEARCH LIBRARY | Research, retrieval and document analysis. |
server_room | MODEL OPS | Provider, model and token telemetry. |
meeting_room | MEETING ROOM A | Meetings, 4 seats. |
meeting_room_b | MEETING ROOM B | Overflow meetings, 4 seats. |
break_room | ESPRESSO BAR | Idle time. Coffee and small talk in showcase mode. |
Speech bubbles say what a message does. Pass a kind in agent.message.sent (or type in meeting.message) and the bubble reads Speaker → Target · HEADER:
| Kind | English header | Spanish header |
|---|
statement | SAYS | DICE |
proposal | PROPOSES | PROPONE |
question | ASKS | PREGUNTA |
answer | ANSWERS | RESPONDE |
objection | OBJECTS | OBJETA |
critique | CRITIQUES | CRITICA |
agreement | AGREES | ACUERDA |
summary | SUMMARIZES | RESUME |
decision | DECIDES | DECIDE |
The office never guesses a kind. A decision in a meeting is also recorded in the meeting decisions.
OFFLINE, IDLE, AVAILABLE, THINKING, READING, RESEARCHING, CODING, WRITING, TESTING, USING_TOOL, WAITING, WAITING_APPROVAL, BLOCKED, DELEGATING, PHONE_CALL, WALKING, IN_MEETING, COFFEE_BREAK, CHATTING, REVIEWING, DELIVERING, DONE, ERROR.
Statuses are case insensitive in events; unknown ones are ignored. tool.started sets USING_TOOL, tool.failed sets ERROR, a requested meeting sets WALKING and then IN_MEETING.
🔌 Connect your agents
Honest maturity, so you know what you are getting:
| Integration | Where | Maturity |
|---|
| REST API, batch, SSE | server/ | ✅ Stable. Covered by integration, SSE and webhook security tests in CI. |
| Claude Code | agent-viewer install claude-code | ✅ Stable. Official Claude Code hooks: sessions, subagents as their own agents, tools, waiting for you. Fixture tests for every hook type prove no arguments or content leave the machine. |
| Claude Code tokens (OTLP logs) | agent-viewer install claude-code --telemetry | ✅ Stable. POST /v1/logs receives Claude Code's native OpenTelemetry export (http/json and http/protobuf) and turns claude_code.api_request/claude_code.api_error into llm.usage/llm.failed on the main agent. Opt-in, off by default. Reference: docs/otlp.md. |
| Claude Code metrics (OTLP metrics) | POST /v1/metrics | ✅ Stable ingestion. Stores claude_code.token.usage/claude_code.cost.usage as their own evidence, kept apart from the ledger, the snapshot, SSE and every rollup on purpose: a second, independent measurement of the same consumption for a future cross-check. The GET /api/v1/usage/reconciliation endpoint that compares the two is not built yet (needs a usage ledger accessor still in progress). Reference: docs/otlp.md. |
| CLI | |
✅ stable and tested · 🧪 example code you wire into the framework's own callbacks (the adapters do not import the frameworks), or (LangGraph and OpenAI Agents SDK only) a real workspace package tested against the real framework in CI but not yet published to npm/PyPI · ❌ not available
Every adapter follows the same trust boundary: only observable states, messages and reported usage leave your runtime. Never send API keys or private system prompts through the event stream.
Python SDK
A single module with no third-party dependencies. Install it from a clone with pip install ./sdk/python (or copy agent_viewer.py into your project):
import os
from agent_viewer import AgentViewer
viewer = AgentViewer(url="http://localhost:8787", token=os.getenv("AGENT_VIEWER_API_TOKEN"), runtime_id="my-crew")
analyst = viewer.agent("analyst", name="Iris", role_title="Market analyst", workspace="research_area")
analyst.researching("Reading the quarterly filings")
analyst.tool_started("filing_fetcher", input_summary="Form 10-K")
analyst.tool_completed("filing_fetcher", output_summary="42 pages retrieved")
analyst.usage("OpenAI", "gpt-4o", input_tokens=4200, output_tokens=320, cost=0.024, cost_source="provider-reported", currency="USD")
analyst.message("Overview ready for review.", target_agent_name="Nova")
analyst.done("Summary delivered")
The agent registers itself on its first call. Requests retry with backoff, and usage() without a cost reports it as unknown. Token counts you leave out stay unknown, never 0. The SDK never assumes provider-reported: a cost passed without cost_source is sent as unknown, with one warning per client. Transport retries reuse the same event id and requestId. If your code calls usage() again for the same provider call, pass the same requestId and the server keeps one copy. viewer.usage_rollup(["agent", "model"], agent_id="analyst") calls GET /api/v1/usage/rollup for grouped, audit-ready sums (details).
Read the calls behind a figure with list_calls() (one page) or iter_calls() (follows the cursor for you), both over GET /api/v1/usage/calls (issue #67):
for call in viewer.iter_calls(agent_id="analyst", cost_source="unknown"):
print(call["requestId"], call["tokens"]["input"], call["status"])
Pass spool_dir="/var/lib/my-crew/agent-viewer-spool" to add an optional, bounded, on-disk durable queue (issue #101, stdlib sqlite3 only): a transient failure (connection down, 429, 5xx) never raises into your agent, the event is persisted and replayed in order once the server returns, and anything the spool has to give up is counted, never silently dropped. See sdk/python/README.md and the shared docs/sdk-queue.md contract.
TypeScript SDK
import { AgentViewer } from './sdk/typescript/index';
const viewer = new AgentViewer({ url: 'http://localhost:8787', apiKey: process.env.AGENT_VIEWER_API_TOKEN, runtimeId: 'my-crew' });
const builder = viewer.agent({ id: 'builder', name: 'Atlas', roleTitle: 'Builder', workspace: 'development' });
await builder.coding('Implementing the webhook handler');
await builder.toolStarted('npm.test', 'unit suite');
await builder.toolCompleted('npm.test', '128 passed');
await builder.usage({ provider: 'Anthropic', model: 'claude-sonnet-4-5', inputTokens: 1800, outputTokens: 450, cost: 0.012, costSource: 'provider-reported', currency: 'USD' });
await builder.message('Handler is ready for review.', 'Nova');
await builder.done('Pull request opened');
Several crews can share one server: tag each client with its own runtimeId and sessionId, then filter with GET /api/v1/events?runtimeId=.... More in the integration guide. Transport retries reuse the same event id and requestId. If your code calls usage() again for the same provider call, pass the same requestId and the server keeps one copy. viewer.usageRollup({ groupBy: ['agent', 'model'], agentId: 'builder' }) calls GET /api/v1/usage/rollup for grouped, audit-ready sums, and toUsageFigures() maps one group straight into the library's usage prop (details).
Read the calls behind a figure with listCalls() (one page) or iterateCalls() (follows the cursor for you), both over GET /api/v1/usage/calls (issue #67):
for await (const call of viewer.iterateCalls({ agentId: 'builder', costSource: 'unknown' })) {
console.log(call.requestId, call.tokens.input, call.status);
}
📜 Event contract V1
One envelope for everything. Producers send it; the server validates it with Zod; the office draws it.
{
"schemaVersion": "1.0",
"id": "evt_1791190800000_abc123",
"type": "agent.status.changed",
"timestamp": 1791190800000,
"runtimeId": "crewai-production",
"sessionId": "run-2026-001",
"source": "agent:researcher",
"agentId": "researcher",
"taskId": "task_01",
"severity": "normal",
"summary": "Research started",
"payload": { "status": "RESEARCHING", "workspace": "research_area" }
}
| Field | Type | Description |
|---|
schemaVersion | "1.0" | Contract version. |
id | string | Unique event id, also the idempotency key. One id names exactly one event: reusing it for different content is rejected with 409. |
type | string | One of the 23 canonical types (aliases accepted). |
timestamp | number | Unix epoch in milliseconds. |
source | string | Producer, for example runtime:crewai or agent:researcher. |
agentId | string? | The agent the event is about. |
runtimeId, sessionId, taskId | string? | Grouping for multi-runtime setups, sessions and tasks. |
severity | string? | low, normal (default), high or critical. |
summary | string | Human-readable line, not empty. |
payload | object | Type-specific details. |
| Category | Type | Effect |
|---|
| Agent | agent.registered | Adds the agent (name, role title, team, workspace, avatar color, provider, model). |
| agent.updated | Updates the profile; a valid workspace becomes its new home. |
| agent.status.changed | Sets the status; the agent moves only when a workspace is given. |
| agent.message.sent | Speech bubble, with optional kind and target (a dashed line joins both agents). |
| Tasks | task.created, task.assigned, task.progress | Recorded. |
| task.completed, task.failed, task.blocked | DONE, ERROR or BLOCKED for the agent working on it. |
| Tools | tool.started, tool.completed, tool.failed | USING_TOOL, then IDLE or ERROR. |
| Meetings | meeting.requested | Reserves the first free room; participants walk there and the meeting starts when all arrive. |
| meeting.started | Starts at once. |
| meeting.message | Bubble headed by its kind; a decision is added to the meeting decisions. |
| meeting.ended, meeting.cancelled | Frees the room; participants walk back to their workspace. |
| Telemetry | llm.usage | Provider, model, input and output tokens, cache read and cache write tokens, reasoning tokens, latency, cost, cost source and currency. A figure that was not reported stays unknown, never 0. Optional correlation fields: traceId, parentId, toolCallId, meetingId, userId and tags (docs/integration.md). |
Aliases such as message.sent, meeting.decision or approval.requested are mapped to their canonical type. The complete effect table is in the library guide, and the schema in canonicalContract.ts.
Usage rule: report cost when the provider gives it (costSource: "provider-reported"). When you do not know it, send null with costSource: "unknown": it stays unknown all the way to the screen. The SDKs never assume provider-reported: a cost sent without a stated costSource goes out as unknown. Logs use the same envelope, one event per line: see event-log.md. A server-side estimate (costSource: "estimated") comes from the versioned pricing table below, is always labeled estimated, and is never mixed with a provider-reported cost in one sum.
📡 Server and API
flowchart LR
subgraph Runtimes["Your runtime"]
A["LangGraph, CrewAI, AutoGen,<br/>OpenAI Agents, Google ADK"]
S["Python or TypeScript SDK,<br/>any HTTP client"]
end
subgraph Server["Agent Viewer server :8787"]
I["REST + webhooks<br/>Zod validation, contract V1"]
D[("Memory ring buffer<br/>or SQLite")]
E["SSE stream<br/>full replay or resync"]
end
subgraph Office["The office"]
W["Demo app :3000"]
L["AgentOffice in your React app"]
end
J["JSONL log file"]
A --> I
S --> I
I --> D
D --> E
E --> W
E --> L
J --> L
| Method | Endpoint | Purpose |
|---|
GET | /health | Status, version, schema version, connected SSE clients, and current auth / webhookAuth mode. |
GET | /ready | Storage readiness and the ingestion counters (conflicts rejected, legacy rows matched by id only). SQLite also returns the database schema version and latest migration time. |
GET/HEAD | /metrics | Opt-in (AGENT_VIEWER_METRICS=1), token-protected Prometheus text-exposition export (issue #103). 404 until enabled; 403 metrics_require_token with no token configured and no explicit opt-out; its own rate-limit bucket and no CORS headers. Exposes the in-process families plus the ledger-derived families (tokens, cost, calls, failures, latency, ledger gauges), cached per AGENT_VIEWER_METRICS_CACHE_MS; 503 metrics_unavailable if the ledger query fails. See docs/metrics.md. |
POST | /api/v1/events | Ingest one event. Honors the Idempotency-Key header. A true retry is a 200 duplicate; the same id with different content is a 409. |
POST | /api/v1/events/batch | Ingest up to 100 events (configurable). Each item reports accepted, duplicate or conflict; only accepted items are stored and streamed. |
GET | /api/v1/events | Query with limit, since, afterId, runtimeId, sessionId, agentId, type, submittedBy (issue #107; the key id, bootstrap, webhook or anonymous that submitted the event, surfaced on every returned event too). Not the audit path: it returns whole events (including free text) and afterId cannot walk history; use GET /api/v1/usage/calls for that. adds a sibling map keyed by event id with each row's hash-chain fields (issue #105), SQLite mode only. events (key creation/revocation) are hidden unless or names one explicitly. |
PATCH agent fields: name, roleTitle, provider, and model emit agent.updated. status emits agent.status.changed; statusText and workspace accompany that event when status is present, and otherwise emit agent.updated. Values must be strings: profile fields and workspace are trimmed and limited to 1-200 characters, statusText to 0-1000 characters, and status must be a known status. All other fields are rejected. Usage fields such as tokensInput, inputTokens, cachedTokens, cost, currency, and latencyMs cannot be patched. Report usage through POST /api/v1/events with an llm.usage event or the SDK usage() helper.
For example, a usage field returns HTTP 400:
{
"error": "validation_failed",
"message": "Usage and cost cannot be edited through PATCH. Send an llm.usage event to POST /api/v1/events (or use the SDK usage() helper) so the spend is recorded and auditable.",
"issues": [{ "path": "cost", "code": "usage_not_patchable", "message": "Report cost with an llm.usage event." }]
}
Invalid events get HTTP 400 with the exact paths that failed:
{
"error": "validation_failed",
"issues": [{ "path": "payload.inputTokens", "message": "Expected non-negative integer" }]
}
The event id is the idempotency key, and the content decides what a repeated id means. The server compares a fingerprint (sha256: over the event as validated, keys sorted, defaults filled in) with the one it stored:
| Stored | Incoming | Answer | Effect |
|---|
| no event with this id | any | 202 accepted | Stored, aggregated and streamed. |
| same id, same content | | 200 duplicate | Nothing changes. This is a true retry. |
| same id, different content | | 409 conflicting_duplicate | Not stored, not aggregated, not streamed. The stored event stays as it was. |
The first send returns 202 with the fingerprint, and a true retry returns HTTP 200 with the same fingerprint instead of a second copy:
{ "accepted": true, "duplicate": true, "duplicateReason": "event_id", "id": "evt_req_9921", "fingerprint": "sha256:3f1c..." }
The same id with any different stored field (a token count, the cost, a cost that was 0 and is now missing, or the timestamp) is rejected with HTTP 409:
{
"error": "conflicting_duplicate",
"message": "An event with id \"evt_req_9921\" was already stored with different content. The new event was not applied.",
"id": "evt_req_9921",
"fingerprint": "sha256:9b0e...",
"storedFingerprint": "sha256:3f1c..."
}
A retry must resend the identical event, timestamp included; rebuilding the body with a new Date.now() is a different event. Give every distinct event its own id. A type alias that validation resolves to the canonical type is the same event, so it is a duplicate.
When both the Idempotency-Key header and a non-empty body id are sent and they differ, nothing is stored and the answer is HTTP 400. A header with no body id becomes the id, as before:
{ "error": "idempotency_key_mismatch", "message": "Idempotency-Key \"a\" does not match the event id \"b\"." }
A batch answers 202 whenever validation passes, even if no item was applied, so read conflicts and each item's status (in input order). Items are compared with the store and with earlier items of the same batch:
{
"accepted": 1,
"duplicates": 1,
"conflicts": 1,
"total": 3,
"results": [
{ "id": "evt_b1", "status": "accepted", "duplicate": false, "fingerprint": "sha256:aa..." },
{ "id": "evt_b2", "status": "duplicate", "duplicate": true, "fingerprint": "sha256:bb..." },
{ "id": "evt_b3", "status": "conflict", "duplicate": false, "fingerprint": "sha256:cc...",
"error": "conflicting_duplicate", "storedFingerprint": "sha256:3f1c..." }
]
}
llm.usage and llm.failed have a second, independent dedup key: (provider, requestId). The event id above tells a retry of the exact same request from a conflicting one; the request key tells whether two different event ids actually name the same provider call (the application called usage() again, a process replayed its own buffer with fresh ids, two layers reported the same call, or a webhook delivery was retried). Provider matching ignores case and surrounding whitespace; requestId matches exactly after trimming; a missing or blank requestId means the event has no request key and behaves exactly as above. llm.usage and llm.failed share one key space, so a call reported as failed and then as used is not counted twice.
A new event id with an already-used (provider, requestId) is a 200 duplicate too, but duplicateReason says which key matched, id is always the id the figure is held under (the original), and submittedId appears whenever it differs from id:
{
"accepted": true,
"duplicate": true,
"duplicateReason": "request_id",
"id": "evt_req_9921",
"submittedId": "evt_req_9988",
"matchesOriginal": true,
"fingerprint": "sha256:9b0e..."
}
matchesOriginal appears only for a request_id duplicate: whether its usage-relevant fields (model, every token field, cost, currency, costSource) match the original. Unknown is never equal to zero: a cost of null against a stored 0 (or the reverse) is matchesOriginal: false. A mismatch also writes one warn log line with both event ids, the provider and a truncated request id, never payload text. The duplicate is stored with its full content for audit, but it is never added to any total, never changes the agent's status, provider or model, is never broadcast over the live stream or replayed on Last-Event-ID, and never appears in GET /api/v1/events. List every duplicate reference with GET /api/v1/usage/duplicates (optional limit, provider, requestId, duplicateOf query filters, same auth as the rest of /api/v1), and read the running counts from GET /api/v1/snapshot's usageDuplicates: { count, mismatched, unverified } (unverified is a reference migrated from before this existed, whose legacy content was never compared).
A batch applies the same two keys in input order, so two items of one batch can resolve against each other:
{
"accepted": 1,
"duplicates": 2,
"conflicts": 0,
"total": 3,
"results": [
{ "id": "evt_b1", "status": "accepted", "duplicate": false, "fingerprint": "sha256:aa..." },
{ "id": "evt_b1", "submittedId": "evt_b2", "status": "duplicate", "duplicate": true, "duplicateReason": "request_id", "matchesOriginal": false, "fingerprint": "sha256:bb..." },
{ "id": "evt_b1", "status": "duplicate", "duplicate": true, "duplicateReason": "event_id", "fingerprint": "sha256:aa..." }
]
}
Each conflict also writes one warn log line with the id, type, source, agent and both fingerprints (never the payload), and GET /ready counts them since the process started:
{ "ok": true, "ready": true, "storage": "sqlite", "ingestion": { "conflicts": 1, "legacyUnverifiedDuplicates": 0 } }
legacyUnverifiedDuplicates counts repeated ids that matched SQLite rows written before 0.2.0 (or with unreadable event_json): their content cannot be compared, so they are treated as duplicates. The counters reset on restart.
With AGENT_VIEWER_WEBHOOK_SECRET set, every inbound webhook needs X-Agent-Viewer-Timestamp (milliseconds since
the epoch) and X-Agent-Viewer-Signature (hex HMAC-SHA256 of ${timestamp}.${rawBody}, compared in constant
time). Full reference, including every response status and error value, the replay cache, and sender/receiver
code in Node and Python: docs/webhooks.md.
A budget is a limit your app configures server-side, over an agent, model, session, user or tag, per day, week or
month: POST /api/v1/budgets with scope, period, currency, basis (reported or reported_plus_estimated)
and limit. The server alone evaluates it against the usage ledger, on every read, and answers one of four states:
ok, warning, exceeded or unknown, the last one whenever the figure is incomplete (a missing cost, a
mismatched currency, truncated history) rather than ever guessed as ok. GET /api/v1/snapshot carries the same
computed state in its budgets field, and a state change pushes a named event: budget SSE frame. Both SDKs
expose a read-only budgets(). The demo app's Model Ops Budgets tab is the portal's own UI for this: it
lists every budget with its live spent/ratio/state, and lets you create, edit and delete one, reading every
figure straight from the server, never computing a ratio or a verdict itself. Full reference, with every endpoint
and a real captured request/response: docs/budgets.md.
An optional outbound webhook delivers every budget.alert event (a budget crossing its warning or exceeded
threshold) to a destination you configure, signed with its own HMAC secret, never the inbound
AGENT_VIEWER_WEBHOOK_SECRET above. Set AGENT_VIEWER_ALERT_WEBHOOK_URL (plus, optionally, _SECRET,
_ALLOW_HTTP, _MAX_ATTEMPTS, _TIMEOUT_MS, see the Configuration table) to enable it; with no URL, nothing is
ever sent, and every alert's delivery stays skipped. Full reference, including the exact headers, the payload
shape, retry and backoff behavior, delivery idempotency, the target-URL restrictions and a receiver verification
snippet: docs/webhooks.md.
Storage: memory (default) keeps the last AGENT_VIEWER_MAX_EVENTS events (default 10,000) in a ring buffer for GET /api/v1/events and SSE replay, derived state included. Dedup never forgets: once an event id (or, for llm.usage/llm.failed, a (provider, requestId) pair) is accepted, a later retry of that id is recognized as a duplicate for as long as the process runs, even after the event itself has fallen off the ring. Remembering every id costs roughly 100-150 bytes per accepted event for the process lifetime; the server logs one warning when it crosses 1,000,000 known ids, suggesting AGENT_VIEWER_STORAGE=sqlite for long-running servers. Totals, per-agent figures and GET /api/v1/snapshot's usage cover every accepted event since the process started, including evicted ones; only the event list itself is capped. snapshot.retention (and the retention field on GET /api/v1/events and the SSE heartbeat) reports maxEvents, how many events are retained versus accepted, and droppedEvents, so a client never mistakes a truncated list for the full history.
Usage ledger (issue #65): every accepted llm.usage or llm.failed event also gets one append-only, typed row in the usage ledger, carrying the server receive time (received_at), never the client's own clock. GET /api/v1/events now returns that same receive time as receivedAt on every event (SQLite always stored it in created_at; it was never returned before this). The ledger is independent of the event ring and of AGENT_VIEWER_MAX_EVENTS: in memory mode it has its own cap, AGENT_VIEWER_USAGE_LEDGER_MAX_ROWS (default 100,000); in SQLite mode it has no foreign key to events, so retention (issue #70) can prune old events without touching their ledger rows. The first start of a 0.4.0 server runs a one-time backfill over every event already stored, logging a summary line such as usage_ledger: scanned=1546 inserted=1530 duplicate=14 conflict=2 unparseable=0 (backfill); every later restart runs the same pass again to catch up on any gap, which normally finds nothing to do. Full reference: docs/usage-ledger.md.
sqlite uses Node's built-in node:sqlite and persists every event to ./data/agent-viewer.db; AGENT_VIEWER_MAX_EVENTS has no effect in this mode (a one-time startup log says so if it is set). Runtimes, sessions, agents, tasks, meetings and usage totals are not stored separately: at startup the server rebuilds all of them by replaying the stored events, in order, through the same reducer the live path uses. The state after a restart is identical to the state before it, events that cannot be read are skipped and reported, and /ready answers 503 until the rebuild finishes (see below). Rebuilding 100,000 events is expected to take about 1 to 2 seconds (see tests/sqlite-rebuild.test.mjs). Because the reducer is part of the server, a version upgrade that changes it (for example a fix to how a missing cost is counted) recomputes the whole history with the new reducer at the next startup: that is intended, not a bug. snapshot.retention reports maxEvents: null (the database keeps every row), droppedEvents: 0 and acceptedEvents equal to retainedEvents, the true stored row count.
SQLite schema migrations run automatically at startup. Before upgrading an existing database, the default AGENT_VIEWER_SQLITE_BACKUP=auto writes a .bak file beside it. Backups contain the same event data, are never pruned automatically, and can make the first startup take longer for large files. Set AGENT_VIEWER_SQLITE_BACKUP=off if you manage backups yourself. A server refuses a database with a newer schema; to roll back, stop the server and restore the .bak file before starting an older version. /ready reports database.schemaVersion, database.latestKnownSchemaVersion and database.appliedAt in SQLite mode. These are database migration details; /health's schemaVersion is the event contract version (1.0).
Readiness during a SQLite rebuild: GET /ready answers 503 with Retry-After: 1 and rebuild.state: "running" until the replay finishes, then 200 with rebuild.state: "done" and rebuild.durationMs. GET /health (the liveness probe) answers 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, are stored, and are applied once the rebuild (or a later restart) reaches them. Point container health checks at /health and readiness checks at /ready. Memory storage has nothing to replay: /ready answers 200 immediately.
🔧 Configuration
Create your .env at the repository root from the example: cp server/.env.example .env (server/.env.example). The server reads it on start; Vite reads the VITE_ variables when the demo app is built or served.
| Variable | Default | What it does |
|---|
PORT | 8787 | Server port. |
AGENT_VIEWER_HOST | 127.0.0.1 | Interface npm run server (and a direct startServer() call) binds. A non-loopback value with no token refuses to start; see the Security table. A blank value counts as unset. |
AGENT_VIEWER_API_TOKEN | empty | Protects /api/v1/*. Clients send Authorization: Bearer <token>; EventSource clients trade it for a ticket from POST /api/v1/stream-tickets first, since it cannot send a header. A token or api_key query parameter never authenticates, on any route. Empty means open: the server warns at startup, /health reports auth: "open" and the live portal shows a banner; the agent-viewer CLI and the Docker images never run open (a blank value counts as unset and a token is generated). AGENT_VIEWER_API_KEY, still read by the example adapters, is a deprecated alias. |
AGENT_VIEWER_ALLOW_OPEN | unset | Set to exactly 1 to let the server bind a non-loopback interface with no token anyway, and to silence the open-mode request guard (403s on a non-loopback remote address, Host or Origin). Any other value is ignored. |
AGENT_VIEWER_STREAM_TICKET_TTL_MS | 30000 | Lifetime of a stream ticket from POST /api/v1/stream-tickets, clamped to [1000, 300000]. |
AGENT_VIEWER_STREAM_TICKET_MAX | 1000 | Outstanding (unused, unexpired) stream tickets allowed at once; past it, minting answers 429. |
AGENT_VIEWER_CORS_ORIGIN | * when unset | Allowed browser origins, comma separated. server/.env.example sets http://localhost:3000. A * value does not count as an explicit origin for the open-mode request guard. |
AGENT_VIEWER_STORAGE | memory | memory or sqlite. |
🎮 Demo app
npm run dev runs the full living office: a simulated team, a scripted demonstration, ambient office life, a task board, meeting rooms, an activity timeline and the Model Ops console (including its Pricing and Budgets tabs over the server's own catalog and limits). It is the showroom; the embeddable library is the product you ship.
| Action | Control |
|---|
| Play or pause the demonstration | Play button or Space |
| Step forward / change speed | Step button / 1x, 2x, 5x |
| Reset the session | Reset button |
| Switch views | O office · T tasks · M meetings |
| Close panels, clear selection | Esc |
| Pan / zoom | Drag the canvas / mouse wheel or the zoom buttons |
| Rotate / fit the office | Canvas toolbar |
| Activity timeline | Timeline button in the canvas toolbar |
| Token and cost console | Tokens pill in the top bar, or the Model Ops room |
| Language | EN / ES selector |
| Replay a log without a server | Drop a JSONL V1 file onto the window |
The demo saves its state in the browser so a refresh picks up where you were; Reset clears it (details). Live mode skips the saved demo state; instead, on open it loads the office, the recent activity and the token/cost figures from the server itself (GET /api/v1/snapshot and a paged GET /api/v1/events) before subscribing to the stream, so a reload or a second tab opened mid-run shows the same figures, never zero (issue #72). Run npm run test:e2e to exercise this and the rest of the browser flows with Playwright.
🔒 Security
The defaults favor local development. Before you expose the server:
| Area | Default | Production |
|---|
| API token | unset, /api/v1/* open | Check with curl -s localhost:8787/health | jq .auth; set AGENT_VIEWER_API_TOKEN to a high-entropy secret. Treat it as the bootstrap credential and create a named, revocable key (issue #107, agent-viewer keys create, requires AGENT_VIEWER_STORAGE=sqlite) for each SDK, hook or pipeline instead of sharing it directly. See docs/api-keys.md. |
| Webhooks | unsigned when no secret | Set AGENT_VIEWER_WEBHOOK_SECRET to require HMAC signatures. |
| CORS | * | Set AGENT_VIEWER_CORS_ORIGIN to your exact frontend origin. |
| Network | npm run server binds 127.0.0.1 by default; a non-loopback AGENT_VIEWER_HOST with no token refuses to start (AGENT_VIEWER_ALLOW_OPEN=1 overrides this); with no token, a request from a non-loopback address, Host or Origin gets 403 even on a loopback bind. Docker Compose ports bind 127.0.0.1. | Set AGENT_VIEWER_API_TOKEN before binding a non-loopback AGENT_VIEWER_HOST. To expose Compose remotely, restore "8787:8787" / "3000:3000" and put it behind a reverse proxy with TLS; a reverse proxy on the same host makes every request look local, so it still needs a token. |
| Storage | in memory, not tamper-evident | AGENT_VIEWER_STORAGE=sqlite on a protected volume: every stored event joins a SHA-256 hash chain (issue #105), so an edit, deletion or reordering of a stored row is detectable. Memory mode has no chain at all. See docs/audit-chain.md for what the chain does and does not cover on its own, without an external anchor. |
| Retention | keeps everything forever | Set AGENT_VIEWER_RETENTION_DAYS for a production deployment that must bound how long prompts-adjacent metadata (summaries, payloads) stays on disk; set AGENT_VIEWER_USAGE_RETENTION_DAYS separately only if the audit record of tokens and cost must also expire. This is a honest baseline, not tamper-evident: see . |
Agent Viewer needs no model provider keys: usage figures come from your runtime. Report vulnerabilities privately through GitHub Security Advisories or [email protected], never in a public issue. Full policy: SECURITY.md.
POST /v1/logs and POST /v1/metrics honor AGENT_VIEWER_API_TOKEN exactly like /api/v1 (open when unset, the same startup warning). The metrics receiver stores only an explicit attribute allowlist (hashed session id, model, type, service.name/service.version); user.email, user.account_uuid, organization.id and the raw session.id are never stored. Its internal series key is a keyed HMAC-SHA256 (random secret, persisted in SQLite mode), not a plain hash, so a low-entropy attribute can never be recovered from it by dictionary attack. See docs/otlp.md for the full reference.
🧭 Roadmap
Shipped in 0.2.0: the embeddable library, professional and showcase modes, message kinds, replay, video export, isolated CSS, keyboard access and English and Spanish texts. Shipped in 0.3.0: true usage figures end to end, llm.failed, Claude Code token telemetry, and the first publish to npm, PyPI and GHCR. Shipped in 0.4.0: the usage ledger with the server receive time, the rollup and calls APIs, CSV/JSONL export with reconciliation totals, retention, secret redaction at export, an API that no longer accepts a token in the URL or runs open by accident, the portal loading full history on open, and OTLP metrics ingestion. Shipped in 0.5.0: per-agent usage badges and a per-call detail panel on the canvas, cost per meeting and per tool, Model Ops reconciling against the real ledger call by call, and the library guide's four usage privacy rules stated together in docs/library.md. Shipped in 0.6.0, "Precios y control": a versioned server pricing table with a per-row cost estimate (docs/pricing.md), budgets per agent, model, session, user or tag with server-computed state and signed outbound alerts (docs/budgets.md), measured latency percentiles and failure rates per model, and one canonical webhook-signing guide for both directions (docs/webhooks.md). See docs/migrating-to-0.4.md if you are upgrading from 0.3.0.
Shipped in 0.7.0, "Captura automática e interoperabilidad": an OTLP/HTTP GenAI traces receiver (POST /v1/traces) and the matching parseEventLog file import, packaged LangGraph (@warlockcode/agent-viewer-langgraph) and OpenAI Agents SDK (@warlockcode/agent-viewer-openai-agents) adapters tested against the real frameworks in CI, a durable local queue in both SDKs so a server outage never loses an event, and a Prometheus GET /metrics export (opt-in, token-protected, ledger-derived token/cost/call/failure/latency families, in-process ingestion counters, optional OTLP metrics push).
Shipped in 0.8.0, "Auditoria formal": a SHA-256 hash chain over every stored event with periodic signed checkpoints (docs/audit-chain.md), named ingestion keys and scoped read-only auditor keys (docs/api-keys.md), chain-compatible retention and redaction that tombstones a row without breaking the chain, an offline-verifiable audit bundle with a standalone verifier script (docs/audit-bundle.md), and the audit guide, threat model and migration guide from the single shared token (docs/audit.md, docs/threat-model.md, docs/migrating-to-0.8.md). This closes the 0.3.0 to 0.8.0 observability and audit roadmap; see docs/roadmap.md for the full history.
Planned, not available yet:
Want one of these sooner? Open an issue and say what you would use it for.
🤝 Contributing
Contributions are welcome: adapters for your framework, translations, bug reports with a JSONL log that reproduces them. Read CONTRIBUTING.md and the code of conduct, open or claim an issue for anything non-trivial, and run the same checks as CI before a pull request:
npm run lint # typecheck
npm test # node:test suites + Vitest
npm run test:golden # golden reconciliation suite (also part of npm test)
python3 tests/test_python_sdk.py
npm run build # demo app
npm run build:lib # library
npm run check:package # publint + attw
Three rules keep the product honest: never require or expose private model reasoning, keep simulated dialogue visibly marked as simulated, and never show an unknown figure as zero. Field-by-field detail: docs/usage-semantics.md (español).
🧱 Built with
💜 Credits
- Idea: María Alejandra (@Alejagop12). Agent Viewer started as her idea: watch AI agents work together as a team in a real office and more control
- Built by: Juan Manuel Castillo (@jmmana), WarlockCode.
- Built with AI collaborators: Gemini, ChatGPT, Qwen Code (Alibaba), Kiro and Claude Code helped design, write, review and test this project.
📄 License
MIT. Use it, fork it, ship it.