Skip to content

Find a component

Loading the directory…

↑ ↓ to select · Enter to open · Esc to closeBrowse all →
Contributor login

Photon

Bijection component for Photon iMessage — durable inbound pipeline, batching, cancellation, and a deduped, paced outbox.

Bijection source
git clone https://github.com/bijectionhq/photon.git
cd photon
git checkout 054653aa8a91a527f788645eb4144413329605a5

Bijection repository access is required. Request access.

Description

Bijection component for Photon iMessage — durable inbound pipeline, batching, cancellation, and a deduped, paced outbox.

Explore the package README for API concepts, configuration, and examples. Check the Bijection source for adaptation-specific setup.

Available to evaluate

Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.

Getting started

  1. Get the source at the revision above and read its package documentation.
  2. Check the required Bijection SDK version, dependencies, and external service credentials.
  3. Follow the package setup instructions and the guide to using components.
  4. Exercise the component in your deployment according to your project’s verification rules.

Open source

Maintained source: bijectionhq/photon. Based on photon-hq/spectrum-convex. License: MIT. Consult the repository for license terms, notices, and contributor attribution.

From the upstream publisher

Convex component for Photon iMessage — durable inbound pipeline, batching, cancellation, and a deduped, paced outbox.

Explore the demo ↗

Upstream overview

From the upstream package documentation.

A Convex component that integrates Photon's iMessage/RCS/SMS platform into a Convex backend without requiring a long-lived process. It solves the fundamental incompatibility between Photon's streaming message loop and Convex's isolated execution model by verifying and persisting inbound webhook deliveries to a durable table before returning the HTTP 200, then handing off all subsequent processing to Convex's scheduler. The component ships burst debouncing, in-flight cancellation, carry-forward context, and a paced outbox pipeline rather than leaving those to be reimplemented per app.

Use cases

From the upstream package documentation.

  • Building an iMessage chatbot backed by an LLM where rapid follow-up messages should cancel and supersede in-flight replies rather than generating multiple overlapping responses
  • Integrating Photon iMessage into an existing Convex app without running a separate server to host Photon's long-lived async message loop
  • Handling at-least-once webhook redeliveries safely by deduplicating on provider message id before dispatching to application logic
  • Implementing a high-throughput messaging pipeline where outbound sends need pacing, retry with backoff, and deduplication at the outbox level
  • Running an echo bot or command handler where each inbound message triggers an independent, immediate response without burst collapsing

How it works

From the upstream package documentation.

The component registers an HTTP route via spectrum.registerRoutes(http) that receives Photon's native webhook. On each delivery, it verifies the HMAC signature against SPECTRUM_WEBHOOK_SECRET, writes the message to a durable table, and returns 200 before doing anything else. A follow-up action then runs via Convex's scheduler, so no work is lost if the isolate is frozen after the response.

Message handling is controlled by the mode option on the Spectrum constructor. In "immediate" mode every inbound message triggers its own onBatch handler invocation. In "collapse" mode a burst of messages within the debounce window is coalesced into a single turn, and a new message arriving while a reply is in flight cancels the pending send and carries prior messages forward as context. The debounceMs option adds an explicit settling window on top of the natural cancellation window.

Outbound sends go through an internal outbox table to a gRPC transport running in a Node action that the application owns. The sender is created with createCloudSender() from @spectrum-ts/convex/sender and cached per container. Each outbox row carries a deterministic clientGuid derived from (chainId, seq) to prevent duplicate queuing. The gRPC peers (nice-grpc, @grpc/grpc-js, @photon-ai/advanced-imessage) must be declared in convex.json as node.externalPackages and statically re-exported from a workaround module so the Convex bundler includes them at runtime.

For testing, the component exposes a convex-test integration via @spectrum-ts/convex/test. Calling spectrum.register(t) on the test instance makes the component's schema and function modules available to the test harness without requiring them to be part of the application's own file glob.

Upstream package FAQ

Why can't I use Photon's app.messages loop or app.webhook() directly in Convex?

Photon's app.messages loop requires a long-lived process, which Convex isolates do not support. The app.webhook() handler runs after the HTTP response is returned, but a Convex isolate is frozen at that point, so any work done there is unreliable. The @spectrum-ts/convex component solves this by writing the verified delivery to a Convex table before the 200 response is sent, making it durable, and then scheduling all subsequent work through Convex's scheduler.

What environment variables does the iMessage (Photon) Convex component require?

The @spectrum-ts/convex component requires three environment variables set in the Convex dashboard: SPECTRUM_WEBHOOK_SECRET for verifying the HMAC signature on incoming webhooks, SPECTRUM_PROJECT_ID for identifying your Spectrum Cloud project, and SPECTRUM_PROJECT_SECRET for authenticating with Spectrum Cloud.

What is the difference between immediate and collapse mode in @spectrum-ts/convex?

In immediate mode (the default), every inbound message triggers a separate onBatch handler call with a single message and no carried context, which is appropriate for cheap replies like echo bots or commands. In collapse mode, a burst of messages within a settling window is coalesced into one handler call, and a new message while a reply is in flight cancels that reply and carries all messages forward as prior context, which is appropriate for expensive LLM-based replies.

Why do gRPC sends fail with a peer dependency error even when the packages are in package.json?

Convex only installs externalPackages entries if the bundled code statically imports them. The gRPC peers (nice-grpc, nice-grpc-common, @grpc/grpc-js) are optional peers of @photon-ai/advanced-imessage and are loaded dynamically, so the bundler never references them and they are not installed. The fix is to add a convex/_workaround.ts file that re-exports all three packages, giving the bundler the static reference it needs to include them.

What message content types can be sent through the @spectrum-ts/convex outbox?

The @spectrum-ts/convex outbox currently supports text and markdown content. The outbox serializes content to JSON for durability and replays it later, and there is no public Photon SDK API to send a pre-resolved Content object back. Attachments, polls, and other rich content types are not supported and will throw a clear error rather than sending incorrect content.

Use with agents and CLI

Give your coding agent the component’s source identity and setup guidance.

agent-prompt.md
Help me integrate Photon with Bijection.

Read: https://bijection.com/components/photon/SKILL.md

Check SDK compatibility and dependency availability.
Follow this project's execution and verification rules.

Upstream README.md

View repository ↗

Documentation from photon-hq/spectrum-convex, recorded October 23, 2026. APIs and setup may differ in the Bijection adaptation.

@spectrum-ts/convex

A Convex component for Photon iMessage.

Convex can't run Photon's for await (… of app.messages) loop — that needs a long-lived process. And the usual webhook escape hatch doesn't fit either: app.webhook() dispatches its handler after the HTTP response, but a Convex isolate is frozen the moment the response is returned.

This component inverts that. A delivery is verified and written to a table before the 200 goes back, so an acknowledged message is already durable. Everything after that runs on Convex's scheduler.

It also ships the production pipeline from Photon's architecture guide — burst debouncing, in-flight cancellation, carry-forward, and paced sends — rather than leaving you to rebuild it.

Install

npm i @spectrum-ts/convex
// convex/convex.config.ts
import { defineApp } from "convex/server";
import spectrum from "@spectrum-ts/convex/convex.config";

const app = defineApp();
app.use(spectrum);
export default app;

Set these in the Convex dashboard:

VariableWherePurpose
SPECTRUM_WEBHOOK_SECRETappVerifies the native webhook's HMAC
SPECTRUM_PROJECT_IDappSpectrum Cloud project
SPECTRUM_PROJECT_SECRETappSpectrum Cloud secret

Use

// convex/spectrum.ts
import { Spectrum } from "@spectrum-ts/convex";
import { components, internal } from "./_generated/api";

export const spectrum = new Spectrum(components.spectrum, {
  onBatch: internal.agent.respond,
  sender: internal.sender.deliver,
  debounceMs: 5000,
  pacingMs: 800,
});
// convex/http.ts — POST https://<deployment>.convex.site/spectrum/webhook
import { httpRouter } from "convex/server";
import { spectrum } from "./spectrum";

const http = httpRouter();
spectrum.registerRoutes(http);
export default http;
// convex/agent.ts — one invocation per settled burst
export const respond = internalAction({
  args: { spaceId: v.string(), chainId: v.string(), messages: v.array(v.any()), carried: v.array(v.any()) },
  handler: async (ctx, args) => {
    const reply = await generate(args.messages, args.carried);

    // The scheduler can't interrupt a running action, so re-check after
    // anything slow.
    if (await spectrum.isCancelled(ctx, args)) return null;

    await spectrum.send(ctx, { spaceId: args.spaceId, chainId: args.chainId, content: { type: "text", text: reply } });
    await spectrum.completeChain(ctx, args);
    return null;
  },
});

See example/ for a complete app, including the sender.

How the pipeline behaves

webhook ──▶ verify + enqueue (durable) ──▶ 200
                     │
                     ├─ dedupe on message id (deliveries are at-least-once)
                     └─ start a turn
                                │
                                ▶ your onBatch handler
                                        │
                                        ▶ outbox ─▶ sender ─▶ iMessage

Choosing a turn policy

The component does not impose a conversation model. Pick one with mode:

modeBehaviourUse when
"immediate" (default)Every message is handled on its own. N messages → N handler calls. messages always has one entry, carried is always empty.Replies are cheap and answering each message is correct — echo bots, commands, notifications.
"collapse"A burst becomes one turn. A new message supersedes the in-flight turn, cancels any reply that hasn't shipped, and carries its messages forward as prior context.Replies are expensive (an LLM call), or answering each message separately would read as the agent talking over itself.
new Spectrum(components.spectrum, {
  onBatch: internal.agent.respond,
  sender: internal.sender.deliver,
  mode: "collapse",   // default is "immediate"
  debounceMs: 0,      // optional extra settling window, collapse mode only
});

debounceMs is a latency cost on every reply, so it defaults to 0 — in collapse mode, cancellation already absorbs most bursts on its own, because a follow-up usually arrives while the previous reply is still in flight.

Guarantees in both modes

  • At-least-once deliveries are deduped on the provider message id.
  • Read receipts and typing indicators never start a turn. Otherwise a bot replies to the read receipt for its own last message and echoes forever.
  • Sends are deduped at the outbox. Each row carries a clientGuid derived from (chainId, seq), so the same logical send is never queued twice. Caveat: the guid is not forwarded to the provider. Photon's send path does not accept a caller-supplied clientMessageId, and the SDK's autoIdempotency mints a fresh key per call — so a retry after a lost acknowledgement can still post twice. Closing that needs an SDK change to thread the key through.
  • Messages are never dropped. One rule governs the pipeline: a message is spent only when a reply actually reaches the user — not when it is drained, and not when the handler returns. A handler that has finished has only queued its reply; a follow-up moments later can still cancel it. Until a send is confirmed, every message stays recoverable. Verified live across two back-to-back supersessions.

Sending

Sends go through the outbox to a gRPC transport running in a Node action your app owns. gRPC is the transport for both directions — the SDK's HTTP client has no subscribeEvents / EventsResource, so it cannot serve inbound.

Two pieces of setup are required, and sends fail without them:

1. Depend on the provider and the gRPC peers. @spectrum-ts/imessage is an optional peer of this package, so it is not installed for you:

npm i @spectrum-ts/imessage @photon-ai/advanced-imessage nice-grpc nice-grpc-common @grpc/grpc-js

2. Mark them external, and force-reference the peers. createGrpcClient requires its peers resolvable at runtime, not merely bundled:

// convex.json
{
  "node": {
    "externalPackages": [
      "@photon-ai/advanced-imessage",
      "nice-grpc",
      "nice-grpc-common",
      "@grpc/grpc-js"
    ]
  }
}
// convex/_workaround.ts — nothing imports this; it exists so the packages ship
"use node";
export * as _grpcJs from "@grpc/grpc-js";
export * as _niceGrpc from "nice-grpc";
export * as _niceGrpcCommon from "nice-grpc-common";

That second file is not optional. Convex installs an externalPackages entry only if the bundled code imports it, and these three are optional peers of @photon-ai/advanced-imessage loaded dynamically from inside that package — so nothing in the bundle references them and they are silently never installed. createGrpcClient then fails its import.meta.resolve check with a misleading "install the peer dependencies" error even though they are in your package.json. The re-export gives the bundler the static reference it needs.

// convex/sender.ts
"use node";
import { createCloudSender } from "@spectrum-ts/convex/sender";

const sender = createCloudSender(); // one Spectrum() instance per container

The sender drives the ordinary Spectrum() runtime. It is built to be long-lived, which looks wrong for request-scoped execution, but measured it costs ~35ms more to construct than the low-level alternative, tears down in ~1ms, and leaves no active handles — and caching one instance per container removes even that on warm invocations.

Content limitation

ContentInput is string | ContentBuilder: the runtime is designed for in-process composition, where you call text("hi") and hand the builder to send(). A durable outbox has to serialize content to JSON and replay it later, and there is no public way to send a resolved Content back.

So the sender rebuilds what round-trips cleanly — text and markdown — and throws a clear error for anything else rather than silently sending the wrong thing. Attachments, polls and other rich arms would need either a JSON→builder mapper here or an SDK change accepting resolved content.

reply, react and markRead address an existing message, which the public API models as a Message rather than an id, so each costs an extra space.getMessage() round trip (~570ms measured).

Note: node.externalPackages is not honoured by the anonymous local backend (CONVEX_AGENT_MODE=anonymous) — the peers are never installed there regardless of this setup. Use a cloud dev deployment to test sends.

Verified behaviour

Tested against a real Convex deployment (convex dev, local backend) with HMAC-signed webhook deliveries:

BehaviourResult
Forged signature401 signature-mismatch, nothing stored
Signed delivery200, durable before the response returns
Redelivery of a seen message idDeduped — 3 rows from 4 deliveries
Burst of 3 within the windowOne flush, one chain, one handler invocation
Batch handler dispatchRan via function handle with messages + carried
Outbound sendQueued with deterministic clientGuid <chainId>#0
gRPC transportReached Spectrum Cloud from a Convex Node action — server validated the chat guid and applied its target policy
Send failureCaptured in lastError, retried with backoff
TransportThe ordinary Spectrum() runtime, one instance cached per container

The final send returned [spectrum-imessage] Target not allowed for this project for a reserved fictional number — a server-side policy response, which means the whole chain (token mint, TLS, gRPC, auth, request validation) worked.

Docs

docs/integration.mdx.vel is the docs.photon.codes page for this component, staged here rather than in spectrum-ts/docs — that directory is rendered from main, so putting it there publishes it. It ships once this package is on npm and this repo is public; the file header has the steps.

Testing against the component

Apps that install the component register it with convex-test explicitly — its schema and function modules are not part of the app's own glob:

// convex/setup.test.ts
import { convexTest } from "convex-test";
import spectrum from "@spectrum-ts/convex/test";
import schema from "./schema";

const modules = import.meta.glob("./**/*.ts");

export function initConvexTest() {
  const t = convexTest(schema, modules);
  spectrum.register(t);
  return t;
}

register(t, name?) defaults to "spectrum" — pass the name you installed under if you changed it in convex.config.ts.

Development

This is a single npm package: the root package.json owns both the component and the example app in example/, and root convex.json points functions at example/convex. There is no separate install inside example/.

npm install
npm run dev       # codegen + build in watch, alongside `convex dev`

npm run dev runs three things the component needs in order — component codegen, the package build, then convex dev --typecheck-components against the example app. To do a one-off instead:

npm run build:codegen   # convex codegen + tsc
npm test
npm run typecheck       # package and example/convex
npm run check           # biome

src/component/_generated/ is committed. It holds component.ts, the ComponentApi type the client re-exports, so regenerate and commit it whenever a component validator changes — npm run build:codegen does both.

Publishing is documented in PUBLISHING.md.

Local development against an SDK checkout. tsconfig.json maps @spectrum-ts/* types to a sibling spectrum-ts checkout's built declarations, so TypeScript uses those rather than checking the SDK's source under this project's lib settings. It is inert without that checkout — TypeScript falls back to normal node resolution when a mapping doesn't resolve — so a plain npm install works unchanged.

License

MIT © Photon

Keywords