channel docs

channel / concepts

Threads & contexts

A thread is the unit your application talks to; a context is the unit everything else hangs off. The thin indirection between them — agent_threads pointing at event_contexts — is what lets one conversation span web, slack and whatsapp without your code ever merging anything.

The indirection

The agent domain adds exactly one entity, and it is deliberately thin:

@ekairos/agent/schema
agent_threads: i.entity({
  key: i.string().optional().unique().indexed(),
  title: i.string().optional(),
  status: i.string().optional().indexed(),
  metadata: i.json().optional(),
  createdAt: i.date().indexed(),
  updatedAt: i.date().optional(),
}),

// the one link that matters
agent_threadsContext: {
  forward: { on: "agent_threads", has: "one", label: "context" },
  reverse: { on: "event_contexts", has: "one", label: "thread" },
},

A thread owns no items, no messages, no executions — it owns a context, one-to-one. The context (from the events domain) is where the actual conversation state lives: agent items, executions, steps, parts, and — via the channel domain's links — every canonical channel message. The thread is the stable, addressable handle on top: it has a unique key your application chooses, a title for lists, a status for dashboards.

The payoff of the indirection: the application-facing identity (the key, the title) and the event-sourced machinery (the context) evolve independently. Renaming a thread touches one row; the context engine underneath never knows threads exist.

One conversation, one thread, one context

The channel runtime hands you a stable threadKey per platform conversation. resolveContextId is the single mapping point where that platform identity becomes an agent identity:

the mapping point
resolveContextId: async ({ channel, threadKey }) => {
  // platform conversation -> agent thread (idempotent: unique key)
  const thread = await ensureThread({ key: `${channel}:${threadKey}` });
  // agent thread -> agent context (the one-to-one link)
  return thread.contextId;
},

Because agent_threads.key is unique and the function is idempotent, the equation holds for the lifetime of the conversation: one platform conversation = one thread = one context. The hundredth telegram message in a chat resolves to the same context as the first. And because the mapping is yours, so are the exceptions — route two platforms into one shared context, or shard a noisy channel into per-topic contexts, by changing one function.

The composition

how the domains stack
            agent_threads                     <- @ekairos/agent
            key: "telegram:chat-88231"           the handle your app addresses
                  |
                  | context (one-to-one)
                  v
            event_contexts                    <- @ekairos/events
            the durable conversation
                  |
        +---------+-----------------+
        | items (many)              | channelMessages (many)
        v                           v
   event_items                channel_messages    <- @ekairos/channel
   the agent timeline:        the canonical wire:
   - user input items         - inbound  (whatsapp, slack, ...)
   - assistant reactions      - outbound (replies, broadcasts)
   - executions/steps/parts   - status, externalId, raw payload

Each domain composes the one below it: agentDomain includes channelDomain, which includes eventsDomain. Pushing the agent schema gives you the whole stack in one InstantDB app — which is exactly why a single query can walk all of it.

Items and channel messages share the context

The two collections answer different questions about the same conversation. event_items is the agent's timeline: what triggered a reaction, what the reactor produced, with full execution provenance (executions, steps, parts). channel_messages is the wire record: what actually crossed each channel, in which direction, with what delivery status and provider payload.

They overlap without duplicating: an inbound whatsapp text exists as a channel message (the wire fact) and produces a trigger item (the agent fact); the agent's reply exists as an item (the reasoning fact) and as an outbound channel message (the delivery fact). The optional item link on channel_messages pairs them, and the shared context keeps both collections in one place — no joins across databases, no reconciliation jobs.

One query, the whole conversation

Because everything hangs off one context, reading a conversation is one reactive InstantDB query, not an aggregation:

the shape useThread subscribes to
{
  agent_threads: {
    $: { where: { key: "telegram:chat-88231" } },
    context: {
      items: {},            // the agent timeline
      channelMessages: {},  // every message, every platform
    },
  },
}

This is why useThread returns the complete multichannel conversation as one snapshot — { thread, context, items, messages } — and why it stays live for free. A whatsapp inbound persisted by the webhook, an assistant item written by the reactor, a slack reply marked delivered: each is a write to an entity already inside the subscribed query, so every connected client repaints instantly. There is no endpoint to call and nothing to merge client-side; the data model already did the merging.

Resist the urge to query channel_messages by participant or channel when you mean a conversation. The context is the conversation boundary; everything else is a filter within it.

Next

database…