Configuration
Every setting is optional. surface works with nothing but the binary, and a missing config file is the normal case rather than an error — so this page is really a list of the four things worth changing.
Where the file goes
surface.toml, in the config directory:
| Path | |
|---|---|
| macOS | ~/Library/Application Support/ai.holistic.surface/surface.toml |
| Linux | ~/.config/surface/surface.toml |
| Windows | %APPDATA%\holistic\surface\config\surface.toml |
surface --check prints the resolved directory on this machine, which beats
guessing:
Precedence
Later wins:
- Built-in defaults
surface.tomlSURFACE_*environment variables- CLI flags (
--offline)
The whole file
Copy this and delete what you do not need — every value shown is the default.
# surface configuration — every setting is optional.
#
# surface works with no config file at all; this documents the defaults. Copy it
# to the path `surface --check` reports as the config directory, as `surface.toml`.
#
# macOS ~/Library/Application Support/ai.holistic.surface/surface.toml
# Linux ~/.config/surface/surface.toml
# Windows %APPDATA%\holistic\surface\config\surface.toml
# ----------------------------------------------------------------- AI sites
#
# Read browser history for visits to known AI domains. Only the domain, a visit
# count and a last-seen date are read: the domain filter runs inside SQLite, so
# no URL, path, query string or page title is ever loaded — not even for the AI
# domains themselves, let alone the rest of your browsing.
[web]
# Set false to skip browser history entirely. The Sites view then says it is
# switched off rather than showing an empty list.
scan_history = true
# How recently a domain must have been visited to appear. Visit counts are the
# browser's lifetime totals; this only decides which domains are listed.
history_lookback_days = 30
# Extra domains to treat as AI services — an internal model gateway, say.
# Matched on the registrable host, so "models.corp.example" also covers
# "www.models.corp.example" but never "notmodels.corp.example".
extra_ai_domains = []
# --------------------------------------------------------------- token usage
#
# Read the transcripts Claude Code, Codex and OpenCode already write, to count
# tokens per day, tool and model. Message content is never read.
[usage]
# Set false to skip transcripts entirely.
scan = true
# How many days of daily totals to keep. Older days are pruned from the ledger,
# along with their deduplication keys.
window_days = 30
# Project rows to fold into another, "shown name" = "fold into". The same
# project legitimately earns two names — a checkout with an `origin` remote
# reports owner/name, a copy of the same code with no remote reports its
# folder basename — and surface never guesses that two names are one project.
# Declare it here instead. Applied when the ledger is read, so history
# regroups retroactively and a wrong alias is one edit away from undone.
# [usage.repo_aliases]
# "HAI Neo" = "holistic-ai/hai-neo"
# ---------------------------------------------------------------------- cost
#
# surface prices tokens at API rates. If you pay a flat subscription instead,
# tell it what you actually pay and the Cost view will compare the two.
[cost]
# Monthly spend per tool, in USD. Keys are tool ids as they appear in the Usage
# view: claude_code, codex, opencode, gemini_cli, ...
#
# Without an entry here, a tool falls back to the plan it names itself — in its
# account file (~/.claude.json, ~/.codex/auth.json) or beside the token counts
# in its transcripts — priced at that plan's list rate and marked an estimate.
# Only the plan's name is read from those files, never the credentials beside
# it. A tool naming no plan gets no row at all, and an entry here always wins.
[cost.subscriptions]
# claude_code = 100.0
# codex = 30.0
# The plan a tool is on, where the tool's own files cannot say. Values are plan
# slugs; a slug here outranks both the account file and the transcripts, and is
# priced at that plan's list rate — so it stays marked an estimate, while the
# plan's name comes out right everywhere it is shown.
#
# ChatGPT Business is why this exists: standard seats are $25/month and premium
# seats $125, and chatgpt_plan_type reads `team` for both. Nothing on disk tells
# them apart, so a premium seat prices as a standard one until declared here.
#
# Use [cost.subscriptions] instead when the rate is yours rather than published
# — an enterprise agreement, say. It wins over anything set here.
[cost.plans]
# codex = "team_premium"
# ------------------------------------------------------------ env overrides
#
# Environment variables win over this file, and are handy for one-off runs:
#
# SURFACE_STATE_DIR where the ledger and price cache live
# SURFACE_CONFIG_DIR where this file is looked for
# SURFACE_SCAN_HISTORY=false same as [web] scan_history
# SURFACE_HISTORY_LOOKBACK_DAYS same as [web] history_lookback_days
# SURFACE_SCAN_USAGE=false same as [usage] scan
# SURFACE_USAGE_WINDOW_DAYS same as [usage] window_days
[web]
Browser history, for known AI domains only.
| Key | Default | Does |
|---|---|---|
scan_history |
true |
Read browser history at all |
history_lookback_days |
30 |
How recently a domain must have been visited to be listed |
extra_ai_domains |
[] |
Extra domains to treat as AI services |
[web]
scan_history = true
history_lookback_days = 90
extra_ai_domains = ["models.corp.example", "llm-gateway.internal"]
extra_ai_domains is matched on the registrable host, so
models.corp.example also covers www.models.corp.example but never
notmodels.corp.example. An internal model gateway is the usual reason to set
it.
Setting scan_history = false makes the Sites view report
that it is switched off, rather than showing an empty list.
The lookback filters domains, not visit counts
Visit counts are the browser's lifetime totals. history_lookback_days only
decides which domains make the list.
[usage]
Token accounting from the transcripts your tools already write.
| Key | Default | Does |
|---|---|---|
scan |
true |
Read transcripts at all |
window_days |
30 |
How many days of daily totals to keep |
window_days is also the retention policy: days that fall outside it are pruned
from the ledger, along with their deduplication keys. Widening it does not
recover days already pruned — those transcripts will be re-read from scratch,
which costs a cold scan once.
Both day counts are clamped to 1–3650, not rejected. A nonsensical window is a typo, and refusing to run over a typo is worse than running over a sane value. An unknown key, on the other hand, is an error at startup: a misspelled setting that silently does nothing is the worse failure.
[usage.repo_aliases]
The same project legitimately earns two rows in the Projects view: a checkout
with an origin remote reports owner/name, while a copy of the same code
with no remote — a scratch workspace, an agent's own working folder — reports
its directory basename. surface never guesses that two names are one project,
because folding someone's spend together on a string resemblance is
misattribution. Declare it instead:
Keys are rows exactly as the Projects view shows them; values are the row to fold them into. The grouping is applied when the ledger is read, never to what is stored — like prices — so an alias added today regroups the whole window retroactively, and a wrong one is one edit away from undone. Aliases do not chase: an alias pointing at another alias folds one hop only.
[cost]
surface prices tokens at API list rates. If you pay a flat subscription instead, tell it what you actually pay and the Cost view will compare the two.
Keys are tool ids as they appear in the Usage view — claude_code, codex,
opencode, gemini_cli, … — and values are monthly USD.
A tool with no entry falls back to the plan it names itself, if any — its
account file first (~/.claude.json, ~/.codex/auth.json), its transcripts
otherwise — priced at that plan's published list rate. Only the plan's name is
read from those files, never the credentials beside it, and a tool that names
no plan and has no entry gets no row rather than a guess. A configured figure
always wins and is used as given; a list-price fallback is labelled est
wherever it is shown.
When the tool cannot name its own seat
Detection can only report what a tool writes down, and some plans are invisible
from disk. ChatGPT Business sells standard seats at $25/month and
premium seats at $125/month, and ~/.codex/auth.json records
chatgpt_plan_type: team for both — nothing in the token, or anywhere else
Codex stores state, names the tier. Left alone, a premium seat is priced as a
standard one and your spend reads five times under.
Declare the seat and detection stops guessing:
Keys are tool ids, as above; values are plan slugs. A slug here outranks both
the account file and the transcripts, and is priced at that plan's published
list rate — so the figure is still labelled est, and the plan's name is
right everywhere it is shown. That is the difference from
[cost.subscriptions], which sets the dollars directly and is taken as what
you actually pay. Use [cost.plans] when your plan has a list price, and
[cost.subscriptions] when it does not — an enterprise agreement, say, or any
rate that is yours rather than published.
Environment variables
Handy for a one-off run, and what the test suite uses to stay out of a real profile.
| Variable | Overrides |
|---|---|
SURFACE_STATE_DIR |
Where the ledger and price cache live |
SURFACE_CONFIG_DIR |
Where surface.toml is looked for |
SURFACE_SCAN_HISTORY |
[web] scan_history |
SURFACE_HISTORY_LOOKBACK_DAYS |
[web] history_lookback_days |
SURFACE_SCAN_USAGE |
[usage] scan |
SURFACE_USAGE_WINDOW_DAYS |
[usage] window_days |
Booleans accept 1/true/yes/on and 0/false/no/off; anything else is
ignored rather than treated as false.
$env:SURFACE_SCAN_HISTORY = 'false'
$env:SURFACE_STATE_DIR = (New-Item -ItemType Directory -Path "$env:TEMP\surface-scratch" -Force).FullName
surface --json
Those two assignments last for the current PowerShell session. Use
[Environment]::SetEnvironmentVariable('SURFACE_SCAN_HISTORY','false','User')
to make one stick, or Remove-Item Env:SURFACE_SCAN_HISTORY to undo it now.
Recipes
Tools only — no history, no transcripts
The scan then reports which AI tools are installed and nothing else. Tool detection has no off switch: it is the cheapest section and the one the tool exists for.
A quarter of history instead of a month
Expect one slow scan while the extra transcripts are read, then the usual ~50 ms.