channel / concepts
Threads & contexts
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:
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:
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
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 payloadEach 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.
One query, the whole conversation
Because everything hangs off one context, reading a conversation is one reactive InstantDB query, not an aggregation:
{
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.
channel_messages by participant or channel when you mean a conversation. The context is the conversation boundary; everything else is a filter within it.