plugins/platforms/photon/README.md
This plugin connects Hermes Agent to iMessage (and other Spectrum interfaces) through Photon — a managed service that handles iMessage line allocation, delivery, and abuse-prevention so users don't have to run their own Mac relay.
The free tier uses Photon's shared iMessage line pool and is the path we recommend for everyone who doesn't already pay for a dedicated number.
Like Discord and Slack, Photon is a persistent-connection channel — no
public URL, no webhook, no signing secret. The spectrum-ts SDK holds a
long-lived gRPC stream to Photon for both directions. Because the SDK is
TypeScript-only, Hermes runs it inside a small supervised Node sidecar and
talks to it over loopback.
gRPC (spectrum-ts)
┌─────────────────────────┐ ◄───────────────► ┌──────────────────────┐
│ Photon Spectrum cloud │ app.messages │ Node sidecar │
│ (iMessage line owner) │ space.send() │ (plugins/…/sidecar) │
└─────────────────────────┘ └──────────┬───────────┘
GET /inbound (NDJSON) │ ▲ POST /send
inbound events ▼ │ /typing
┌──────────────────────┐
│ PhotonAdapter │
│ (Python, in gateway) │
└──────────────────────┘
app.messages gRPC stream,
normalizes each message, and streams it to the adapter over a loopback
GET /inbound (NDJSON). The adapter dedupes on messageId and dispatches
a MessageEvent to the gateway. It reconnects automatically if the stream
drops; the sidecar owns the gRPC reconnect to Photon.send / send_typing / reaction tapbacks are loopback POSTs
to the sidecar (/send, /send-attachment, /typing, /react,
/unreact), authenticated with a shared X-Hermes-Sidecar-Token.# One-shot setup: device login (opens browser) + project + user + sidecar deps
hermes photon setup --phone +15551234567
# Start the gateway
hermes gateway start
hermes photon setup does, in order:
client_id=photon-cli) — opens
https://app.photon.codes/ for approval and stores the bearer token.Hermes Agent project on the Photon dashboard.~/.hermes/.env so the
sidecar can authenticate spectrum-ts. Spectrum is always on, so there's no
separate enable step.npm ci — installs the committed lockfile
verbatim, so every setup runs the exact spectrum-ts version this plugin
was written against).There is no separate login command; like every other Hermes channel,
onboarding goes through one setup surface. Re-running setup reuses an
existing token/project, so it's safe to run again to finish a partial setup.
Run hermes photon status to see what's configured.
Runtime SDK credentials live in ~/.hermes/.env (the same place every other
channel keeps its token), and the adapter reads them from the environment:
PHOTON_PROJECT_ID=<projectId> # the SDK's projectId (same as the dashboard project id)
PHOTON_PROJECT_SECRET=<projectSecret>
Management metadata lives in ~/.hermes/auth.json under credential_pool:
{
"credential_pool": {
"photon": [
{ "access_token": "<device-bearer>", "issued_at": ... }
],
"photon_project": [
{
"dashboard_project_id": "<project id>",
"spectrum_project_id": "<project id>",
"project_secret": "<projectSecret>",
"name": "Hermes Agent"
}
]
}
}
Note on ids. A Photon project's dashboard id and its Spectrum project id are the same value, exposed as
PHOTON_PROJECT_ID. Thedashboard_project_idandspectrum_project_idkeys inauth.jsonboth hold that id.
All env vars are documented in plugin.yaml. The most important:
| Env var | Default | Meaning |
|---|---|---|
PHOTON_PROJECT_ID | from .env / auth.json | Spectrum project id (SDK projectId) |
PHOTON_PROJECT_SECRET | from .env / auth.json | Project secret |
PHOTON_SIDECAR_PORT | 8789 | Loopback port for the sidecar |
PHOTON_SIDECAR_AUTOSTART | true | Spawn the sidecar on connect |
PHOTON_DASHBOARD_HOST | https://app.photon.codes | Dashboard API host |
PHOTON_SPECTRUM_HOST | https://spectrum.photon.codes | Spectrum API host |
PHOTON_HOME_CHANNEL | your number (set by setup) | Default space for cron delivery — a space id, or a bare E.164 number (resolved to a DM) |
PHOTON_ALLOWED_USERS | your number (set by setup) | Comma-separated E.164 allowlist |
PHOTON_REQUIRE_MENTION | false | Gate group chats on a wake word |
PHOTON_MAX_INLINE_ATTACHMENT_BYTES | 20 MB | Max inbound attachment size the sidecar reads & inlines |
PHOTON_TELEMETRY | false | Spectrum SDK telemetry — toggle with hermes photon telemetry on|off (restart the gateway to apply) |
PHOTON_MARKDOWN | true | Send agent replies as markdown (iMessage renders natively). false strips formatting to plain text |
PHOTON_REACTIONS | false | Tapback 👀/👍/👎 as processing status; tapbacks on bot messages reach the agent as reaction:added:<emoji> |
content.read()) and base64-inlines them on the NDJSON event; the
adapter caches them to the shared media cache and populates media_urls /
media_types, so the agent sees the real image/file or can transcribe the
voice note — parity with the BlueBubbles iMessage channel. Mixed iMessage
bubbles that contain both text and attachments are normalized as a grouped
payload so the user's typed text is preserved alongside the cached media.
Media larger than PHOTON_MAX_INLINE_ATTACHMENT_BYTES (default 20 MB), or
any byte read that fails, falls back to a text marker ([Photon attachment received: …] or [Photon voice received: …]) so the agent still knows
something arrived.space.send(attachment(...)) /
space.send(voice(...)) through the sidecar's /send-attachment
endpoint; a caption is delivered as a separate text bubble after the media.markdown()
builder; iMessage renders bold/italics/lists/code natively and other
Spectrum platforms degrade to readable plain text. PHOTON_MARKDOWN=false
reverts to stripped plain text.PHOTON_REACTIONS (default
off): the adapter tapbacks 👀 while processing and swaps it for 👍/👎 on
completion, and a user tapback on a bot-sent message is routed to the agent
as a synthetic reaction:added:<emoji> event. Removal after a sidecar
restart is best-effort — the live reaction handle is lost, so a stale
tapback heals when the next reaction replaces it. Group spaces stay
reachable across restarts via spectrum-ts v3's space.get(id).spectrum-ts but not yet
exposed; the sidecar is the natural place to add them.spectrum-ts is pinned to an exact version in sidecar/package.json
(no ^ range) and installed with npm ci, because the SDK ships breaking
majors (v2 removed defineFusorPlatform; v3 reworked space construction).
A floating range or npm install spectrum-ts@latest would let a breaking
release take down fresh setups silently. Upgrades are deliberate:
sidecar/package.json, then run npm install
inside sidecar/ to regenerate package-lock.json. Commit both.sidecar/index.mjs against the new typings
(sidecar/node_modules/spectrum-ts/dist/*.d.ts is the source of truth —
the hosted docs can lag).pytest tests/plugins/platforms/photon/.hermes photon status, a DM and a group roundtrip,
and an agent reply into a group right after a gateway restart (exercises
space.get rehydration).