/

Product

Spectrum 1.6: Designed for building Agents

Ryan

No headings found on page

Start building
with Spectrum

Deploy AI agents
across every channel

Learn more about Spectrum

Preface

Preface

Spectrum 1.6 is a fundamental redesign. We collapsed the entire platform surface from seven action methods down to two primitives — an input stream and an output dispatcher — because Spectrum is a tool for building agents, and the old surface did not reflect what agents actually are.

Why we redesigned

When we first built Spectrum, we designed it for human developers: message.reply() for replies, message.react() for reactions, space.startTyping() and space.stopTyping() for typing indicators, each with its own method and signature. If you were writing an iMessage agent by hand, the whole thing felt natural and clean.

But at some point we stepped back and asked a more fundamental question: what are people actually building with Spectrum, and does the SDK reflect the nature of what they are building?

The answer was no. An agent is not a traditional application where a developer writes imperative call sequences. An agent is an API that only responds — it takes input, processes it through a model, and produces structured output. Good engineering says you should have a contract on that response: a schema that describes everything the agent can do, and a single dispatch method to execute it. Our old design did the opposite. It scattered capabilities across seven methods and assumed the developer would write routing logic to map model outputs to the right calls. The agent's behavior was encoded in if-else branches, not in the model.

We also looked at the ecosystem and realized every major agent SDKs had the same problem — they were all designed for human-written imperative code, and none of them had rethought their surface for model-driven agents.

The new surface

In 1.6, the PlatformDef surface collapses to two top-level contracts: messages (the input stream) and send (the output dispatcher). Every previous action slot is gone. They became content types — structured data with a type field that goes through send:

reaction("✨", message)      // { type: "reaction", emoji: "✨", target: message }
reply(text("ack"), message)  // { type: "reply", content: ..., target: message }
edit(text("fixed"), prior)   // { type: "edit", content: ..., target: prior }
typing("start")              // { type: "typing", state: "start" }
reaction("✨", message)      // { type: "reaction", emoji: "✨", target: message }
reply(text("ack"), message)  // { type: "reply", content: ..., target: message }
edit(text("fixed"), prior)   // { type: "edit", content: ..., target: prior }
typing("start")              // { type: "typing", state: "start" }
reaction("✨", message)      // { type: "reaction", emoji: "✨", target: message }
reply(text("ack"), message)  // { type: "reply", content: ..., target: message }
edit(text("fixed"), prior)   // { type: "edit", content: ..., target: prior }
typing("start")              // { type: "typing", state: "start" }

All dispatched through one method:

await space.send(reaction("✨", message))
await space.send(reply(text("ack"), message))
await space.send(edit(text("fixed"), prior))
await space.send(typing("start"))
await space.send(reaction("✨", message))
await space.send(reply(text("ack"), message))
await space.send(edit(text("fixed"), prior))
await space.send(typing("start"))
await space.send(reaction("✨", message))
await space.send(reply(text("ack"), message))
await space.send(edit(text("fixed"), prior))
await space.send(typing("start"))

The content type schema is the response contract. The model generates content objects, Spectrum dispatches them, and adding a new capability means adding a new type to the union — no new routing code on the developer's side. At runtime, this also means there is almost no code to write: you receive from one stream, you send through one method, and that is the entire integration surface.

Here is the minimum viable platform integration:

definePlatform("acme", {
  name: "acme",
  config: configSchema,
  lifecycle: { createClient },
  user: { /* ... */ },
  space: { /* ... */ },
  messages: ({ client }) => client.stream(),
  send: async ({ space, content, client }) => {
    if (content.type === "reply") { /* ... */ }
    if (content.type === "reaction") { /* ... */ }
    if (content.type === "typing") { /* ... */ }
    if (content.type === "edit") { /* ... */ }
    return await client.send(space.id, content)
  },
})
definePlatform("acme", {
  name: "acme",
  config: configSchema,
  lifecycle: { createClient },
  user: { /* ... */ },
  space: { /* ... */ },
  messages: ({ client }) => client.stream(),
  send: async ({ space, content, client }) => {
    if (content.type === "reply") { /* ... */ }
    if (content.type === "reaction") { /* ... */ }
    if (content.type === "typing") { /* ... */ }
    if (content.type === "edit") { /* ... */ }
    return await client.send(space.id, content)
  },
})
definePlatform("acme", {
  name: "acme",
  config: configSchema,
  lifecycle: { createClient },
  user: { /* ... */ },
  space: { /* ... */ },
  messages: ({ client }) => client.stream(),
  send: async ({ space, content, client }) => {
    if (content.type === "reply") { /* ... */ }
    if (content.type === "reaction") { /* ... */ }
    if (content.type === "typing") { /* ... */ }
    if (content.type === "edit") { /* ... */ }
    return await client.send(space.id, content)
  },
})

Two functions instead of seven action slots. If your platform does not support a content type (WhatsApp Business has no typing API, for instance), the send handler no-ops for that type. For edge cases outside the two-stream model, there is an optional actions? escape hatch for getMessage? and an optional events? for custom event streams, but most integrations will never touch either.

Backward compatibility

The ergonomic methods still work exactly as before:

await message.react("👀")
await message.reply(text("ack"))
await space.responding(async () => { /* ... */ })
await message.react("👀")
await message.reply(text("ack"))
await space.responding(async () => { /* ... */ })
await message.react("👀")
await message.reply(text("ack"))
await space.responding(async () => { /* ... */ })

Under the hood, message.react("👀") now executes space.send(reaction("👀", message)). They are sugar functions on top of the canonical form — the human-friendly API and the agent-friendly API are the same thing.

What we learned at scale

We built thousands of agents on Spectrum internally before shipping this. The pattern that kept showing up was extensibility: in the old model, adding a new action (say, message editing) meant adding a new branch in every agent's routing logic. With the content type schema, we added the edit type once and every agent that used the schema could immediately generate edits without a single code change. That is the practical payoff of putting capabilities in a schema instead of in imperative code — the schema scales, the routing code does not.

What's next

Over the coming weeks we are shipping more content types as schema extensions, debugging and observability tooling built around the messages → LLM → send loop, and infrastructure improvements for the parts we identified internally that need to work better at scale.

Get started

The PR for this release has the full changeset, including the rewritten platform providers for iMessage, WhatsApp Business, and terminal. The sugar API is fully backward-compatible, and the new canonical form is available immediately.

Documentation · GitHub · Spectrum Cloud


Subscribe Photon Newsletter

Subscribe
Photon Newsletter