---
title: SMS via CLI (read, triage, and send texts from an AI agent)
---

# SMS via CLI (read, triage, and send texts from an AI agent)

## 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](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](/.claude/skills/omniscio-control/sms.md)) 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/:phoneNumber` and `GET /sms/scheduled` require 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`, `disconnect` apply 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 an `sms:conversation-updated` push so an open Omniscio window refreshes (a CLI mutation has no renderer to optimistically update).
- **Sending is approval-gated.** `POST /sms/send` is 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](cli-pending-actions.md), sharing the same open-pending cap. You can flip `requireApprovalForCliSmsSend` off (Settings → CLI Control → Approval requirements) to send immediately. The same gate covers `POST /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 an `sms.schedule_send` row and never schedules directly. Cancelling one is NOT gated: `POST /sms/scheduled/:id/cancel` applies 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

1. **Check setup with `GET /sms/status`.** If `configured` is 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 until `configured` is true.
2. **List conversations with `GET /sms/conversations`**, then read one thread with `GET /sms/conversations/:phoneNumber?limit=20`. Use `?before=<ISO>` to page older messages.
3. **Triage immediately** — `archive`, `snooze` (a future ISO timestamp), `dismiss`, `read`, `contact-name`. Each returns `200` and refreshes any open Omniscio window.
4. **Send a text with `POST /sms/send`** — body `{ phoneNumber, message }`. Send an `X-Client-Request-Id` (≤64 chars) so retries are idempotent in a 30-day window. The route returns `202` with the queued row; tell the user "open Omniscio's inbox and approve to send" and stop. **A `202` means queued, not sent.**

#### Approval + failure semantics (the important part)

- `POST /sms/send` returns **`202`** with a pending row when gated — the text transmits only after the user approves it in the inbox (or **`200`** immediately 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 same `X-Client-Request-Id` so 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

```bash
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](/src/main/services/cli/cli-server-sms-routes.ts), registered once at Omniscio startup from [src/main/app/startup/register-cli-routes.ts](/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-sms` functions directly, then emit the same `sms:conversation-updated` push the in-app handlers emit. CLI body schemas are `.strict()` (unknown keys → 400); `snooze` additionally 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](/src/main/services/sms/sms-operations.ts) module (`sendOperatorSms`, `debouncedSmsSync`) that both the IPC handlers and the CLI/dispatch path import. One definition each, no drift.
- **`POST /sms/send`** routes through the shared `gateOrApplyCliAction` chokepoint: it enqueues an `sms.send` row (or applies immediately when the toggle is off). The actual transmit happens in the [sms-send-handler.ts](/src/main/services/cli-action-handlers/sms-send-handler.ts) dispatch module, which inspects the send result — a delivery failure throws a `PermanentDispatchError` (so the row is rejected, never reported "applied", and never auto-retried).

Behavior is locked by [sms-cli-contract.md](/.claude/memory/contracts/sms-cli-contract.md) and tested in [cli-server-sms-routes.test.ts](/tests/unit/services/cli-server-sms-routes.test.ts) + [sms-send-handler.test.ts](/tests/unit/services/cli-action-handlers/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](set-up-sms-integration.md) page. Sending is approval-gated through a
shared queue described on [CLI pending actions](cli-pending-actions.md), and the server the calls
arrive on, together with its token handling, is on [CLI control](cli-control.md).

- [set-up-sms-integration.md](set-up-sms-integration.md) — the in-app SMS feature: Pushbullet pairing, the conversation view, snooze/archive.
- [cli-pending-actions.md](cli-pending-actions.md) — the shared approval queue `POST /sms/send` lands in.
- [cli-control.md](cli-control.md) — the CLI server itself, token handling, the full route catalog.
- [omniscio-control skill](/.claude/skills/omniscio-control/sms.md) — the external-AI side of the contract.