Photon iMessage Channel
OpenEve ships a Photon/Spectrum channel adapter in @openeve/photon. It keeps Photon as a channel boundary: inbound webhooks are normalized into OpenEve turns, and outbound side effects go through a Photon transport bridge.
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.
Agent Channel Wrapper
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 "@openeve/photon";
export default definePhotonChannel();
This compiles to an HTTP channel on /photon/events that requires PHOTON_TRANSPORT_URL and PHOTON_BRIDGE_TOKEN. Pass options to override the route, methods, or description: definePhotonChannel({ route: "/imessage" }).
Set PHOTON_WEBHOOK_SIGNING_SECRET or PHOTON_SIGNING_SECRET to verify
X-Spectrum-Signature. Alternatively set PHOTON_INGRESS_TOKEN or
PHOTON_WEBHOOK_BEARER_TOKEN and send 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.
How the lifecycle is wired
The runtime drives the typing indicator generically: it starts the channel turn lifecycle before attachment intake and model work, then stops it immediately before final delivery, refreshing the indicator every few seconds in between. definePhotonChannel() supplies the startTurn handler that maps that lifecycle onto Photon's typing:start/typing:stop, so the user sees "typing…" for the entire turn — attachment download/extraction, tools, model calls, and all — until the reply lands.
If you prefer to wire the handlers by hand (for example, to wrap one of them), you can still re-export them explicitly instead of using the helper:
import { defineChannel } from "@openeve/core";
import {
normalizeHttp as normalizePhotonHttp,
send as sendPhoton,
startTurn as startPhotonTurn
} from "@openeve/photon";
export default defineChannel({ transport: "http", route: "/photon/events", methods: ["POST"] });
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. 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.)
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 bridge transport and reply destination from the run's channel context, so the model only supplies the content:
// tools/photon_react.ts
import { definePhotonReactionTool } from "@openeve/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.
Outbound Bridge Contract
The adapter expects PHOTON_TRANSPORT_URL to point at a bridge exposing the Photon transport endpoints:
POST /v1/messages/sendPOST /v1/messages/interactPOST /v1/messages/pollPOST /v1/messages/appPOST /v1/messages/background
Final replies support native reply targets, safe markdown styling, link previews, and optional media fields:
{
"mediaUrl": "https://example.com/image.png",
"mediaFilename": "image.png",
"mediaMimeType": "image/png"
}
Typing indicators use the runtime startTurn lifecycle hook: the adapter sends typing:start before the model turn and typing:stop before final delivery.
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.
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.
Helpers
@openeve/photon also exports:
sendPhotonReplysendPhotonReactionsendPhotonPollsendPhotonAppCardsendPhotonBackgroundstartPhotonTypingphotonTextForReplyshouldSendPhotonMarkdown/containsRenderableMarkdowndefinePhotonReactionTool/definePhotonPollTool/definePhotonAppCardTool/definePhotonBackgroundToolphotonTransportFromToolContext/photonDestinationFromToolContext