SMS via CLI (read, triage, and send texts from an AI agent)
Reading, triaging and sending texts from outside the app, over Omniscio's local control server, so an AI agent or a shell script can work your SMS inbox. Covers the three tiers the routes are split into, the workflow a caller follows, the approval gate that stands in front of sending, and what is deliberately left out.
What it is
Omniscio's SMS integration — texts sent through your selected SMS provider (Pushbullet, or your own phone via the native provider), described in set-up-sms-integration.md — is reachable from outside Omniscio over the same 127.0.0.1:19519 localhost HTTP server that exposes cron jobs, tags, recipes, and settings. An external AI agent (Claude Code in another project, a curl script, the omniscio-control skill) holding the Omniscio bearer token can check the connection status, list conversations and read a thread, triage the SMS inbox, and send a text — all from a shell.
The gating model splits the 18 routes across three tiers by blast radius:
- Reads are bearer-gated and read-budgeted.
GET /sms/status,GET /sms/devices,GET /sms/conversations,GET /sms/conversations/:phoneNumberandGET /sms/scheduledrequire the bearer token and share the 60-reads-per-minute-per-token-hash budget with every other auth-gated GET on this server. They expose private message content, so they always require the token (same tier as the search endpoints). - Triage mutations apply immediately.
read,archive/unarchive,snooze/unsnooze,dismiss,contact-name,sync,connect,disconnectapply inline with no inbox row (bearer + the global 10-mutations-per-minute bucket). Each is reversible personal state — mirrors the in-app SMS view — and emits ansms:conversation-updatedpush so an open Omniscio window refreshes (a CLI mutation has no renderer to optimistically update). - Sending is approval-gated.
POST /sms/sendis the one outbound, irreversible action: it counts against your monthly SMS limit and texts a real person. By default it lands as a pending row in Omniscio's inbox (preview shows the number + a message snippet) and only transmits when you click Approve — the same user-in-the-loop guarantee as the other approval-gated CLI capabilities in cli-pending-actions.md, sharing the same open-pending cap. You can fliprequireApprovalForCliSmsSendoff (Settings → CLI Control → Approval requirements) to send immediately. The same gate coversPOST /sms/scheduled, which stages a future text: a scheduled send IS a send, so leaving it open would be an approval bypass (schedule 31 seconds out and skip the gate) — it lands ansms.schedule_sendrow and never schedules directly. Cancelling one is NOT gated:POST /sms/scheduled/:id/cancelapplies immediately, because cancelling is reversible and de-escalating, and it is idempotent (it pushes only on the live-to-deleted transition).
Deliberately not exposed (by design): setting the Pushbullet token (it's a secret — configure it in the desktop SMS settings), choosing the sending device (setup; it also opens the realtime socket — desktop only), and the two AI helpers (reply suggestions, thread summary — they call a paid model). Use the desktop app for those.
Where to find it
This one has no screen of its own. The routes are called from outside the app by anything holding a token for Omniscio's local control server — a Claude Code session in another project, a curl script, or an external AI. The texts themselves are the same ones the in-app SMS view shows, and anything sent this way turns up there too.
How it behaves
How to use it
The 18 routes:
| Method | Path | Gating | Purpose |
|---|---|---|---|
GET |
/sms/status |
read | Connection + config status (configured, hasToken, connected, monthly usage — the monthly cap is Pushbullet-only; native has no quota). |
GET |
/sms/devices |
read | List Pushbullet devices that can send SMS (Pushbullet-only — native has no device list). With no Pushbullet token saved it answers 422 UNPROCESSABLE ("Pushbullet API token not configured") — a normal not-set-up answer, not an error to retry. |
GET |
/sms/conversations |
read | List conversations (?includeArchived=true to include archived). |
GET |
/sms/conversations/:phoneNumber |
read | Messages in one thread (?limit 1–500 default 50, ?before ISO timestamp). |
POST |
/sms/conversations/:phoneNumber/read |
immediate | Mark the conversation read. |
POST |
/sms/conversations/:phoneNumber/archive |
immediate | Archive + clear inbox attention. |
POST |
/sms/conversations/:phoneNumber/unarchive |
immediate | Un-archive. |
POST |
/sms/conversations/:phoneNumber/snooze |
immediate | Snooze until a future ISO-8601 timestamp. Body: { snoozedUntil }. |
POST |
/sms/conversations/:phoneNumber/unsnooze |
immediate | Clear the snooze. |
POST |
/sms/conversations/:phoneNumber/dismiss |
immediate | Clear inbox attention ({ restore: true } re-flags it). |
PATCH |
/sms/conversations/:phoneNumber/contact-name |
immediate | Set/clear the contact name. Body: { name } (empty string clears). |
POST |
/sms/sync |
immediate | Pull latest threads from the phone (debounced 15s; coalesces concurrent callers). |
POST |
/sms/connect |
immediate | Open the Pushbullet realtime socket (no-op without a token). |
POST |
/sms/disconnect |
immediate | Close the realtime socket (reconnectable). |
POST |
/sms/send |
approval | Send a text. Body: { phoneNumber, message }. Returns 202 + pending row by default. |
POST |
/sms/scheduled |
approval | Schedule a future text. Body: { phoneNumber, message, scheduledFor }. Same gate as /sms/send; returns 202 + pending row. |
GET |
/sms/scheduled |
read | List every live (enabled, not deleted) scheduled SMS. |
POST |
/sms/scheduled/:id/cancel |
immediate | Cancel a scheduled SMS (soft-delete). Idempotent; pushes only on the live-to-deleted transition. |
All require Authorization: Bearer <token>. :phoneNumber is the conversation's stored key (usually E.164, e.g. +15551234567); URL-encode it (%2B for +) and pass the exact key from /sms/conversations — the triage routes do not re-normalize it.
Workflow
- Check setup with
GET /sms/status. Ifconfiguredis false (no token / no device), the user must finish SMS setup in the desktop app — the CLI can't set the token or pick the device. A send will be rejected untilconfiguredis true. - List conversations with
GET /sms/conversations, then read one thread withGET /sms/conversations/:phoneNumber?limit=20. Use?before=<ISO>to page older messages. - Triage immediately —
archive,snooze(a future ISO timestamp),dismiss,read,contact-name. Each returns200and refreshes any open Omniscio window. - Send a text with
POST /sms/send— body{ phoneNumber, message }. Send anX-Client-Request-Id(≤64 chars) so retries are idempotent in a 30-day window. The route returns202with the queued row; tell the user "open Omniscio's inbox and approve to send" and stop. A202means queued, not sent.
Approval + failure semantics (the important part)
POST /sms/sendreturns202with a pending row when gated — the text transmits only after the user approves it in the inbox (or200immediately if they turned approval off).- A failed send is never reported as sent. If Pushbullet can't deliver (phone offline, rate-limited, auth expired), the approval is rejected with a plain-language reason — the message did not go out.
- A send is never auto-retried — a retry could text a real person twice. If a send errors, the row goes to
rejected; reuse the sameX-Client-Request-Idso a deliberate re-POST is de-duplicated rather than doubling the text.
Idempotency
POST /sms/send accepts an X-Client-Request-Id header (≤64 chars). The same id within a 30-day window returns the existing pending row (HTTP 200, idempotent: true) instead of queuing a duplicate, scoped to the sms.send action kind. The triage mutations are reversible and need no idempotency key.
Examples
TOKEN=$(<~/.amc/cli-token)
BASE=http://127.0.0.1:19519
# 1. Is SMS set up + connected?
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/sms/status"
# 2. Recent conversations, then one thread (URL-encode the + as %2B)
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/sms/conversations"
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/sms/conversations/%2B15551234567?limit=20"
# 3. Triage (applies immediately)
curl -s -X POST -H "Authorization: Bearer $TOKEN" "$BASE/sms/conversations/%2B15551234567/archive"
curl -s -X POST "$BASE/sms/conversations/%2B15551234567/snooze" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"snoozedUntil":"2026-06-09T09:00:00.000Z"}'
# 4. Send a text — lands in the inbox for approval (default). 202 = queued, NOT sent.
curl -s -X POST "$BASE/sms/send" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "X-Client-Request-Id: sms-send-$(date +%s)" \
-d '{"phoneNumber":"+15551234567","message":"On my way!"}'
Out of scope (v1)
- Setting the Pushbullet token (
set-token) — it's a secret; configure it in the desktop SMS settings, never over the CLI. - Choosing the sending device (
select-device) — setup that also auto-opens the realtime socket; desktop-only. - AI reply suggestions + thread summaries — they call a paid Anthropic model, so they're kept off the CLI (the same rule that keeps mind-map AI generation off the CLI).
For agents
How it works
The 18 routes live in src/main/services/cli/cli-server-sms-routes.ts, registered once at Omniscio startup from src/main/app/startup/register-cli-routes.ts. Every handler reuses the SAME Pushbullet service + message DB the in-app SMS IPC handlers use, so the CLI and the desktop UI can't drift:
- Reads + triage call the shared
pushbullet-service/queries-smsfunctions directly, then emit the samesms:conversation-updatedpush the in-app handlers emit. CLI body schemas are.strict()(unknown keys → 400);snoozeadditionally requires a real, future ISO-8601 timestamp (stricter than the loose in-app schema, so a malformed value can't land in the DB). - The two orchestrated operations — sending (transmit + clear inbox attention + track) and the debounced sync — live in a shared sms-operations.ts module (
sendOperatorSms,debouncedSmsSync) that both the IPC handlers and the CLI/dispatch path import. One definition each, no drift. POST /sms/sendroutes through the sharedgateOrApplyCliActionchokepoint: it enqueues ansms.sendrow (or applies immediately when the toggle is off). The actual transmit happens in the sms-send-handler.ts dispatch module, which inspects the send result — a delivery failure throws aPermanentDispatchError(so the row is rejected, never reported "applied", and never auto-retried).
Behavior is locked by sms-cli-contract.md and tested in cli-server-sms-routes.test.ts + sms-send-handler.test.ts.
Related
The in-app feature these routes drive — pairing, the conversation view, snooze and archive — is on the set up SMS integration page. Sending is approval-gated through a shared queue described on CLI pending actions, and the server the calls arrive on, together with its token handling, is on CLI control.
- set-up-sms-integration.md — the in-app SMS feature: Pushbullet pairing, the conversation view, snooze/archive.
- cli-pending-actions.md — the shared approval queue
POST /sms/sendlands in. - cli-control.md — the CLI server itself, token handling, the full route catalog.
- omniscio-control skill — the external-AI side of the contract.
Last verified 2026-09-28