CLI reference
Every zanei command, flag, shared convention, and exit code.
All functionality lives in a single binary, zanei: daemon control, permission diagnostics, data retrieval, filter management, agent setup, and the MCP server. The CLI and the MCP server are thin wrappers over the same core and store, and share the conventions on this page.
- Signed, notarized single Rust binary; no external runtime.
- Strictly local: the CLI performs no network egress of any kind.
- Apart from the destructive operation (
purge), commands have minimal side effects and are safe for agents to call.
Commands at a glance
| Command | Summary |
|---|---|
doctor |
Diagnose macOS permissions and recorder health; guide granting |
start |
Start background recording (launchd) |
stop |
Stop recording and unregister (data kept) |
pause / resume |
Temporarily suspend / resume recording |
status |
Daemon state, event counts, store info |
record |
Foreground capture to stdout/file (debug & piping) |
query |
Retrieve raw events with filters |
timeline |
LLM-ready, token-budgeted timeline |
export |
Bulk dump of raw events |
purge |
Manually delete stored events (destructive) |
apps |
List apps available for filter selection |
filter |
Manage capture-time allow/deny lists |
config |
Initialize, show, locate, or edit configuration |
mcp |
Run the stdio MCP server |
setup |
Configure agent instructions and MCP integration |
Global options
Valid for every subcommand.
| Flag | Description |
|---|---|
--config <path> |
Override config file path (default ~/.config/zanei/config.toml) |
--store <path> |
Override store path (default ~/.local/state/zanei/store.sqlite) |
--json |
Select JSON output where supported; see Output formats |
-q, --quiet |
Suppress progress output and notices |
-v, --verbose |
Verbose logging to stderr |
--version / --help |
Version / help |
Environment
| Variable | Description |
|---|---|
ZANEI_STORE_KEY_FILE=<path> |
Read the store’s encryption key from this file (64 hexadecimal characters) instead of the login Keychain; the recorder creates the file (mode 0600) and its directory if they are missing. A development override for builds from source, whose ad-hoc code signature changes on every build and would otherwise trigger Keychain dialogs. Not for everyday use: the key sits on disk |
Shared conventions
Time expressions
Values accepted by --since / --until:
| Form | Example | Meaning |
|---|---|---|
| Relative | 15m 2h 1d 1w |
Looking back from now; units s m h d w |
| Absolute | 2026-08-16T09:00:00Z |
RFC3339 timestamp |
| Keyword | now |
Current moment (mainly for --until) |
--until defaults to now. --since defaults per command: timeline = 1h, query = 15m, export = 24h.
Event types
--types takes a comma-separated list of event types, with trailing wildcards allowed (browser.*).
Output formats
Commands with a --format option support these values:
| Value | Commands | Description |
|---|---|---|
jsonl |
query, export, record |
One raw event per line (machine-readable) |
json |
query, export, timeline |
Single JSON document. query and export are event arrays; timeline is the structured timeline object |
md |
timeline |
LLM-ready Markdown (default) |
table |
query |
Human-readable table |
sqlite |
export |
Plaintext SQLite snapshot of the store for the range; requires --out |
Diagnostic/state commands use a separate fixed-output convention:
| Commands | Machine-readable output | Without --json |
|---|---|---|
status, doctor |
Global --json only; these commands do not accept --format |
Fixed human-readable output |
Exit codes
| Code | Meaning |
|---|---|
0 |
Success. Background start also uses this code when the recorder is alive but its permission snapshot remains pending after the 20-second wait; it notes that dialogs may take a moment to appear and advises running zanei doctor --fix if none are visible |
1 |
General error. status also uses this code for every store_* state, including store_locked |
2 |
Usage error (invalid arguments) |
3 |
Confirmed missing permissions. Background start uses this code only after the running recorder reports a permission snapshot with a missing required TCC permission; doctor uses the same code for its diagnostic result |
4 |
No daemon (nothing to act on for status, stop, …) |
doctor
Diagnoses recorder health and the granted state of required TCC permissions (Accessibility / Input Monitoring / Automation) against the configured sources and content opt-ins.
zanei doctor # human-readable; shows granting steps and the System Settings pane if anything is missing
zanei doctor --fix # interactively opens each relevant System Settings pane (granting is still a user action)
zanei doctor --json # machine-readable, for agents
| Flag | Description |
|---|---|
--fix |
Interactively open each missing permission’s System Settings pane |
--json |
Machine-readable output |
--fix handles missing permissions one pane at a time. Before opening the next pane, it waits for
Return; the final pane does not add another wait. Combining --json --fix does not disable this
walkthrough: Zanei prints the JSON report first and then enters the same interactive flow when a
required permission is missing. In that combination, stdout is therefore not JSON-only.
- Exits with code 3 if any required permission is missing.
input_monitoringcounts as required when the configuration capturesinput.*orui.click; when neitherinputnoruiis selected, its absence still yieldsok.
--json output:
{
"ok": false,
"reported_by_recorder": true,
"capture_sources": ["app", "window", "ui", "input", "browser"],
"capabilities": {
"read_accessibility_tree": {
"state": "available", "required": true,
"required_for": ["window.focus", "window.title", "ui.focus", "ui.click", "ui.value", "content.snapshot"],
"detail": { "platform": "macos", "permission": "accessibility", "status": "granted", "settings_url": "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" }
},
"observe_input": {
"state": "action_required", "required": true,
"required_for": ["input.key", "input.scroll", "clipboard.copy", "clipboard.paste", "ui.click"],
"detail": { "platform": "macos", "permission": "input_monitoring", "status": "denied", "settings_url": "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent" }
},
"automate_browser": {
"state": "deferred", "required": true,
"detail": { "platform": "macos", "permission": "automation", "status": "not_determined", "settings_url": "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation", "target_bundle_id": "com.google.Chrome" }
},
"automate_safari": {
"state": "deferred", "required": true,
"detail": { "platform": "macos", "permission": "automation", "status": "not_determined", "settings_url": "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation", "target_bundle_id": "com.apple.Safari" }
}
},
"store_key": { "state": "key_store", "detail": "the login Keychain (item \"Zanei store key\")" },
"health": {
"state": "degraded",
"degraded": { "chrome": "state=unavailable phase=query kind=apple_event code=-1712" },
"collector_failures": { "chrome": 3 }
}
}
Capability state values are available / action_required / deferred. The nested macOS detail preserves the native permission name and granted / denied / not_determined status, and owns the relevant System Settings URL and optional target bundle ID. reported_by_recorder is true when the result came from a fresh recorder heartbeat. Without one, doctor probes from its own process, sets this field to false, and the human output notes that the recorder must be started to see its own permissions.
store_key reports where the store’s encryption key is. state is key_store (the key was found; detail names the place, either the login Keychain or the ZANEI_STORE_KEY_FILE development override), not_needed (the store is missing, plaintext, or not recognized), missing (the store is encrypted but there is no key), mismatch (the key does not decrypt this store), key_store_locked or key_store_denied (detail carries the platform’s advice, for example that the login Keychain is locked), or unavailable; detail is present only when there is more to report. Human-readable output adds a Store key: ... line after the permission table. doctor does not fail when the store is locked: it reports the key state and probes permissions from its own process instead.
Recorder health
health.state classifies only the evidence available from the store lock and persisted recorder status:
| State | Meaning |
|---|---|
healthy |
The current store owner matches the recorder status, the status is running, and there are no current degraded reasons |
degraded |
The current store owner matches the running recorder status, which contains one or more current degraded reasons |
stopped |
The store has readable status but no current owner |
stale |
The owner matches the persisted recorder instance, but its status is not accepted as current (status.running is false) |
suspected_unavailable |
A store owner exists, but the persisted recorder instance does not match it |
status_unreadable |
Checking, opening, or reading the selected store status fails |
status_missing |
The selected store does not exist |
The health object has these fields:
| Field | JSON type | Meaning |
|---|---|---|
state |
string | One of the states above |
degraded |
object<string, string> | null |
Current component/reason map. It is empty when readable evidence does not belong to the current owner, and null when status is missing or unreadable |
collector_failures |
object<string, integer> | null |
Persisted cumulative failures by collector. Recovery does not reset these counts. It is null when status is missing or unreadable |
status_error |
string, omitted otherwise | Selected-store inspection or status-read failure detail; present only for status_unreadable |
Human-readable output adds the following fixed lines after Store key::
| Line | Value |
|---|---|
COLLECTOR HEALTH |
health.state, followed by one indented component: reason line per current degraded entry |
indented status: detail |
Present only for status_unreadable |
COLLECTOR FAILURES none |
Status is readable and the cumulative map is empty |
COLLECTOR FAILURES - |
Status is missing or unreadable |
COLLECTOR FAILURES |
Followed by one indented component: count line per cumulative entry |
Control characters in human-readable component names, reasons, and status errors are printed as visible escapes. Recorder health does not change doctor’s exit policy: code 3 still means that a required permission is missing. A degraded health state by itself does not make doctor fail.
Human-readable output always ends with the next action. When a required permission is denied, it
walks through granting it: run zanei start so the recorder asks macOS for the permission, then
follow the dialog and System Settings guidance. Accessibility adds the bundled app as Zanei, but
Input Monitoring may omit the row even after the dialog grant takes effect. After restarting, the
recorder-reported doctor result, not the list, is authoritative. The manual + walkthrough
copies the Zanei.app root when running from a bundle and the executable path when running as a raw
binary; use Command-Shift-G (or a single ~) to select that path. A manually added bundle entry
persists. See the permissions guide for the complete setup and removal flow.
When Chrome Automation is not_determined, no advance
action is needed: macOS asks the first time Zanei contacts Chrome. An unsigned or ad-hoc-signed
build also produces a warning that rebuilding will reset granted permissions.
start
Registers Zanei with launchd and starts background recording. If an agent is already registered, Zanei waits up to 10 seconds for launchd to finish unregistering it before registering the agent again. For background starts, Zanei first waits up to 10 seconds for the daemon to report that it is alive. After that, the permission check described below may take up to another 20 seconds. At recorder startup, required missing Accessibility and Input Monitoring permissions are requested once. Automation remains deferred until the first real Apple Event to Chrome.
zanei start # start background recording
zanei start --foreground # run in the foreground instead (dev/debug)
| Flag | Description |
|---|---|
--foreground |
Don’t daemonize; run in the foreground |
After the recorder reports that it is alive, background start rereads the recorder’s permission snapshot once per second for up to 20 seconds while it remains pending. At the 5-second mark, it prints Waiting for the recorder's permission check... once to stderr unless --quiet is set. If the snapshot becomes available with all required permissions granted, start prints the normal success message and, when eligible, asks the capture.text_content opt-in question. If it reports missing required permissions, start prints the same granting guidance as doctor and exits with code 3. Only when the snapshot is still pending after 20 seconds does start report that the recorder is still waiting for the macOS permission dialogs. It notes that dialogs may take a moment to appear and advises running zanei doctor --fix if none are visible, then exits with code 0 because the recorder is alive. The recorder remains running; after granting permissions, run zanei stop && zanei start and confirm with zanei doctor. --foreground runs the same recorder startup request logic in the foreground process.
When capture.text_content is not explicitly present in config.toml, the first successful interactive background start for which the recorder has reported all required permissions as granted asks:
Record typed text and clipboard contents too? They stay in the local store like everything else (48-hour retention), password fields are always excluded, and Chrome Incognito text is never captured. You can change this anytime: zanei config set capture.text_content <true|false> [y/N]
Before bootstrapping, start uses the persisted last-known recorder permission report. If that report does not cause the question, start bootstraps the recorder, waits for it to become alive, and checks the current recorder report. The CLI process’s local permission probe is never used for this decision because it reflects the terminal’s TCC identity rather than the recorder’s.
Only y or Y enables capture; Enter, any other answer, EOF, or a read error selects false. Zanei saves the explicit value through the same path as config set, preserves the rest of the file, and prints Text content will be recorded. or Text content stays off.. If the question was asked after bootstrap, a y answer also prints Restarting the recorder to apply text content capture... and restarts through the canonical stop-then-start path. A false answer needs no restart because it preserves the recorder’s default-off behavior. Because either answer writes the key, the question is not repeated.
Immediately after this question is answered, start prints the following separate opt-in guidance exactly. It does not ask a content-snapshot y/N question:
Content snapshots (text shown in apps you choose) are a separate opt-in. Choose the apps first
(zanei filter content-snapshot only-app add <APP>, or exclude-app), then enable it with
zanei config set capture.content_snapshot true.
If capture.text_content was already explicit and the question is not shown, this guidance is not shown either. The permanent entry point is the CONTENT SNAPSHOT status line.
The question is not shown by --foreground, with non-TTY stdin or stderr, with --quiet or --json, while permissions are missing, or when permission state cannot be determined. A skipped start leaves the setting undecided and keeps the existing opt-in guidance. If the key is already explicitly true or false, start does not ask.
On its first start, the recorder generates the store’s encryption key and saves it in your login Keychain as “Zanei store key”. A plaintext store written by 0.2.x or earlier is not rewritten: the recorder renames it to store.sqlite.plaintext-<timestamp>, starts a fresh encrypted store, and reads keep returning the old events alongside the new ones until they age out of retention (see the FAQ). The recorder never shows Keychain dialogs: if the login Keychain is locked, start fails with store is locked: your login Keychain is locked; unlock it (for example by opening Keychain Access) and try again.
Only one recorder can own a store at a time. A second foreground or launchd recorder for the same store exits with code 1 and reports the PID of the current owner.
After a background start succeeds, the confirmation states that Zanei is registered as a launchd background item. A macOS notification or an entry under Login Items & Extensions is expected behavior.
stop
Stops the recorder instance that owns the selected store. A launchd recorder is unregistered; a
foreground recorder receives SIGTERM. Not destructive: stored data is kept. Exits with
code 4 if there is no recorder to stop.
Stopping uses two bounded stages in order: it waits up to 10 seconds for the selected store owner to disappear, then, for a launchd recorder, up to another 10 seconds for launchd registration to be removed. A timeout in either stage is an error.
zanei stop
pause
Temporarily suspends recording without unregistering the daemon.
zanei pause --for 30m # auto-resume after 30 minutes
zanei pause # paused until `resume`
| Flag | Description |
|---|---|
--for <TIME> |
Duration; omit to pause indefinitely |
resume
Resumes recording after a pause.
zanei resume
status
Reports daemon state, capture configuration, and store statistics.
zanei status
zanei status --json
Human-readable output shows the current app/site scope next to enabled TEXT CONTENT and CONTENT SNAPSHOT lines. Disabled lines show their explicit activation commands, so both privacy-sensitive opt-ins are visible without requesting JSON. Examples are TEXT CONTENT on (apps: exclude 6, sites: exclude 0) and CONTENT SNAPSHOT off (opt-in: zanei config set capture.content_snapshot true).
{
"state": "running",
"running": true,
"paused": false,
"since": "2026-08-16T08:00:00.000Z",
"instance": "4242@2026-08-16T08:00:00.000Z",
"mode": "launchd",
"uptime_s": 3600,
"events_captured": 12345,
"events_dropped": 2,
"collector_failures": { "eventtap": 1 },
"last_event_ts": "2026-08-16T08:59:58.000Z",
"heartbeat_freshness": "fresh",
"heartbeat_age_s": 2,
"last_event_age_s": 4,
"store_write_state": "healthy",
"degraded": {},
"store": { "path": "~/.local/state/zanei/store.sqlite", "size_bytes": 5242880, "retention_hours": 48, "oldest_event_ts": "2026-08-14T09:00:00.000Z", "encryption": "sqlcipher" },
"capture": { "sources": ["app", "window", "ui", "input", "browser"], "text_content": false, "content_snapshot": false },
"permissions_ok": true
}
The JSON object always has the following fields. integer values are non-negative.
| Field | JSON type | Meaning and null condition |
|---|---|---|
state |
string | running when the store lock has an owner; stopped when it does not. Inspection failures use store_missing, store_unavailable, store_corrupt, or store_locked (the store is encrypted but cannot be opened with the key; degraded.store holds the reason). Never null |
running |
boolean | Whether the lock has an owner. Never null, including in a store_* state |
paused |
boolean | null | For a readable store, true only when a lock owner exists, its heartbeat is fresh, and its pause request is active. A stale, future, or missing heartbeat and a readable stopped store report false. Null when database contents cannot be read |
since |
string | null | Current owner’s RFC3339 start timestamp; null when there is no owner |
instance |
string | null | Current owner identity, composed from PID and start timestamp; null when there is no owner |
mode |
"foreground" | "launchd" | null |
Current owner’s lifecycle mode; null when there is no owner |
uptime_s |
integer | null | Seconds since the current owner started; null when there is no owner |
events_captured |
integer | null | Persisted cumulative captured count; null when database contents cannot be read |
events_dropped |
integer | null | Persisted cumulative delivery loss from backpressure, a full queue, disconnection, or similar failure. It excludes unattributable out-of-scope input and unflushed crash/SIGKILL loss. Null when database contents cannot be read |
collector_failures |
object<string, integer> | null |
Persisted cumulative failure counts by collector; an empty object means no recorded failures. Null when database contents cannot be read |
last_event_ts |
string | null | RFC3339 timestamp of the latest event; null when no event exists or database contents cannot be read |
heartbeat_freshness |
"fresh" | "stale" | "future" | "missing" | null |
For a readable store: fresh when the elapsed whole-second age is 0 through 15 inclusive, stale when it is greater than 15, future when the timestamp is ahead of the clock, and missing when absent. Null only when database contents cannot be read |
heartbeat_age_s |
integer | null | Heartbeat age in seconds, clamped to zero for a future timestamp; null when the heartbeat is absent or database contents cannot be read |
last_event_age_s |
integer | null | Latest-event age in seconds, clamped to zero for a future timestamp; null when no event exists or database contents cannot be read |
store_write_state |
"healthy" | "suspected_unavailable" | "heartbeat_stale" | "stopped" | null |
healthy requires a lock owner and its matching fresh heartbeat. No owner yields stopped. A mismatched owner/heartbeat, or a matching non-fresh heartbeat with no newer event, yields suspected_unavailable; the remaining matching-owner/non-fresh case with a newer event yields heartbeat_stale. Null when database contents cannot be read |
degraded |
object<string, string> |
Current component/reason map only when the persisted instance matches the current owner; otherwise empty. A store inspection failure is reported under store. Never null |
store.path |
string | Selected store path. Never null |
store.size_bytes |
integer | null | File size when metadata is available; null when unavailable or the file does not exist |
store.retention_hours |
integer | null | With a readable store, the recorder’s value when its matching heartbeat is fresh, otherwise the current configuration value. Null when database contents cannot be read |
store.oldest_event_ts |
string | null | Oldest retained event timestamp; null when there are no events or database contents cannot be read |
store.encryption |
"sqlcipher" | "plaintext" | null |
sqlcipher for an encrypted store; plaintext for a store written before encryption existed, which the recorder sets aside on its next start; null when the store is missing or unreadable |
store.retired_plaintext |
string[] | Plaintext stores set aside at the encryption upgrade that reads still include, oldest first; empty once they have aged out. Human-readable output shows each as a PREVIOUS STORE line. One that cannot be read is skipped and reported under degraded.retired_store |
capture.sources |
string[] |
Sources from the current configuration. Never null |
capture.text_content |
boolean | Text-content opt-in from the current configuration. Never null |
capture.content_snapshot |
boolean | Frontmost-window content-snapshot opt-in from the current configuration. Independent from capture.sources; never null |
permissions_ok |
boolean | Available permission snapshot from a fresh heartbeat whose instance matches the current owner; otherwise, including when the snapshot is absent or the heartbeat is stale, future, or missing, a local probe. Never null |
Human-readable output is a fixed set of lines rather than a table selected with --format:
| Line | Value |
|---|---|
STATE |
state |
PAUSED |
paused, or - when null |
SINCE |
since, or - when null |
INSTANCE |
instance, or - when null |
MODE |
mode, or - when null |
EVENTS CAPTURED |
events_captured, or - when null |
EVENTS DROPPED |
events_dropped, or - when null |
LAST EVENT |
last_event_ts, or - when null |
HEARTBEAT |
Freshness plus (<N>s old) when age exists; - when freshness is null |
STORE WRITES |
store_write_state, or - when null |
STORE |
store.path, followed by (encrypted) or (plaintext; the recorder encrypts it on its next start) when store.encryption is not null |
TEXT CONTENT |
on (apps: <exclude|only> N, sites: <exclude|only> N) or off (opt-in: zanei config set capture.text_content true) |
CONTENT SNAPSHOT |
on (apps: <exclude|only> N, sites: <exclude|only> N) or off (opt-in: zanei config set capture.content_snapshot true) |
PERMISSIONS OK |
true or false |
COLLECTOR FAILURES |
none for an empty map; - when the store cannot be read; otherwise followed by one indented component: count line per entry |
DEGRADED |
false for an empty map; otherwise true followed by one indented component: reason line per entry |
JSON fields not listed in this human-output table are not printed in human mode.
status exits with code 0 for running, 4 for stopped, and 1 for every store_* state.
For store_corrupt, preserve the damaged file and create a fresh store:
zanei stop
mv ~/.local/state/zanei/store.sqlite ~/.local/state/zanei/store.sqlite.corrupt
zanei start
Use the configured store path instead of the default path when applicable. The moved file remains available for investigation; the new store is empty.
store_locked with a missing or mismatched key is recovered the same way: the new store is created with the existing key, or with a new key if none exists. Losing the key loses at most the retention window (48 hours by default) of data. When degraded.store says the login Keychain is locked, unlock it and retry instead of moving the store. The recorder never shows Keychain dialogs, so zanei start fails while the Keychain is locked; zanei status and zanei query may show the macOS unlock dialog. zanei doctor reports the key state.
record
Foreground capture: streams raw events as NDJSON to stdout (or a file) without daemonizing. Intended for piping and experiments; use start for everyday recording.
zanei record --stream # NDJSON to stdout
zanei record --out events.jsonl # to a file
| Flag | Description |
|---|---|
--stream |
Stream events to stdout as they occur |
--out <FILE> |
Write to a file instead |
--format jsonl |
Output format (NDJSON) |
query
Retrieves raw events matching the given conditions. Machine-readable, structured output.
zanei query --since 15m --types browser.navigate,app.activate
zanei query --since 2h --app Safari --format json --limit 500
| Flag | Description |
|---|---|
--since / --until |
Time range (default --since 15m) |
--types <TYPE,...> |
Event-type filter (comma-separated; browser.* wildcards allowed) |
--app <NAME> / --bundle-id <ID> |
Filter by app name / bundle ID |
--limit <N> |
Max events (default 500) |
--format |
jsonl (default) / json / table |
These are query-time filters: they narrow reads, not what gets recorded (see filters).
When --types is omitted, content.* is excluded. Request --types content.snapshot or --types content.* explicitly to read snapshot bodies; other families can be listed alongside it. --format json remains an array of event objects. If stored rows use unknown event types, Zanei skips them and warns with the count on stderr; global --quiet suppresses that warning.
timeline
Splits raw events into sessions, deduplicates, coalesces, and serializes an LLM-ready summary within a token budget. This is the command agents call most.
zanei timeline --since 1h --format md --token-budget 4000
zanei timeline --since 30m --format json --granularity fine
| Flag | Description |
|---|---|
--since / --until |
Time range (default --since 1h) |
--format |
md (default; LLM-ready Markdown) / json (structured) |
--token-budget <N> |
Approximate token cap (default 4000, minimum 34); content is coarsened to fit |
--granularity |
coarse (default; per session) / fine (per interaction) |
Default --format md output (abridged; activity phrases and headings are emitted exactly as shown):
# Zanei timeline
Range: 2026-08-16T08:00:00.000Z — 2026-08-16T09:00:00.000Z
Estimated tokens: 3810
Truncated: no
## 2026-08-16T08:12:00.000Z — 2026-08-16T08:31:00.000Z · Safari
Title: Reviewing PR #42
- Browsed 3 pages on github.com
- Edited text in "Reviewing PR #42"
Content snapshots: 3
With --format json, the same session is represented by range, token_estimate, truncated,
skipped_unknown_types, and sessions; session activities use the same fixed phrases. Every JSON session includes content_snapshots, including 0. Markdown prints Content snapshots: N after the activity lines only when the count is nonzero. Snapshot bodies are never inlined. JSON sessions always include
event_ids_truncated and include up to 100 event_ids when the token budget permits. event_ids
may be omitted before sessions are removed.
Ranges that contain only snapshots, including snapshots before the first ordinary event, still emit metadata-backed sessions with empty activity and event lists.
export
Dumps every raw event in range (backup / external processing).
zanei export --since 24h --format jsonl --out dump.jsonl
zanei export --since 24h --format sqlite --out snapshot.sqlite
zanei export --since 24h --types app.*,window.*,ui.*,input.*,browser.*,clipboard.* --format sqlite --out share.sqlite
| Flag | Description |
|---|---|
--since / --until |
Time range (default --since 24h) |
--types <TYPE,...> |
Optional event-type filter, applied to jsonl, json, and sqlite |
--format |
jsonl / json / sqlite |
--out <FILE> |
Output file; required for --format sqlite |
Export includes every event type by default in all formats, including content.snapshot; unlike query, an omitted --types does not hide content. --format json remains an array of event objects. JSON/JSONL export skips unknown event types and warns with the count on stderr unless --quiet is set.
--format sqlite writes a plaintext SQLite snapshot with the same tables as the live store (events, daemon_state, daemon_capabilities, meta) for the requested range; retention applies like every read. SQLite export copies matching rows without decoding them, so unknown types are copied rather than skipped. --out is required (usage error, code 2, without it). The file is created owner-only (mode 0600) and never overwritten: an existing path fails with code 1 and snapshot file already exists at PATH; choose another --out path. On success it prints Wrote a plaintext SQLite snapshot with N events to PATH (suppressed by --quiet). The snapshot is not encrypted and contains snapshot bodies in plaintext when content.* is selected. Treat it like a sensitive JSONL export; use --types to select only the families intended for sharing or external processing. It opens in sqlite3, DB Browser for SQLite, any language’s SQLite bindings, and zanei query --store snapshot.sqlite.
purge
Deletes stored events manually, independent of retention-based auto-purge. Destructive.
zanei purge --before 24h # delete events older than 24 hours
zanei purge --all # delete everything (confirmation prompt; suppress with --quiet)
zanei purge --types '*' # also delete everything (same confirmation as --all)
zanei purge --types content.* --before 24h
zanei purge --types content.* --app Slack
zanei purge --types content.* --bundle-id com.tinyspeck.slackmacgap
| Flag | Description |
|---|---|
--before <TIME> |
Delete events older than this |
--types <TYPE,...> |
Delete only matching event types; wildcards allowed. Unscoped '*' asks for the same confirmation as --all |
--app <NAME> / --bundle-id <ID> |
Delete only events matching the app; mutually exclusive |
--all |
Delete everything (asks for confirmation) |
--types can be combined with --before and one app selector, for example to remove snapshots from an app after excluding it. An unscoped --types '*' is a universal selection and requires the same confirmation as --all unless --quiet is set; narrower type patterns such as content.* do not prompt. This deletion is irreversible and has no dry-run mode; the command reports the applied scope and deleted count. When the store does not exist, purge prints Purged 0 events and does not create one. Set-aside plaintext stores from the encryption upgrade are purged too: scoped options delete matching rows, while --all deletes the files.
apps
Lists apps available for filter selection from installed apps, running apps, and app.activate events still in the store.
zanei apps
zanei apps slack
zanei apps slack --json
The optional QUERY is a case-insensitive substring match on name or bundle ID. The table contains NAME, BUNDLE ID, SOURCES (installed, running, recent), and LAST USED. Recently used apps come first, then other running apps, then installed apps. No result is success (code 0) with No apps match "QUERY". on stderr. No TCC permission is needed, and the command works while the daemon is stopped.
--json returns { "apps": [{ "name", "bundle_id", "path", "installed", "running", "last_used" }], "recent_unavailable": null, "installed_unreadable": 0 } in the same order. If the store is absent or cannot be opened, installed and running apps remain available and recent_unavailable contains the reason. App bundles with missing, unreadable, or malformed Info.plist files are skipped; installed_unreadable reports their count, and a nonzero count also prints warning: N app bundles could not be read to stderr.
filter
Manages three capture-time scopes: [filter] for all events, [filter.text_content] for typed/copied bodies, and [filter.content_snapshot] for snapshot events. The command shape is zanei filter [<scope>] <list> add|remove [VALUE]; <scope> is omitted, text-content, or content-snapshot.
zanei filter show
zanei filter exclude-app add 1Password
zanei filter text-content exclude-app add Slack
zanei filter content-snapshot only-app add Terminal
zanei filter content-snapshot exclude-site add mail.google.com
zanei filter content-snapshot exclude-app add FutureApp --unverified
| List | Config suffix | Meaning |
|---|---|---|
exclude-app |
exclude_apps |
Exclude these apps; active in both exclude and only mode |
only-app |
include_only_apps |
A non-empty list selects only these apps |
exclude-site |
exclude_websites |
Exclude these Chrome and Safari URL hosts; active in both modes |
only-site |
include_only_websites |
A non-empty list selects only these Chrome and Safari URL hosts |
- Every app-list
addaccepts a display name or bundle ID, resolves it againstzanei apps, and saves the bundle ID when available. An unresolved value is not saved and exits with code 2, with a close candidate when available.--unverifiedexplicitly saves an unresolved value with a warning. addwith no value opens a numbered, recently-used-first selector. Non-TTY use and--quietrequire a value; omission exits with code 2.removeresolves against the current list, including entries for apps no longer installed.filter showdisplays all three scopes, each apps/sites mode and count, resolved names, and(not installed)for unresolved entries. It also lists hard-coded built-in exclusions separately.- Both content scopes default to excluding Safari, Firefox, Brave, Edge, Vivaldi, and Arc. Adding one with
only-appor removing it fromexclude-appwarns unless--quietis set, because private-window detection is unavailable. The defaults are editable. - Site rules apply to Chrome and Safari. Changes apply on the next configuration-watch cycle, normally within 2 seconds; no restart is required.
The exact evaluation order and the fields removed by each scope are in the filters guide.
config
zanei config init # create a fully commented configuration template
zanei config path # print the config file path
zanei config show # print the effective configuration (defaults merged)
zanei config edit # open the config in $EDITOR
zanei config set capture.text_content true
zanei config set capture.content_snapshot true
config init writes a complete, commented template containing every supported option and its
current default. By default it creates ~/.config/zanei/config.toml; the global
--config <path> flag selects a different destination. Missing parent directories are created. On
success, the command prints the created path and exits with code 0. If the destination already
exists, it leaves the file unchanged, reports the existing path, and exits with code 1.
config set <DOTTED_KEY> <VALUE> validates and saves one scalar setting. Supported keys and values:
| Key | Accepted values |
|---|---|
capture.text_content |
true, false |
capture.content_snapshot |
true, false |
output.batch_interval_s |
Unsigned integer greater than zero |
output.retention_hours |
Unsigned integer greater than zero |
Array settings are not accepted: use zanei filter for filter lists, or
zanei config edit for other arrays. Unknown keys, array keys, and invalid values exit with
code 2 without saving. output.retention_hours is reloaded by a running daemon and immediately
applied to retention purging. If the daemon is running, a successful capture.text_content, capture.content_snapshot, or
output.batch_interval_s change prints:
Restart recording with `zanei stop && zanei start` for this to take effect.
config set capture.content_snapshot true first prints the current app/site scope and waits for [y/N], with N as the default. The scope summary reflects the effective lists, and the deletion interval comes from the loaded output.retention_hours; with its default value, the prompt is:
Content snapshots record the text shown in the frontmost window, including messages and
documents written by other people and text you typed that is on screen. Password fields and
Chrome Incognito windows are never captured; stored text is redacted and deleted after 48 hours.
Current scope (change it first if this is not what you want):
Apps: every app except 6 excluded (Safari, Firefox, Brave, Edge, Vivaldi, Arc)
Sites: every site
zanei filter content-snapshot only-app add <APP> record only these apps
zanei filter content-snapshot exclude-app add <APP> everything except these
zanei apps list apps to choose from
Enable content snapshots with this scope? [y/N]
N, Enter, or EOF leaves the file unchanged. With non-TTY input, --quiet is required to write; without it Zanei prints the scope summary to stderr, exits with code 2, and does not save. After choosing the scope with filter content-snapshot, an agent can use zanei config set capture.content_snapshot true --quiet, must state what will be recorded, and then restart. Setting the value to false does not ask for confirmation.
Schema: see the configuration reference.
mcp
Runs the stdio MCP server: JSON-RPC on stdin/stdout, with no network access. Started by MCP clients (Claude Code, Codex, opencode, Claude Desktop, …), not usually by hand.
zanei mcp [--store <PATH>]
- Read-only view over the store; independent from the recording daemon.
- Exposed tools:
get_timeline/query_events/get_status— see the MCP reference.
setup
Configures Zanei instructions and MCP integration for the target agent. Every agent that runs the CLI gets the skill file; Claude Desktop’s chat gets only its MCP registration, and opencode’s MCP JSON is printed for you to place.
zanei setup --agent claude # SKILL.md into Claude Code's skill location + MCP registration
zanei setup --agent codex --scope user
zanei setup --agent hermes # skill into ~/.hermes/skills/ + MCP in ~/.hermes/config.yaml
zanei setup --agent pi # skill into ~/.pi/agent/skills/ or .pi/skills/; no MCP
zanei setup --agent claude-desktop # MCP registration in claude_desktop_config.json
zanei setup --agent opencode # skill into opencode's skill location + MCP JSON printed
| Flag | Description |
|---|---|
--agent |
claude / codex / opencode / hermes / pi / claude-desktop |
--scope |
project (default; current repository) / user (account-wide) |
--print |
Preview planned file changes without writing |
Per-agent behavior and setup output: see agent setup.