@chipallen2/snazi
v0.6.4
Published
On-demand local gate for your messages. Reveals WHO contacted you; only reveals WHAT for approved senders. iMessage today, pluggable channels next.
Maintainers
Readme
snazi — capability & messaging gate CLI
"No messages for you."
snazi is a capability gate for AI agents. It has two functions:
- Messaging gate — An on-demand CLI that reads your messages (iMessage, Gmail, Outlook) through pluggable channel adapters so an AI agent can be told who messaged without being able to read what they said — unless the sender is on the server's approved list.
- Capability gate — Any action an AI agent wants to take on your behalf (trades, payments, account changes) can be gated behind the same one-tap HMAC-signed
/decideapproval link pattern. Built-in: Schwab financial adapter. Built-in channel types: iMessage (macOS), Gmail (Gmail API), and Outlook (Microsoft Graph). The CLI runs on macOS, Windows, and Linux (Node 18+); each type works wherever its adapter does (iMessage is macOS-only; Gmail/Outlook are pure HTTPS, so they run anywhere).
You configure channels as named instances of a type, and you can have
several of the same type — e.g. a Personal and a Work Gmail, each with its
own approve/deny list. The --channel flag takes a channel id (slug) such as
imessage or gmail-work; it defaults to imessage.
The base CLI runs on demand — no launchd job, no background process. The
agent invokes it when needed. It stores nothing locally and the server stores no
message content. Message text is read live from the channel's local store (for
iMessage, ~/Library/Messages/chat.db) and printed only when the gate opens.
Optional serve mode runs a long-lived HTTP gate so your AI can run on a
separate agent machine and reach this messages machine over a private
tailnet. Run it in the background with one command — snazi start (plus
snazi stop / snazi restart) — which installs the right OS service for you
(launchd / systemd --user / a hidden Windows Scheduled Task). The agent
machine is then set up with snazi init-agent. See Serve mode below.
How the gate works
agent wants to know what's new
│
▼
snazi list-new ──► reveals WHO + status + label (approved/denied/unknown)
│ (never the message text)
│ unknown sender? agent texts you a one-tap /decide link
│ you tap Allow (or approve in the dashboard as a backup)
▼
snazi read <sender> ──► CLI asks server: is this sender approved?
├─ approved → prints message text
└─ otherwise → "No messages for you."The approval decision lives entirely on the server (Supabase-backed, per-account list). The CLI only checks it with a read-only token — it cannot approve a sender or reveal content for a non-approved one.
Install
macOS, Windows, or Linux (Node 18+):
npm install -g @chipallen2/snazi
snazi init # writes ~/.snazi/config.json (deployment URL + READ token)
snazi doctor # checks Node, config, connectivity, and channel accessThe npm package is scoped (
@chipallen2/snazi) but the command you run is justsnazi. (The bare namesnaziwas too close to an existing npm package to publish unscoped.)
snazi init asks for your deployment URL (default https://snazi.dev) and
your account READ token (sign up at /signup, then copy it from the
/account page), and then — if you want your AI on a separate agent machine
to reach this messages machine — offers to set up the always-on background
gate for you (the same thing as snazi start; say no to skip, which is right for
most people). There is no admin key — approvals happen in the dashboard or
via a signed /decide link. For agents/CI, skip the prompts (add --serve to
also install the background gate non-interactively):
snazi init --api-url https://snazi.dev --token <READ_TOKEN> --yesRunning your AI on a separate machine? This
snazi initsets up the messages machine. On the agent machine (where your AI runs), install snazi and runsnazi init-agentinstead — it asks for the messages machine's URL + connect token + your READ token (so the agent can mint approve links) and writes~/.snazi/config.jsonfor you. See Serve mode below.
macOS only — Full Disk Access. To read iMessage, grant Full Disk Access to
your terminal (or the node binary) in System Settings → Privacy & Security →
Full Disk Access. snazi doctor flags it if missing.
From source (contributors)
git clone https://github.com/chipallen2/snazi.git
cd snazi/packages/snazi
./install.sh # npm install + build, links `snazi` onto your PATH
# Windows (no bash): npm install && npm run build && npm link
snazi init && snazi doctorCommands
| Command | What it does |
| --- | --- |
| snazi init [--api-url <url>] [--token <tok>] [--channel <id>] [--serve] | Set up the messages machine. Create/update ~/.snazi/config.json. Offers to set up the background gate (or --serve to do it non-interactively). |
| snazi init-agent [--url <url>] [--token <tok>] [--read-token <tok>] [--api-url <url>] [--yes] | Set up an agent machine: writes remoteUrl + remoteToken + a read-only READ token (--read-token) so the agent can mint approve links, then pings the messages machine. No channel creds live here. |
| snazi doctor | Diagnose Node, config, connectivity, and channel access. |
| snazi list-new [--channel <id>] [--since <min>] [--fresh] | Distinct inbound senders, counts, timestamps, approval status, display label, and local Contacts contact_name. No text. Default window 60 min. |
| snazi read <sender> [--channel <id>] [--since <min>] [--fresh] | Message text for one sender — only if approved. Otherwise errors with No messages for you. |
| snazi send <recipient> --text <message> [--channel <id>] | Send a message. Never gated — you can always send to anyone. |
| snazi check <sender> --channel <id> [--fresh] | One sender's approval status, display label, and local Contacts contact_name (approved/denied/unknown). |
| snazi channels list | Configured channels (instances) + the channel types this build can drive locally. |
| snazi channels add <id> [--type <t>] [--name <n>] [auth flags] | Configure a channel instance locally (see Channels & email setup). |
| snazi cache clear | Drop cached approval statuses (force fresh checks after a revocation). |
| snazi status | Config path, apiUrl, masked read token, channels, server reachability. |
| snazi start [--bind <ip>] [--port <n>] | Run the gate in the background and auto-start it at login (macOS/Linux/Windows). Mints a connect token (serveToken) if missing and verifies /health. |
| snazi stop | Stop the background gate and remove its auto-start entry. |
| snazi restart [--bind <ip>] [--port <n>] | Restart the background gate (picks up config/bind/port changes). |
| snazi serve [--bind <ip>] [--port <n>] | Run the read-only HTTP gate in the foreground (no background service). See Serve mode below. |
| snazi serve --install-daemon [--bind <ip>] [--port <n>] | (advanced) Just write the launchd plist without loading it. Prefer snazi start. |
| snazi remote-status | Probe the messages machine's /health (remoteUrl). |
| snazi remote-list-new [--channel <id>] [--since <min>] | WHO messaged on the messages machine + status + label. |
| snazi remote-check <sender> --channel <id> | One sender's status and label, via the messages machine. |
| snazi remote-read <sender> [--channel <id>] [--since <min>] | Message text via the messages machine — only if approved. |
| snazi remote-send <recipient> --text <message> [--channel <id>] | Send a message via the messages machine — never gated. |
| snazi remote-resolve [<name>] --channel <id> | Resolve a name → sender address(es). Empty name = full address book. |
| snazi remote-label <sender> --name <name> --channel <id> | Set a sender's display label (UPDATE-only; cannot open the gate). |
| snazi remote-filter <create\|list\|get\|update\|delete> --channel <id> [flags] | Manage Gmail filters / Outlook rules on the messages machine. Never gated. update is Outlook-only (Gmail: delete + recreate). |
| snazi remote-calendar <list\|create> --channel <id> [flags] | Manage calendar events on the messages machine (Outlook today). OPEN/UNGATED — no approval link, unlike Schwab. See Calendar events below. |
All output is JSON. Approval status is cached on disk for a short TTL (default 5
min; set checkCacheTtlMs in config or SNAZI_CHECK_CACHE_TTL_MS). Pass
--fresh on read/check/list-new to bypass the cache, or run snazi cache clear
right after you revoke someone.
Examples
snazi list-new --since 180
# [
# { "sender": "+15551234567", "message_count": 3,
# "latest_at": "2026-06-23T22:10:04.000Z", "status": "unknown",
# "label": null, "contact_name": "Jenny Tutone" }
# ]
snazi read "+15551234567"
# { "error": "Sender not approved. No messages for you.", "status": "unknown" }
# Approve in the dashboard, or mint a one-tap /decide link with your read token:
curl -s -H "x-api-key: $READ_TOKEN" \
"https://snazi.dev/api/decide-link?channel=imessage&sender=%2B15551234567&label=Mom"
# { "url": "https://snazi.dev/decide?owner=…&channel=imessage&sender=%2B15551234567&exp=…&sig=…", … }
# Tap Allow on that link, then:
snazi read "+15551234567"
# { "sender": "+15551234567", "status": "approved", "since_minutes": 60,
# "messages": [ { "date": "...", "text": "hey are we still on for lunch?" } ] }
snazi send "+15551234567" --text "On my way!"
# { "ok": true, "channel": "imessage", "recipient": "+15551234567" }Channels & email setup
A channel type (imessage, gmail, outlook) defines the adapter +
transport. A channel is a named instance of a type — and you can have many
of the same type. Each channel has its own approve/deny list, keyed by its
id (slug).
A channel lives in two places:
- On the server (dashboard → Channels): register the channel's name + type. The server generates the slug and shows it. This is what gives the channel its own list. No credentials are ever stored on the server.
- On the messages machine (
~/.snazi/config.json): the channel's id (= that slug), type, and credentials. The id must match the dashboard slug so the gate checks the right list.
config.json channels is an array of instances:
{
"apiUrl": "https://snazi.dev",
"apiKey": "<your READ token>",
"channels": [
{ "id": "imessage", "type": "imessage", "name": "iMessage" },
{
"id": "gmail-work",
"type": "gmail",
"name": "Work",
"auth": {
"clientId": "XXXX.apps.googleusercontent.com",
"clientSecret": "GOCSPX-…",
"refreshToken": "1//0g…"
}
},
{
"id": "gmail-personal",
"type": "gmail",
"name": "Personal",
"auth": { "clientId": "…", "clientSecret": "…", "refreshToken": "…" }
}
]
}The legacy form (
"channels": ["imessage"], an array of type strings) is still accepted and treated as{ id, type, name }instances.
Then read/send against a specific channel by its id:
snazi list-new --channel gmail-work --since 1440
snazi read "[email protected]" --channel gmail-work
snazi send "[email protected]" --channel gmail-work \
--text $'Subject: Re: lunch\n\nSounds good!'send treats the text as the email body. To set a subject, start the text with
a Subject: … line followed by a blank line (as above); otherwise the subject
defaults to (no subject). You can also set the subject explicitly with
--subject "…", which takes precedence over any Subject: line.
Send as an alias (--from)
Email channels can override the From address with --from <address> when the
sending account has that address configured as a verified "send mail as"
alias (Gmail/Outlook require a one-time confirmation of the alias). This lets
one authenticated account send on behalf of another verified address:
snazi remote-send "[email protected]" --channel gmail-personal \
--from "[email protected]" --subject "Hi" --text "body"Works with plain text and HTML. If the alias is not verified on the account,
the provider rejects or rewrites the From header. Omit --from to send from
the account's own address.
HTML email
Email channels (gmail, outlook) can send HTML messages. Pass the HTML
body with --html-file <path> (or --html-text "<html>") and set the subject
with --subject:
snazi send "[email protected]" --channel gmail-work \
--subject "Weekly report" --html-file report.html
# remote (agent machine -> messages machine):
snazi remote-send "[email protected]" --channel gmail-work \
--subject "Weekly report" --html-file report.html- Gmail sends a proper
multipart/alternativemessage (a plaintext part plus the HTML part), so clients that can't render HTML still show readable text. - Outlook sends the body with
contentType: HTML. - The plaintext alternative is auto-derived from the HTML unless you also pass
--text "…"to supply your own. - Non-email channels (e.g. iMessage) ignore HTML and send the plaintext body.
- HTML is text-only, no attachments; keep bodies reasonable (server caps the
/sendbody at 512 KiB).
Reply and thread (real email threading)
A plain send with a Subject: Re: … line creates a brand-new email - it
does NOT thread under the original conversation in the recipient's client. To
send a genuine, threaded reply, pass --reply-to <messageId> (add --reply-all
to also address everyone on the original To + Cc, minus your own address):
# 1) Read the conversation - each message row now includes its native id.
snazi remote-read "[email protected]" --channel gmail-work --since 1440
# -> messages: [ { id: "18f...", direction: "incoming", text: "..." }, ... ]
# 2) Reply to a specific message id (usually the latest incoming one).
# No Subject: line needed - it is auto-derived (single "Re: " prefix).
snazi remote-send "[email protected]" --channel gmail-work \
--reply-to "18f..." --text "Sounds good, see you then."
# 3) Reply to everyone on the original thread (To + Cc, minus you):
snazi remote-send "[email protected]" --channel gmail-work \
--reply-to "18f..." --reply-all --text "Confirming for the group."- Gmail builds RFC 5322
In-Reply-To/Referencesheaders from the original message and sets itsthreadIdon send, so the reply threads in Gmail and other clients. The subject is reused with a singleRe:prefix (never doubled) unless you override it with--subject. - Outlook uses Microsoft Graph's native
/replyand/replyAllendpoints, which handle subject, threading, and quoting automatically. Known limitation: Graph reply does not honor a--fromalias override, so--fromis ignored on an Outlook reply (the reply-to id is preferred). - Works with
--html-file/--html-texttoo (the HTML becomes the reply body). - Without
--reply-to, aSubject: Re: …send does NOT thread - only use that pattern for a genuinely new/unrelated email, never call it a reply. - iMessage has no reply concept here;
--reply-tois ignored on that channel.
Forward a specific message (real forward, new thread)
Pasting the original text into a brand-new email is NOT a forward - the
recipient's client shows it as an unrelated message, with no "Fwd:" marker, no
original headers, and no original attachments. To send a genuine forward of a
specific message, pass --forward-msg <messageId> (the id comes from a
remote-read row, same as --reply-to). Any --text becomes your forwarding
comment, shown above the quoted original:
# 1) Read the conversation - each message row includes its native id.
snazi remote-read "[email protected]" --channel gmail-chip --since 10080
# -> messages: [ { id: "18f...", direction: "incoming", text: "..." }, ... ]
# 2) Forward that message to someone else, with an optional comment.
snazi remote-send "[email protected]" --channel gmail-chip \
--forward-msg "18f..." --text "FYI, looping you in on this."- Gmail: builds a real MIME forward - subject gets a single
Fwd:prefix (never doubled), a standard---------- Forwarded message ---------header block (From/Date/Subject/To) is included below your comment, followed by the original body. The original attachments are re-attached best-effort (each is fetched individually and re-encoded as a MIME part); if a given attachment fails to fetch, that one attachment is silently dropped rather than failing the whole forward. A forward always starts a new thread (nothreadIdis set), unlike a reply. - Outlook: uses Microsoft Graph's native
/forwardendpoint, which preserves the original message and its attachments automatically - no MIME reconstruction needed. Same known limitation as reply: Graph's/forwarddoes not honor a--fromalias override. - Works with
--html-file/--html-texttoo (the HTML becomes the forwarding comment). - iMessage has no forward concept here;
--forward-msgis ignored on that channel.
Credentials live ONLY on the messages machine
OAuth tokens / secrets are written to ~/.snazi/config.json (created 0600) and
are never sent to the snazi server. You can hand-edit that file, or set them
with channels add (which never echoes secrets back):
snazi channels add gmail-work --type gmail --name Work \
--client-id <id> --client-secret <secret> --refresh-token <token>Gmail (Gmail API + OAuth2)
New to Google OAuth? n8n has an excellent step-by-step walkthrough for creating the Cloud project, OAuth consent screen, and client credentials: docs.n8n.io → Google OAuth2 (Custom OAuth2). Follow it to get your client id, client secret, and refresh token, then come back here. The snazi-specific bits:
- In Google Cloud Console: create a project, enable the Gmail API, and create an OAuth client (Desktop app gives you a client id + secret).
- Authorize the scopes
https://www.googleapis.com/auth/gmail.readonly(read) andhttps://www.googleapis.com/auth/gmail.send(send), and obtain a refresh token for the mailbox (e.g. via the OAuth Playground or your own consent flow). - Configure it locally:
snazi channels add gmail-work --type gmail --name Work \ --client-id <id> --client-secret <secret> --refresh-token <token>
Outlook / Microsoft 365 (Microsoft Graph + OAuth2)
New to Azure OAuth? n8n has a step-by-step walkthrough for registering the app and generating a client secret: docs.n8n.io → Microsoft credentials. Follow it to get your client id and client secret, then come back here. The snazi-specific bits:
- In the Azure portal: register an app, add a client secret, and grant the
delegated scopes
offline_access+ mail read/send (Mail.ReadorMail.ReadWrite, plusMail.Send). - Obtain a refresh token for the mailbox.
- Configure it locally:
snazi channels add outlook-work --type outlook --name Work \ --client-id <id> --client-secret <secret> --refresh-token <token> \ --tenant <tenant-id> --user [email protected]
--tenant matters. It defaults to common, which only works for
multi-tenant apps. If your app is single-tenant (the common case, and what
Azure creates by default), you must pass your Directory (tenant) ID (a GUID
from the app's Overview page) or a verified domain (e.g.
yourcompany.onmicrosoft.com) — otherwise the token refresh fails with
AADSTS50194.
Scopes are inherited. On refresh, snazi requests no specific scopes, so the
access token carries whatever the refresh token was originally granted (this is
why tokens exported from tools like n8n work as-is). Set auth.scope only if you
want to narrow them.
snazi doctor and snazi channels list report, per channel, whether its
credentials are configured and the adapter is usable on this machine.
Serve mode — least-privilege HTTP gate over a tailnet
Sometimes your AI runs on a different machine (the agent machine) than the
one signed into iMessage (the messages machine). SSH would work, but SSH
grants a full shell — far more than "let me read approved messages." snazi
serve exposes only the gated, read-only operations over HTTP so the agent
machine gets least privilege.
Agent machine (snazi init-agent) Messages machine (iMessage Mac)
┌────────────────────┐ Tailscale tailnet ┌──────────────────────┐
│ snazi remote-read │ ───── HTTP ─────────► │ snazi serve │
│ (bearer token) │ 100.x:8787 │ /health (no auth) │
└────────────────────┘ │ /list-new (bearer) │
│ /check (bearer) │
│ /read (bearer) │
│ /resolve (bearer) │
│ POST /label (bearer) │
│ POST /send (bearer) │
│ │ │
│ ▼ same gate (api) │
│ approved? → text │
│ else → "No │
│ messages │
│ for you." │
└──────────────────────┘Endpoints (all JSON)
| Method + path | Auth | Returns |
| --- | --- | --- |
| GET /health | none | { ok: true, version } — connectivity probe only, no data. |
| GET /list-new?channel=imessage&since=<min> | bearer | { channel, since_minutes, senders: [{ sender, message_count, latest_at, status, label, contact_name }] }. On check failure: status is unknown and an error field describes the failure. Never message text. |
| GET /check?sender=<addr>&channel=imessage | bearer | { channel, sender, status, label, contact_name }. On check failure: HTTP 502 with { error }. |
| GET /read?sender=<addr>&channel=imessage&since=<min> | bearer | { sender, channel, status, since_minutes, contact_name, messages } only if approved; otherwise 403 { error: "Sender not approved. No messages for you.", status }. On check failure: HTTP 502 with { error }. |
| POST /send body { recipient, channel, text, subject?, html?, from? } | bearer | Send an outbound message. Never gated — you can always send to anyone. subject sets the email subject; html sends an HTML email (Gmail multipart/alternative, Outlook contentType HTML) with text as the plaintext alternative (auto-derived from html when omitted); from overrides the From address (email channels only, requires a verified send-as alias on the account). Body cap 512 KiB. Returns { ok: true, channel, recipient } on success. |
| GET /resolve?name=<q>&channel=imessage | bearer | { channel, query, matches: [{ sender_address, label, status, contact_name }] }. Empty/omitted name returns every labelled sender. Never message text. |
| POST /label body { sender, channel, name } | bearer | Set a sender's display label via an UPDATE-only web endpoint. Cannot create a row or change status, so it cannot open the gate. 404 if the sender is not on the list yet. |
| POST /action body { sender?, messageId?, channel, action, sinceMinutes? } | bearer | Perform archive\|delete\|markRead\|markUnread on one message (messageId) or every message from a sender in a window. Never gated. |
| POST /filter/create body { channel, from?, to?, subject?, query?, action?, labelId?, forwardTo?, folderId?, name?, criteria?, actions? } | bearer | Create a Gmail filter / Outlook rule. Never gated. |
| GET /filter/list?channel=<id> / GET /filter/get?channel=<id>&id=<id> | bearer | List / get filters or rules. |
| PATCH /filter/update?channel=<id>&id=<id> | bearer | Update a rule in place. Outlook only — Gmail has no update API, returns 405 (delete + recreate). |
| DELETE /filter/delete?channel=<id>&id=<id> | bearer | Delete a filter/rule. |
| GET /calendar/list?channel=<id> | bearer | { channel, count, calendars: [{ id, name, isDefault? }] } — calendars available on this account (Outlook today). |
| POST /calendar/create body { channel, calendar, subject, start, end?, allDay?, timeZone? } | bearer | Create a calendar event. calendar may be an id or a case-insensitive name (resolved against /calendar/list; ambiguous name → 409, unknown name → 404). Never gated — fully open, unlike Schwab. See Calendar events below for the all-day date rules. |
There is no approve/deny over HTTP. Approvals stay dashboard//decide-only.
POST /label is the only write that can touch the approval list's display
metadata (never status). Filter, action, and calendar writes are real
mutations on the underlying provider (Gmail/Outlook) but are never gated by
the approval list — the soup nazi only blocks reading messages. Unknown
path → 404. Bad params → 400. Unsupported methods → 405.
contact_name — local macOS Contacts enrichment (display only)
/list-new, /check, /resolve (and the 200 body of /read) include a
contact_name for each sender: the matching name from the messages machine's
local macOS Contacts (AddressBook), looked up by phone/email. It is attached for
every sender regardless of approval status, so you can see who an
unknown/denied caller is without reading their messages.
- Display-only, never a gate.
contact_namenever affectsstatus, approval, or the read gate. Reading is still allowed solely whenstatus === 'approved'— a known contact name does not open the gate. - Separate from
label.label= the name you set on your snazi.dev account (privileged).contact_name= read locally from macOS Contacts. Both fields are kept separate in the JSON;nullwhen there's no match. - Untrusted text. A contact name is stripped of control characters and length-capped (≤64) before it's ever returned — it can't carry a terminal/log-injection payload.
- Degrades silently. If Contacts is unreadable (no permission, non-macOS,
native module missing),
contact_nameis simplynulland nothing breaks.
Contacts access on the messages machine. Reading the AddressBook DB needs the
node binary to have Contacts access (or Full Disk Access, which already
covers the AddressBook database). Full Disk Access is the simplest option since
you already grant it for iMessage; without it contact_name just stays null.
Security model
- Tailnet-only. Default bind is this host's Tailscale IP (
100.64.0.0/10) if present, else127.0.0.1. It never binds0.0.0.0—--bind 0.0.0.0is refused. For loopback bind, front it withtailscale serveto reach it on the tailnet over HTTPS. - Bearer token required on every endpoint except
/health. The token isserveTokenin config; comparison is constant-time (SHA-256 +timingSafeEqual) and the token is never logged. - Read-only surface. No shell, no arbitrary file reads, no path traversal —
only the same channel adapters the CLI uses. Params are validated
(
channel/sendercharset-checked,sinceclamped to ≤ 7 days). - Same gate for reading.
/readcalls the server list API (api.ts) before touching any text — identical tosnazi read. The gate is the product; it is not bypassed. Sending is never gated —/sendandsnazi sendwork for any recipient. - No storage. Content is read live from
chat.dband returned in the response only. Nothing is persisted on either side.
Config keys
On the messages machine, snazi start mints serveToken for you. To set
keys by hand in ~/.snazi/config.json:
{
"serveToken": "<openssl rand -hex 32>",
"serveBind": "100.64.0.10", // optional; default = tailnet IP else 127.0.0.1
"servePort": 8787 // optional; default 8787
}On the agent machine, snazi init-agent writes these for you (or set them
by hand):
{
"remoteUrl": "http://100.64.0.10:8787",
"remoteToken": "<same value as serveToken on the messages machine>",
"apiUrl": "https://snazi.dev",
"apiKey": "<your READ token>"
}apiUrl + apiKey hold your READ token so the agent can mint one-tap
approve links itself — snazi init-agent asks for it (or pass --read-token
<tok>). The READ token is read-only: it can check the list and mint links, but
can't read content or approve/deny, so it can't open the gate. If you leave it
out, the agent still reads approved messages but you approve new senders by hand
in the dashboard.
Run it
# Foreground (binds tailnet 100.x if present, else 127.0.0.1):
snazi serve
# Explicit bind/port:
snazi serve --bind 100.64.0.10 --port 8787Run it in the background (start / stop / restart)
One command per OS — no service-manager incantations:
snazi start # install + run in the background, auto-start at login
snazi stop # stop it and remove the auto-start entry
snazi restart # restart (e.g. after editing config or granting FDA)
snazi initalso offers to do this for you at the end of setup (or passsnazi init --serveto set it up non-interactively), so you don't have to know the command exists.
snazi start figures out the right background service for your platform and
manages it for you:
| Platform | What start sets up | Auto-start | Restart-on-crash |
| --- | --- | --- | --- |
| macOS | launchd LaunchAgent (~/Library/LaunchAgents/com.soup-nazi.snazi-serve.plist) | at login | yes (KeepAlive) |
| Linux | systemd --user unit (~/.config/systemd/user/snazi.service) | at login¹ | yes (Restart=on-failure) |
| Windows | hidden Scheduled Task (snazi-serve, launched via wscript) | at logon | — |
It also mints a connect token (serveToken) for you if one isn't set (saved
to ~/.snazi/config.json and printed once — give it to the agent machine via
snazi init-agent), then polls /health so it can tell you whether the gate
actually came up. snazi status shows the live service state any time:
snazi status
# { ..., "service": { "manager": "launchd", "installed": true,
# "state": "loaded", "bind": "100.x.y.z", "port": 8787,
# "healthy": true, "version": "0.2.1" } }¹ A systemd --user service stops when you log out unless you enable lingering:
loginctl enable-linger $USER (snazi start prints this hint).
macOS — Full Disk Access (required for iMessage). A background launchd job
runs in a context that cannot read ~/Library/Messages/chat.db unless the
node binary has Full Disk Access. snazi start prints the exact node path;
add that binary (not just Terminal) in System Settings → Privacy &
Security → Full Disk Access, then snazi restart. Without FDA, /list-new
and /read return an FDA error — the gate still holds; you just get no data.
(Gmail/Outlook channels are pure HTTPS and need no FDA, so serve mode is useful
on Linux/Windows too.)
snazi serve --install-daemon just writes the plist without loading it, for
people who want to drive launchctl themselves:
snazi serve --install-daemon # writes ~/Library/LaunchAgents/com.soup-nazi.snazi-serve.plist
launchctl load -w ~/Library/LaunchAgents/com.soup-nazi.snazi-serve.plist # start
launchctl unload -w ~/Library/LaunchAgents/com.soup-nazi.snazi-serve.plist # stopCalling it
From the agent machine, either use the thin client subcommands:
snazi remote-status
snazi remote-list-new --since 120
snazi remote-check "+15551234567" --channel imessage
snazi remote-read "+15551234567"
snazi remote-send "+15551234567" --text "On my way!"
snazi remote-resolve "Dan" --channel imessage
snazi remote-label "+15551234567" --name "Dan" --channel imessage…or plain curl (bearer token in the header, never logged server-side):
TOKEN=... # the serveToken
BASE=http://100.64.0.10:8787
curl -s "$BASE/health"
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/list-new?channel=imessage&since=120"
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/check?sender=%2B15551234567&channel=imessage"
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/read?sender=%2B15551234567&channel=imessage"
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/resolve?name=Dan&channel=imessage"
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"sender":"+15551234567","channel":"imessage","name":"Dan"}' "$BASE/label"
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"recipient":"+15551234567","channel":"imessage","text":"On my way!"}' "$BASE/send"
# Unknown/denied sender on /read → 403 { "error": "Sender not approved. No messages for you.", ... }Capability Adapters
snazi is not just a messaging gate — it is a capability gate. Any action an AI agent wants to take on your behalf can be gated behind the same one-tap HMAC-signed /decide link pattern.
Architecture
Your AI agent
│
│ POST /schwab/action { type, payload, description }
│
┌────────────────────────────────────────┐
│ snazi serve (capabilities machine) │ ← Schwab creds in Keychain
└───────────┬──────────────────┬─────────┘
│ │
│ mint action link │ execute (after approval)
▼ ▼
┌───────────────────────────┐
│ snazi.dev (web tier) │ ← sna_actions table
│ /api/action-link │
│ /decide?a=<code> ──────►│── one-tap approve / deny
└───────────────────────────┘Schwab adapter
The Schwab adapter reads credentials from the macOS Keychain on the serve host — no secrets in config files or environment variables. Set up:
# Store your Schwab OAuth tokens (run on the messages machine)
security add-generic-password -s snazi-schwab-client-id -a snazi -w "<client-id>"
security add-generic-password -s snazi-schwab-client-secret -a snazi -w "<client-secret>"
security add-generic-password -s snazi-schwab-refresh-token -a snazi -w "<refresh-token>"Once set, the serve daemon exposes:
| Method + path | Auth | Returns |
| --- | --- | --- |
| GET /schwab/accounts | bearer | All accounts + positions. Read-only, no approval gate. |
| GET /schwab/transactions?accountNumber=&from=&to= | bearer | Transactions for one account in a date range. from required (ISO 8601). |
| POST /schwab/action body { type, payload, description } | bearer | Mints a signed action-approval link via snazi.dev. Returns { url, code }. The action is NOT executed until approved. |
From the agent machine:
# Read accounts (no approval needed — read-only)
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/schwab/accounts"
# Read transactions
curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE/schwab/transactions?accountNumber=XXXX1234&from=2026-01-01"
# Request an action (returns an approval link — nothing executes until Chip taps it)
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"type":"transfer","payload":{"from":"checking","to":"brokerage","amount":500},"description":"Move $500 to brokerage"}' \
"$BASE/schwab/action"
# { "url": "https://snazi.dev/decide?a=abc12345", "code": "abc12345" }
# Send that URL to the user — action only runs after they tap Approve.Adding your own capability adapter
- Add
packages/snazi/src/adapters/<name>.ts— export functions, read secrets from Keychain. - Add endpoints to
packages/snazi/src/server.tsfollowing the Schwab pattern. - For write operations, always go through
POST /schwab/action→ snazi.dev action-link flow so the user gets a one-tap approve/deny.
Why this design
- No prompt-injection surface from strangers. The agent never sees content from unknown senders, so a malicious text can't smuggle instructions to it.
- No message storage anywhere. Cheap and private. The server is a list, not an inbox.
- One-tap approval for any action. The same signed
/decidelink pattern used for sender approvals works for any capability — trades, payments, config changes. The agent proposes; you approve. - Extensible. The same server list API works for other channels (e.g.
Gmail) — just add a channel and a new wrapper that calls
/api/senders/check.
