Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.photon.codes/docs/llms.txt

Use this file to discover all available pages before exploring further.

definePlatform is the entry point for building your own provider. It takes a name and a definition object, and returns a callable that:
  • exposes a .config() method for registering the provider on Spectrum()
  • accepts a Spectrum instance, space, or message for narrowing
  • carries any static properties you declare (like iMessage’s tapbacks)
The full definition shape is .

Shape

import { definePlatform } from "spectrum-ts";
import z from "zod";

export const myPlatform = definePlatform("my-platform", {
  // Validate provider config
  config: z.object({
    apiKey: z.string(),
  }),

  // Resolve a user from a string ID
  user: {
    resolve: async ({ input, client }) => ({
      id: input.userID,
      displayName: await client.lookupUser(input.userID),
    }),
  },

  // Resolve or create a conversation
  space: {
    resolve: async ({ input, client }) => ({
      id: await client.findOrCreateConversation(input.users.map(u => u.id)),
    }),
  },

  // Client lifecycle
  lifecycle: {
    createClient: async ({ config, store }) => new MyPlatformClient(config.apiKey),
    destroyClient: async ({ client }) => { await client.disconnect(); },
  },

  // Inbound message stream
  async *messages({ client }) {
    for await (const msg of client.onMessage()) {
      yield {
        id: msg.id,
        content: { type: "text", text: msg.body },
        sender: { id: msg.authorId },
        space: { id: msg.channelId },
        timestamp: new Date(msg.ts),
      };
    }
  },

  // Outbound dispatcher — all content types flow through here
  send: async ({ space, content, client }) => {
    switch (content.type) {
      case "text":
        return await client.send(space.id, content.text);
      case "reaction":
        await client.react(space.id, content.target.id, content.emoji);
        return;
      case "reply":
        return await client.reply(space.id, content.target.id, content.content);
      case "typing":
        await client.setTyping(space.id, content.state === "start");
        return;
    }
  },

  // Optional static properties
  static: {
    reactions: { thumbsUp: "+1", thumbsDown: "-1" } as const,
  },
});

Field reference

FieldRequiredDescription
configYesA Zod schema that validates the object passed to platform.config(). If every field is optional, platform.config() can be called with no arguments.
user.resolveYesResolves a user from a string ID. Returns at minimum { id: string }.
user.schemaNoOptional Zod schema for extra user properties.
space.resolveYesResolves or creates a conversation. Receives an array of users plus optional params.
space.schemaNoOptional Zod schema for the resolved space.
space.paramsNoZod schema for additional space creation parameters — surfaces as the second arg to platform(app).space().
space.actionsNoA map of content-builder factories that become sugar methods on the resolved space. Each space.<name>(...args) delegates to space.send(factory(...args)). Names that collide with built-in Space methods (send, edit, startTyping, stopTyping, responding, getMessage) are skipped at runtime with a warning.
lifecycle.createClientYesCreates the platform client. Receives config, projectId, projectSecret (both may be undefined), and store.
lifecycle.destroyClientNoTears down the client on shutdown. Omit if no cleanup is needed.
messagesYesAsync generator that yields incoming messages.
sendYesDispatches a content item to a space. All content types — text, attachments, reactions, replies, edits, typing indicators — flow through this single action. Return a ProviderMessageRecord for content that produces a message, or undefined for fire-and-forget signals (reactions, typing, edits).
actions.getMessageNoFetches a message by ID from a space. Powers space.getMessage(id).
events.[custom]NoAdditional async generators for platform-specific events — exposed on app.[eventName].
message.schemaNoZod schema for extra properties on incoming messages.
staticNoConstants attached to the platform object (e.g. tapback names).

Event producers

Every event generator receives { client, config, store } and returns an AsyncIterable. The signature is . The core messages stream lives at the top level of the definition. Optional custom event streams (presence, read receipts, etc.) live inside events:
// Top-level — required
async *messages({ client }) { /* ... */ },

// Optional custom events
events: {
  async *presence({ client }) {
    for await (const ev of client.presence()) {
      yield { spaceId: ev.chatId, userId: ev.user, online: ev.online };
    }
  },
},
Custom events are auto-wired as flat properties on both the Spectrum instance (app.presence) and the narrowed platform instance (myPlatform(app).presence).

Message extras

Declare a message.schema to add extra typed fields to every incoming message. The extractor surfaces them through a narrowed message:
message: {
  schema: z.object({
    threadId: z.string().optional(),
    reactions: z.array(z.string()),
  }),
},
Use when you need the message shape in your own types.

Registering your platform

Exported platforms work like the built-ins — register with .config() and use narrowing for the typed surface:
const app = await Spectrum({
  providers: [
    myPlatform.config({ apiKey: process.env.MY_KEY! }),
  ],
});

const mine = myPlatform(app);
const user = await mine.user("user-123");
const space = await mine.space(user);

await space.send("Hello from my custom platform.");