Photon iMessage Channel
@assemblyline-agents/photon connects an agent to iMessage through Photon/Spectrum. It keeps Photon at the channel boundary: inbound webhooks are normalized into Assembly Line turns, and outbound side effects go through a Photon transport — either Photon's Spectrum cloud directly, or a bridge you host.
Photon webhooks are at-least-once. The adapter returns a fast 2xx accepted response and uses X-Spectrum-Webhook-Id + message.id as the default idempotency key, falling back to message.id when the webhook id header is absent.
Delivery Modes
The adapter picks the outbound transport from the environment at send time:
| Mode | Selected when | Required env |
|---|---|---|
| Direct (Spectrum cloud) | No transport URL is set and both project credentials are | PHOTON_PROJECT_ID, PHOTON_PROJECT_SECRET |
| Bridge (self-hosted) | PHOTON_TRANSPORT_URL (or PHOTON_BRIDGE_URL) is set | PHOTON_TRANSPORT_URL, PHOTON_BRIDGE_TOKEN |
Direct talks to Photon's Spectrum cloud with the project's own credentials — no bridge to run. It requires the spectrum-ts package in the agent's dependencies; the package is loaded dynamically, so agents that use a bridge never need it installed. Direct mode covers text and markdown sends, media by URL, typing, app cards, and backgrounds, and delivers to DM destinations (the phone number is recovered from a DM space id when the webhook carries no explicit recipient). Reactions and polls are feature-detected against the installed spectrum-ts: reactions are skipped when unsupported, and polls are rejected with an error telling you to configure a bridge. Inbound attachment downloads always require a bridge (see Inbound Files).
Bridge posts every side effect to a transport bridge you host (see Outbound Bridge Contract), authenticated with Authorization: Bearer <PHOTON_BRIDGE_TOKEN>. When a transport URL is set without a token, sends fail with PHOTON_BRIDGE_TOKEN is required for Photon delivery.; when neither mode is configured, sends fail with PHOTON_TRANSPORT_URL is required for Photon delivery (or set PHOTON_PROJECT_ID/PHOTON_PROJECT_SECRET for direct delivery).
One-Line Channel Setup
Create channels/photon.ts in an agent folder. The default export from definePhotonChannel() carries the full lifecycle — inbound normalization, the typing indicator, and outbound delivery — so a single line is all you need and typing works on every build:
import { definePhotonChannel } from "@assemblyline-agents/photon";
export default definePhotonChannel();
This compiles to an HTTP channel on /photon/events. definePhotonChannel() stamps the ingress-auth requirement but no transport env requirement — the delivery mode is resolved from the environment at send time, so the same channel file works in both modes. Pass options to override the route, methods, or description: definePhotonChannel({ route: "/imessage" }).
Manual Wiring
If you prefer to wire the handlers by hand (for example, to wrap one of them), re-export them explicitly instead of using the helper:
import { defineChannel } from "@assemblyline-agents/core";
import {
normalizeHttp as normalizePhotonHttp,
send as sendPhoton,
startTurn as startPhotonTurn
} from "@assemblyline-agents/photon";
export default defineChannel({ transport: "http", route: "/photon/events", methods: ["POST"] });
// Bridge mode only: pin the transport env at preflight. Omit for direct mode.
export const requiredEnv = ["PHOTON_TRANSPORT_URL", "PHOTON_BRIDGE_TOKEN"];
export const normalizeHttp = normalizePhotonHttp;
export const startTurn = startPhotonTurn;
export const send = sendPhoton;
Named exports take precedence over the default export's handlers. The explicit requiredEnv line pins the channel to bridge mode during preflight; definePhotonChannel() itself stamps no requiredEnv, so leave it out when the agent may run with direct delivery.
If you go this route and forget startTurn, the compiler emits a photon-channel-missing-typing warning and the runtime records a channel.turn_lifecycle_unsupported event, so a missing typing wiring never passes silently. (The one-line definePhotonChannel() form can't hit that case — typing is always wired.)
Ingress Auth
Set PHOTON_WEBHOOK_SIGNING_SECRET to verify X-Spectrum-Signature (HMAC-SHA256 over v0:<timestamp>:<body>, with a configurable timestamp tolerance). Alternatively set PHOTON_INGRESS_TOKEN and have the sender pass Authorization: Bearer <token>. Unsigned Photon ingress is local/dev-only; production runtime boot rejects a Photon channel when neither a signing secret nor bearer token is configured.
The production boot check accepts exactly PHOTON_WEBHOOK_SIGNING_SECRET or PHOTON_INGRESS_TOKEN; the aliases PHOTON_SIGNING_SECRET and PHOTON_WEBHOOK_BEARER_TOKEN satisfy per-request verification but not the boot check, so always set at least one canonical name in production.
Typing Lifecycle
The runtime drives the typing indicator generically. For fast-ack HTTP ingress, it begins the channel turn lifecycle immediately after capacity admission and idempotency reservation, concurrently with runtime initialization and durable run creation. Rejected-capacity turns and idempotent replays therefore never start a duplicate indicator. Direct runtime calls start the lifecycle during run setup. In both paths, lifecycle startup happens before attachment intake and model work, and shutdown happens immediately before final delivery. definePhotonChannel() supplies the startTurn handler that maps that lifecycle onto Photon typing signals, so the user sees "typing…" for the entire turn — initialization, attachment download/extraction, tools, model calls, and all — until the reply lands.
The indicator is refreshed every PHOTON_TYPING_REFRESH_MS (default 4 seconds). In bridge mode, typing signals are posted to /v1/messages/interact with action: "typing" and state: "start" | "stop"; in direct mode they map onto the Spectrum typing API. Typing failures are logged as warnings and never fail the run, and when no transport or destination is available the lifecycle is a no-op.
Markdown Replies
Photon's Spectrum bridge renders full CommonMark in iMessage, so the adapter sends replies as textFormat: "markdown" whenever the response contains renderable markdown — headings, lists, tables, fenced or inline code, blockquotes, links, or bold/italic. Plain casual messages (no markdown syntax) are sent as plain so they read like a normal text, with casual sentence-ending punctuation softened.
You can override the detection per delivery with a textFormat field on the delivery payload, or call photonTextForReply(body, "markdown") directly.
Rich Feature Tools
Drop these tool factories into the agent's tools/ folder to let the agent send rich Photon side effects to the current conversation. Each resolves the transport and reply destination from the run's channel context, so the model only supplies the content:
// tools/photon_react.ts
import { definePhotonReactionTool } from "@assemblyline-agents/photon";
export default definePhotonReactionTool();
Available factories:
definePhotonReactionTool— tapback the user's latest message (like,love,laugh,emphasize,dislike,question)definePhotonPollTool— send a native iMessage poll (title + 2–10 options)definePhotonAppCardTool— send a styled link card (caption, subcaption, image)definePhotonBackgroundTool— set or clear the chat background image
Each factory accepts { description?, needsApproval? } to customize the model-facing description or gate the side effect behind an approval. In direct mode, reactions and polls depend on the installed spectrum-ts version (see Delivery Modes).
Outbound Bridge Contract
In bridge mode, the adapter expects PHOTON_TRANSPORT_URL to point at a bridge exposing the Photon transport endpoints:
POST /v1/messages/send— final replies: text or markdown body, native reply targets, link previews, and optional media fieldsPOST /v1/messages/interact— reactions (action: "react") and typing signals (action: "typing",state: "start" | "stop")POST /v1/messages/pollPOST /v1/messages/appPOST /v1/messages/background
The direct transport additionally routes a dedicated /v1/messages/typing path ({ "action": "start" | "stop" }) for hosts that address typing explicitly; bridges only receive typing through /v1/messages/interact.
Optional media fields on a send:
{
"mediaUrl": "https://example.com/image.png",
"mediaFilename": "image.png",
"mediaMimeType": "image/png"
}
Every request carries an idempotency key. Bridge responses are parsed as JSON and read up to a fixed 512 KiB cap; non-2xx responses raise Photon transport returned HTTP <status>: <payload>.
Inbound Files
Photon attachment content is preserved as runtime-visible files, not just
message metadata. When Spectrum sends attachment content with fields such as
name, mimeType, size, and downloadUrl/contentUrl/url, the adapter
keeps those references in ChannelTurn.attachments. The runtime then downloads
the bytes after the webhook ACK, stores them through the configured blob
adapter, and exposes them under /files/original/... with entries in
/files/manifest.json. Attachment downloads are restricted to the
PHOTON_TRANSPORT_URL origin, which is why inbound files require a bridge even
when outbound delivery runs in direct mode.
Photon bridge multipart/form-data forwarding is also supported. When the
bridge sends asset_manifest_json plus matching file parts, the Node host
parses the file bytes and the Photon adapter converts them into inline
attachments before runtime storage. Prefer form forwarding for uploaded files;
JSON forwarding can describe attachments, but it cannot carry the actual file
bytes unless it includes an explicit downloadable URL.
Markdown/text uploads read back as UTF-8 through read; binary
uploads remain byte-accurate when hydrated under /files. ZIP uploads are kept as
their original archive under /files/original/... and, when extraction
succeeds, safe entries are also exposed under
/files/extracted/<archive-name>/... so the agent can open and project files
from a zipped folder directly. If the download URL points at the Photon bridge
origin, the runtime uses PHOTON_BRIDGE_TOKEN for the fetch without exposing
that token to the model context.
Environment Reference
PHOTON_* variables are canonical on this page; runtime-wide ASSEMBLY_LINE_* variables live in the Configuration Reference.
| Variable | Values | Default | Effect |
|---|---|---|---|
PHOTON_TRANSPORT_URL | URL | unset | Bridge base URL; presence selects bridge mode. PHOTON_BRIDGE_URL is an accepted alias. |
PHOTON_BRIDGE_TOKEN | string | unset | Bearer token for bridge requests and bridge-origin attachment downloads; required in bridge mode. |
PHOTON_PROJECT_ID | string | unset | Spectrum project id for direct delivery. |
PHOTON_PROJECT_SECRET | string | unset | Spectrum project secret for direct delivery. |
PHOTON_WEBHOOK_SIGNING_SECRET | string | unset | HMAC secret for X-Spectrum-Signature verification. Alias: PHOTON_SIGNING_SECRET (request verification only, not the production boot check). |
PHOTON_INGRESS_TOKEN | string | unset | Expected webhook Authorization: Bearer token. Alias: PHOTON_WEBHOOK_BEARER_TOKEN (request verification only, not the production boot check). |
PHOTON_WEBHOOK_TOLERANCE_SECONDS | integer, 30–86400 | 300 | Maximum accepted signature timestamp age. |
PHOTON_SEND_REQUEST_TIMEOUT_MS | integer, 1000–120000 | 30000 | Timeout for sends, polls, app cards, and backgrounds. |
PHOTON_TYPING_REQUEST_TIMEOUT_MS | integer, 500–15000 | 3000 | Timeout for typing and reaction requests. |
PHOTON_TYPING_REFRESH_MS | integer, 1000–30000 | 4000 | Typing indicator refresh cadence. |
Exports
@assemblyline-agents/photon exports, grouped by concern:
- Channel:
definePhotonChannel,normalizeHttp,startTurn,send,resolveAttachment - Transport helpers:
sendPhotonReply,sendPhotonReaction,sendPhotonPoll,sendPhotonAppCard,sendPhotonBackground,startPhotonTyping - Text formatting:
photonTextForReply,photonTextFormatForReply,shouldSendPhotonMarkdown,containsRenderableMarkdown,softenIMessageBubbleEndings - Tools:
definePhotonReactionTool,definePhotonPollTool,definePhotonAppCardTool,definePhotonBackgroundTool,photonTransportFromToolContext,photonDestinationFromToolContext - Mode and env constants:
directPhotonEnabled,PHOTON_REQUIRED_ENV,PHOTON_DIRECT_ENV,PHOTON_WEBHOOK_ENV,PHOTON_INGRESS_SECRET_ENV
Related Docs
- channels/ — the channel file contract this page plugs into.
- Adapters — the channel role matrix and the other channel providers.
- Configuration Reference — runtime
ASSEMBLY_LINE_*environment variables. - Troubleshooting — production ingress boot failures and webhook
401s.