JSON output
surface --json runs the same scan as the dashboard, prints it as pretty JSON,
and exits. This is the form to pipe into jq, commit to a report, or feed to
whatever you already use.
The payload is assembled for a reader rather than derived from the internal
types: the shape you want in jq is not the shape the views want to read.
Top level
{
"surface": "0.1.0",
"timings_ms": { "tools_ms": 6, "sites_ms": 331, "usage_ms": 52, "total_ms": 391 },
"failed_sections": [],
"tools": { "…": "…" },
"sites": { "…": "…" },
"usage": { "…": "…" },
"prices": { "models": 1284, "age_secs": 7204 }
}
| Key | Meaning |
|---|---|
surface |
Version of the binary that produced this |
timings_ms |
Per-section and total scan duration |
failed_sections |
Sections that panicked and were skipped. Empty is normal |
prices.models |
Rows in the price table that was applied |
prices.age_secs |
Age of the cached table; null when the built-in table was used |
failed_sections is the one field worth asserting on in a script. A non-empty
array means a section produced no data for a reason that was not "nothing to
find".
tools
{
"detected": 8,
"autonomous": 5,
"vendors": ["Anthropic", "OpenAI", "Ollama"],
"list": [
{
"id": "claude_code",
"name": "Claude Code",
"vendor": "Anthropic",
"kind": "coding_agent",
"autonomous": true,
"evidence": ["executable:/Users/you/.local/bin/claude", "config:~/.claude"]
}
]
}
kind is one of assistant, coding_agent, autonomous_agent, editor,
extension, local_runtime. autonomous means the tool can execute code or
take actions on the machine on a model's behalf.
# Everything installed here that can act on the machine
surface --json | jq -r '.tools.list[] | select(.autonomous) | .name'
sites
{
"sites": [
{
"domain": "claude.ai",
"vendor": "Anthropic",
"kind": "assistant",
"visits": 317,
"last_seen_unix": 1785100000,
"last_seen": "2026-07-27T09:14:22Z"
}
],
"vendors": ["Anthropic", "OpenAI"],
"domains_watched": 30,
"browsers_scanned": ["chrome", "firefox"],
"profiles_scanned": 6,
"history_entries_scanned": 18422,
"lookback_days": 30,
"unreadable": [],
"blind_spots": [
{ "browser": "safari", "name": "Safari", "reason": "insufficient_privileges" }
],
"disabled": false
}
Three fields carry the caveats, and a script that ignores them will read a permissions problem as an absence of AI usage:
| Field | Means |
|---|---|
disabled |
scan_history = false. Not "found nothing" |
unreadable |
A profile that was found but could not be read, and why |
blind_spots |
A browser installed but structurally unreadable — Safari without Full Disk Access |
reason values are stable strings meant to be matched on, not displayed:
insufficient_privileges (Safari without Full Disk Access),
no_history_database, tool_unavailable.
visits is the browser's lifetime count for the domain; lookback_days only
decides which domains appear. last_seen is null when the row carried no usable
timestamp, which is deliberately not the same as never visited.
Without the sqlite feature this becomes a marker instead of a result, and again
says so rather than looking empty:
{ "compiled_in": false, "reason": "tool_unavailable", "detail": "built without the sqlite feature" }
usage
{
"window_days": 30,
"disabled": false,
"tools_read": ["claude_code", "codex"],
"sources_read": 214,
"bytes_read": 12693,
"unreadable": [],
"duplicates_skipped": 9596,
"totals_by_tool": {
"claude_code": {
"input": 1043221,
"output": 8894120,
"cache_read": 1785340221,
"cache_creation": 42119883,
"reasoning": 0,
"messages": 8472,
"total": 1837397445
}
},
"days": [
{
"date": "2026-07-27",
"tool": "claude_code",
"model": "claude-opus-5",
"input": 34221,
"output": 291204,
"cache_read": 58113402,
"cache_creation": 1402113,
"reasoning": 0,
"messages": 284,
"cost": { "state": "known", "usd": 41.88 }
}
],
"by_repo": [
{
"repo": "acme/platform",
"tokens": 1204338211,
"messages": 5218,
"cost_usd": 612.4,
"unpriced_models": 0
},
{
"repo": "(unattributed)",
"tokens": 88213004,
"messages": 611,
"cost_usd": 121.9,
"unpriced_models": 2
}
]
}
days is the long form — one row per (date, tool, model) — and the natural
thing to aggregate yourself. bytes_read is how much was read this run, which
is small precisely because the ledger tracks byte offsets;
duplicates_skipped is how many records were dropped as already-counted.
by_repo keys on the git remote slug, never a path. unpriced_models on a row
means cost_usd is a floor.
Cost states
Every cost object is one of three states, and never a bare number:
{ "state": "known", "usd": 41.88 }
{ "state": "local", "usd": 0.0 }
{ "state": "unpriced", "usd": null }
An unpriced model serialises as null, never 0.0 — see
Costs. Any arithmetic you do downstream should
decide explicitly what to do with null rather than let it coerce to zero.
Recipes
# Total spend, ignoring anything unpriced
surface --json | jq '[.usage.days[].cost.usd | select(. != null)] | add'
# Spend per repository, biggest first
surface --json | jq -r '.usage.by_repo[] | "\(.cost_usd)\t\(.repo)"' | sort -rn
# Models we cannot price — the list to check when a total shows ≥
surface --json | jq -r '[.usage.days[] | select(.cost.state == "unpriced") | .model] | unique[]'
# Which browsers could not be read
surface --json | jq -r '.sites.blind_spots[] | "\(.name): \(.reason)"'
# Fail a check if any section panicked
surface --json --offline | jq -e '.failed_sections | length == 0' > /dev/null
Stability
The JSON shape is versioned along with the crate, and pre-1.0 it can change in a
minor release. The surface field in every payload tells you which version wrote
it. See Stability.