# imessage
An iMessage and SMS channel for the Vellum assistant.
## What it does
Makes the assistant reachable by text message. People text a line the assistant
listens on, and it answers over iMessage where the recipient supports it and SMS
otherwise. The assistant can also send on request, via the `imessage` skill.
## What it does not do
It does not read the user's personal iMessage account, history, or existing
threads.
## Bring your own line
You supply the line. Photon is the default. Linq and Comms are also
selectable:
| Provider | You need | Where |
| --- | --- | --- |
| **Photon** (default) | A project ID and project secret | [photon.codes](https://photon.codes) |
| **Linq** | A V3 API token | [dashboard.linqapp.com/sandbox](https://dashboard.linqapp.com/sandbox) |
| **Comms by Osis** | A Messages API key | [comms.osis.co](https://comms.osis.co) |
For Photon, the `imessage-setup` skill can connect the project through
Photon's device login (approve a short code in the browser) and store the
project id and secret itself. The settings app and a terminal still work as
a manual fallback:
```bash
assistant credentials set --service imessage --field photon_project_id <id>
assistant credentials set --service imessage --field photon_project_secret <secret>
```
Bringing your own is the only option. A provider-neutral line from us is not on
offer: dedicated lines cost roughly $250/line/month from the vendors that sell
them, and a shared line cannot give anyone a stable number, so the economics are
unresolved.
The `imessage-setup` skill walks through the whole thing, including inbound.
## Provider APIs
Each vendor has shapes that cost real time to rediscover, so they are written
down here rather than left in the code.
**Photon runs two planes with two protocols.** The control plane
(`spectrum.photon.codes`) is REST with `Basic base64(projectId:projectSecret)`,
and every response is enveloped as `{ succeed, data }` — a failure can arrive
inside a 200, so the envelope is what gets checked. It handles projects, the
token mint, webhooks, and users. The message plane
(`imessage.spectrum.photon.codes:443`) is **gRPC only** and answers 415 with an
empty body to anything else, including a bodiless `GET /`. Its REST routes are
real but belong to `imessage-server-v2-http`, a middleware Photon publishes as
software rather than hosting, so reaching the hosted plane means the vendor SDK.
`src/providers/photon/message-client.ts` is the only file that imports it.
Send, listing, and the live inbound stream all share that one gRPC channel.
**A Photon project may only message people it knows.** Anyone else is refused
at the message plane with `Target not allowed for this project`, which reads
like a bad address but is a policy answer. `POST /projects/{id}/users/`
registers a recipient; it is idempotent. The plugin calls it when the webhook
is registered (every contact phone number the assistant already knows) and from
`skills/imessage-setup/scripts/allow.ts` for a number allowed by hand. A cold
send does **not** register first — that control-plane call was blocking
messages the plane would have delivered — and only runs if `createChat` is
refused with the target-not-allowed error. A shared project allocates each user
their own `assignedPhoneNumber` out of a pool, which is the concrete reason a
shared line cannot promise anyone a stable number.
**A Photon webhook's `signingSecret` is returned once** and is never
retrievable, so a lost one means delete and re-register.
**Comms** (`osis.co/api/v1/comms`) is one REST API with bearer auth and the
scopes `comms_send` / `comms_read` / `comms_webhooks`, fixed at key creation.
It signs deliveries with `X-Osis-Signature: sha256=<hmac of the raw body>`,
using a secret returned when the webhook is registered. Its published docs
specify the message object as `{ id, body, direction }` only, so anything
beyond that is marked `UNVERIFIED` in `src/providers/comms/schemas.ts` and is
optional: a wrong guess degrades rather than drops a message.
**Linq** (`api.linqapp.com/api/partner/v3`) is one REST API with bearer auth.
A cold send is `POST /messages` with `to` and no `from`, so Linq picks the
line. A reply to a known chat is `POST /chats/{id}/messages`. Idempotency
lives inside the `message` object (`idempotency_key`), not as a header.
Inbound is webhook-only in practice: there is no live stream, and listing
messages is per-chat. Webhooks are Standard Webhooks
(`webhook-id` / `webhook-timestamp` / `webhook-signature`) signed with a
`whsec_` secret issued once at subscription create. The registration URL
pins `?version=2026-02-03` so the envelope stays
`data.sender_handle` / `data.chat.id`. A lost secret means delete and
re-register. Sandbox accounts are capped at 100 messages per day.
Inbound webhook verification uses the gateway's `standard-webhooks` kind;
a gateway that only knows `hmac` will 403 every Linq delivery. Polling
still receives without that.
## Development
```bash
bun install
bun test
bunx tsc --noEmit
```