Configuration
The config.toml schema — capture sources, filters, output, and retention.
Configuration lives in a single TOML file:
~/.config/zanei/config.toml
Override the path per invocation with the global --config <path> flag. Helper commands:
zanei config init # create a fully commented template at the configuration path
zanei config path # print the config file path
zanei config show # effective configuration (defaults merged)
zanei config edit # open in $EDITOR
zanei config set capture.text_content true
zanei config set capture.content_snapshot true
zanei config init creates missing parent directories and writes the complete template shown
below. It never overwrites an existing file; if the destination exists, it reports that path and
exits with code 1. Use the global --config <path> option to initialize a non-default location.
Use zanei config set for capture.text_content, capture.content_snapshot,
output.batch_interval_s, and output.retention_hours. Use
zanei filter for filter lists and config edit for other arrays.
Full example (defaults)
[capture]
sources = ["app", "window", "ui", "input", "browser"]
text_content = false # content capture is an explicit opt-in
content_snapshot = false # frontmost-window Accessibility text is a separate opt-in
[filter] # capture-time filters: the privacy boundary (applied before the store)
exclude_apps = ["1Password", "Keychain Access"] # deny list (bundle_id recommended); defaults ship built-in
include_only_apps = [] # non-empty switches to allow-list mode
exclude_websites = [] # browser URL events/text bodies; dot-boundary host suffix match
include_only_websites = [] # allow list for browser URL events/text bodies
redactors = ["credit_card", "token"] # limited pattern-based scrubbing; email is opt-in
[filter.text_content] # typed/copied bodies only; events and non-content facts remain
exclude_apps = ["com.apple.Safari", "org.mozilla.firefox", "com.brave.Browser",
"com.microsoft.edgemac", "com.vivaldi.Vivaldi", "company.thebrowser.Browser"]
include_only_apps = []
exclude_websites = []
include_only_websites = []
[filter.content_snapshot] # content.snapshot events only
exclude_apps = ["com.apple.Safari", "org.mozilla.firefox", "com.brave.Browser",
"com.microsoft.edgemac", "com.vivaldi.Vivaldi", "company.thebrowser.Browser"]
include_only_apps = []
exclude_websites = []
include_only_websites = []
[output]
batch_interval_s = 5
retention_hours = 48 # short-lived retention by default
There is no egress-related setting; external transmission does not exist as a feature.
Validation
Validation runs after omitted settings are filled with their defaults. Only the 18 keys below and the optional [filter.capture_policy] table, in their declared sections, are accepted; any other key makes the file invalid. config init writes all 18 options and never writes [filter.capture_policy]. A file that violates a rule is not accepted: every command that loads it, including config show and config edit, exits with code 1. Invalid values given to config set or filter are usage errors (code 2) and nothing is saved. config set accepts only scalar keys (capture.text_content, capture.content_snapshot, output.batch_interval_s, output.retention_hours); array keys are edited with filter or by hand.
| Key | Accepted values |
|---|---|
capture.sources |
Zero or more of app, window, ui, input, browser; no duplicates |
capture.text_content |
TOML boolean; config set accepts exactly true or false |
capture.content_snapshot |
TOML boolean; config set accepts exactly true or false. Enabling it shows the current scope and asks [y/N]; see the CLI reference |
filter.exclude_apps, filter.include_only_apps |
Strings; each non-empty, no leading or trailing whitespace, unique within the list ignoring case. filter ... add of a value already present (including a case-only variant) is a no-op (code 0) |
filter.exclude_websites, filter.include_only_websites |
Domain names, same non-empty / whitespace / uniqueness rules. At most one trailing dot; without it, at most 253 bytes total, each dot-separated label 1–63 bytes of ASCII letters, digits, or -, starting and ending with a letter or digit. A value that is itself a public suffix in the bundled snapshot (for example com) is accepted, but filter ... add prints a warning unless --quiet is set |
filter.text_content.exclude_apps, filter.text_content.include_only_apps, filter.content_snapshot.exclude_apps, filter.content_snapshot.include_only_apps |
The same app-list rules. Both exclude_apps lists default to Safari, Firefox, Brave, Edge, Vivaldi, and Arc bundle IDs shown in the full example; the other app lists default to [] |
filter.text_content.exclude_websites, filter.text_content.include_only_websites, filter.content_snapshot.exclude_websites, filter.content_snapshot.include_only_websites |
The same domain-list rules; all four default to [] |
filter.redactors |
Zero or more of email, credit_card, token; no duplicates. email is available but is not enabled by default |
output.batch_interval_s, output.retention_hours |
Unsigned integer (u64) greater than zero |
[filter.capture_policy] |
Optional and absent by default. When the table is present, browser and ide and every key inside them are required; allowed_apps is optional. See [filter.capture_policy] |
[capture]
| Key | Default | Description |
|---|---|---|
sources |
["app", "window", "ui", "input", "browser"] |
Which event families to capture. Without browser, browser Automation is required for eligible content snapshots or when text_content is enabled with ui or input; window alone does not require it (see permissions) |
text_content |
false |
Capture typed/field content (ui.value, input.key, clipboard contents). Off by default; see the privacy model |
content_snapshot |
false |
Capture Accessibility text shown in the frontmost window as content.snapshot. Independent from sources and text_content; even with window absent from sources, this opt-in requires Accessibility and can record snapshots. Restart required |
[filter]
Capture-time filters apply before writing. There are three scopes: [filter] for all events, [filter.text_content] for typed/copied bodies, and [filter.content_snapshot] for snapshot events. For each app or website axis, a non-empty include_only_* list selects only mode; otherwise the scope uses exclude mode. exclude_* always wins.
The evaluation order is built-in exclusions, [filter] apps and websites, [filter.text_content], then [filter.content_snapshot]. Falling outside the text-content scope keeps the event and facts but sets body fields to null, as if capture.text_content were false in that window. Falling outside the snapshot scope prevents content.snapshot from being created. Website rules apply to Chrome and Safari. See the filters guide; manage lists with zanei filter rather than editing by hand.
| Key | Default | Description |
|---|---|---|
exclude_apps |
built-in defaults | Deny list of apps (bundle_id recommended; display names allowed) |
include_only_apps |
[] |
Allow list; non-empty switches to allow-list mode |
exclude_websites |
[] |
Deny list of URL hosts for browser URL events and text-content bodies. Dot-boundary suffix match with no Public Suffix List handling: example.com covers api.example.com but not evil-example.com; a bare com would match every .com host |
include_only_websites |
[] |
Allow list of URL hosts for browser URL events and text-content bodies |
redactors |
["credit_card", "token"] |
Pattern-based redaction rules applied to captured values. email remains available as an opt-in rule |
The nested scopes use the same four list names. Their defaults and behavior are shown above. The daemon watches all three filter scopes at roughly 2-second intervals, so changes apply within a few seconds with no daemon restart.
[filter.capture_policy]
An optional table for an application that embeds the recorder. It is written by that application
or by hand: config init never writes it, and config set and filter never touch it. A
configuration without it behaves exactly as described above, and adding it does not change the
meaning of any key above.
App selection is the same for everyone. Which applications are recorded is decided by
filter.exclude_apps and filter.include_only_apps — the lists managed with
zanei filter exclude-app and zanei filter only-app. capture_policy
adds only what those lists cannot express: browser URL rules and IDE file rules.
allowed_apps is optional and exists for a policy that must pin its own explicit list of
application display names. Omit it and the two filter lists are the only app gate; include it and
it is an additional allow list applied after them, so an empty allowed_apps records nothing.
Given an event whose app already passed the [filter] app lists, evaluation runs in this order:
- Built-in exclusions (password managers and credential stores) deny the app. They cannot be lifted by any configuration.
allowed_apps, when present, must contain the app’s display name (compared case-insensitively after trimming surrounding whitespace); otherwise the event is denied.Google ChromeandSafariare decided bybrowserand nothing else.Cursor,Visual Studio Code, andCodeare decided byide.- Every other app is allowed.
The [filter] and [filter.text_content] / [filter.content_snapshot] website rules still apply
to Chrome and Safari after browser allows a URL. Like the rest of [filter], changes are picked
up by the daemon’s config watch within a few seconds; no restart is needed.
| Key | Required | Description |
|---|---|---|
allowed_apps |
no | Application display names. Absent leaves app selection to filter.exclude_apps / filter.include_only_apps; [] denies every app. Each entry non-empty, no leading or trailing whitespace, unique ignoring case |
browser.mode |
yes | off denies Chrome and Safari outright, all_sites allows any resolved URL, rules consults the lists below |
browser.default_policy |
yes | allow or block for a resolved URL that matches no rule under mode = "rules" |
browser.on_url_unavailable |
yes | allow or block when no http/https URL with a host could be bound to the window |
browser.block_auth |
yes | Block URLs whose path contains a representative sign-in, sign-up, OAuth, or password-reset segment. It is a URL pattern, not detection of every authentication screen: /docs/oauth matches too |
browser.block_payments |
yes | Block URLs whose path contains checkout, payment, payments, billing, subscription, or subscriptions |
browser.allow_list, browser.block_list |
yes | Arrays of { host, path_prefix, match_subdomains }. block_list wins over allow_list and over mode = "all_sites". host must already be canonical — the exact form URL.hostname produces, so lowercase ASCII/IDNA (xn--r8jz45g.xn--zckzah, not 例え.テスト), no port, no trailing dot, IPv6 in brackets. path_prefix is a literal, case-sensitive prefix with no surrounding whitespace |
ide.block_env_files |
yes | Block capture in Cursor, Visual Studio Code, and Code when the window title names a .env file. .env.example and similar are not blocked |
ide.on_file_name_unavailable |
yes | allow or block when no file name can be read from the window title |
Written by an embedding application that lets the user exclude specific apps, where the browser is
restricted to two sites and IDE .env files are never captured:
[filter]
exclude_apps = ["1Password", "Keychain Access", "com.tinyspeck.slackmacgap"]
include_only_apps = []
[filter.capture_policy] # no allowed_apps: exclude_apps above is the app gate
[filter.capture_policy.browser]
mode = "rules"
default_policy = "block"
on_url_unavailable = "block"
block_auth = true
block_payments = true
allow_list = [
{ host = "github.com", path_prefix = "", match_subdomains = false },
{ host = "example.com", path_prefix = "/docs", match_subdomains = true },
]
block_list = []
[filter.capture_policy.ide]
block_env_files = true
on_file_name_unavailable = "block"
The same policy in only mode — the user picked the two apps to record:
[filter]
include_only_apps = ["com.apple.Notes", "com.google.Chrome"]
And the pinned form, for a policy that must carry its own allow list instead of using the filter
lists:
[filter.capture_policy]
allowed_apps = ["Notes", "Google Chrome"]
[output]
| Key | Default | Description |
|---|---|---|
batch_interval_s |
5 |
Interval for flushing coalesced events and SQLite batches |
retention_hours |
48 |
Events older than this are purged at startup and periodically, and excluded from reads |
zanei record emits NDJSON as events are recorded. The daemon writes to the single local SQLite
store; neither output behavior nor the store backend is configurable.
Change propagation
[filter],[filter.text_content], and[filter.content_snapshot]changes are picked up by the daemon’s config watch (roughly 2-second intervals) and apply within a few seconds; no restart is needed.output.retention_hoursis picked up by the daemon without a restart and immediately purges events outside the new window. Other changes (e.g.capture.sources,capture.content_snapshot, andoutput.batch_interval_s) take effect after a daemon restart:zanei stop && zanei start.
Related paths
| Path | Purpose | Override |
|---|---|---|
~/.config/zanei/config.toml |
Configuration | --config <path> |
~/.local/state/zanei/store.sqlite |
Event store | --store <path> |
The event store is encrypted. Its key is generated on the recorder’s first start and kept in your login Keychain; there is no configuration key for encryption. ZANEI_STORE_KEY_FILE=<path> makes every Zanei process read the key from that file instead. It is a development override for builds from source, described in the CLI reference.