Skip to content

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:

surface --check

Precedence

Later wins:

  1. Built-in defaults
  2. surface.toml
  3. SURFACE_* environment variables
  4. CLI flags (--offline)

The whole file

Copy this and delete what you do not need — every value shown is the default.

surface.toml
# 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
[usage]
scan = true
window_days = 90

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:

[usage.repo_aliases]
"HAI Neo" = "holistic-ai/hai-neo"

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.

[cost.subscriptions]
claude_code = 100.0
codex = 30.0

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:

[cost.plans]
codex = "team_premium"

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.

# Transcripts only — no browser history, nothing written to the real profile
SURFACE_SCAN_HISTORY=false SURFACE_STATE_DIR=$(mktemp -d) surface --json
$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
[web]
scan_history = false

[usage]
scan = false

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
[web]
history_lookback_days = 90

[usage]
window_days = 90

Expect one slow scan while the extra transcripts are read, then the usual ~50 ms.

Never touch the network

Pass --offline, or make it permanent with a shell alias:

alias surface='surface --offline'

Prices then come from the cache, and from the built-in table when the cache is missing. There is no config key for this — a flag you can see beats a setting you have forgotten.