Skip to content
Zanei
English
Esc
↑↓navigate↵open⌘Jpreview
On this page

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_monitoring counts as required when the configuration captures input.* or ui.click; when neither input nor ui is selected, its absence still yields ok.

--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 add accepts a display name or bundle ID, resolves it against zanei 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. --unverified explicitly saves an unresolved value with a warning.
  • add with no value opens a numbered, recently-used-first selector. Non-TTY use and --quiet require a value; omission exits with code 2. remove resolves against the current list, including entries for apps no longer installed.
  • filter show displays 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-app or removing it from exclude-app warns unless --quiet is 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.

Was this page helpful?