channel / reference
useThread integration
useThread lives in the agent domain (@ekairos/agent/react) and is where channel meets the UI: one hook returns the whole multichannel conversation — the thread, its context events, and every channel_messages record attached to the context — as reactive InstantDB queries. Only append touches the reaction endpoint.How channel plugs in
Internally the hook composes three reactive queries: agent_threads by key (to resolve the contextId), the context with its event_items and their linked channelMessages, and all channel_messages on the context ordered by createdAt. The channel domain links (context, item) are what make this possible without any data API: a whatsapp reply appears in the timeline the moment the webhook persists it.
Signature
export function useThread(db: any, options: UseThreadOptions): ThreadValue| prop | type | description |
|---|---|---|
| db * | any | InstantDB react client (the one with useQuery). |
| options.key * | string | Stable thread key (agent_threads.key). The hook resolves the thread and its context from it. |
| options.apiUrl * | string | Endpoint that runs the thread reaction server-side (append posts here). Reads never go through it: data is reactive InstantDB. |
| options.onContextUpdate | UseContextOptions["onContextUpdate"] | Callback fired when the context updates. |
| options.prepareAppendArgs | UseContextOptions["prepareAppendArgs"] | Transforms the arguments before append sends them. |
| options.prepareRequestBody | UseContextOptions["prepareRequestBody"] | Customizes the request body posted to apiUrl. |
| options.enableResumableStreams | UseContextOptions["enableResumableStreams"] | Opts into resumable streaming for in-flight turns. |
ThreadValue
export type ThreadValue = ContextValue & {
thread: ThreadMeta | null;
/** Every channel message attached to the thread's context. */
messages: ChannelMessage[];
/** Context events + unattached channel messages, in chronological order. */
timeline: ThreadTimelineEntry[];
};| prop | type | description |
|---|---|---|
| thread | ThreadMeta | null | Thread metadata (id, key, title, status, timestamps, contextId); null until the thread row loads. |
| messages | ChannelMessage[] | Every channel message attached to the thread's context, ordered by createdAt, with itemId/contextId resolved from the links. |
| timeline | ThreadTimelineEntry[] | Context events + unattached channel messages, in chronological order. |
Inherited from ContextValue (the reactive context surface from @ekairos/events/react):
| prop | type | description |
|---|---|---|
| events | ContextEventForUI[] | The context items in order. In useThread they come from the thread state query and carry their linked channelMessages — cast to ThreadEventForUI[] when you need that field. |
| append | (args: AppendArgs) => Promise<void> | Posts a new user turn to apiUrl, triggering the thread reaction server-side. The only call that leaves the client. |
| stop | () => void | Stops the in-flight turn. |
| contextStatus | ContextStatus | Live status of the context (e.g. open_idle, running states), derived reactively from the context row. |
| sendStatus | SendStatus | Status of the current append request. |
| context, contextId, sendError, ... | ContextValue | The rest of ContextValue carries over as-is: apiUrl, context, contextId, activeExecutionId, turnSubstateKey, sendError. |
ThreadEventForUI
/** A context event enriched with the channel messages linked to it. */
export type ThreadEventForUI = ContextEventForUI & {
channelMessages?: ChannelMessage[];
};| prop | type | description |
|---|---|---|
| channelMessages | ChannelMessage[] | The channel messages anchored to this event via the channel_messagesItem link — e.g. the outbound email an agent turn produced. Render them inside the event. |
| ...ContextEventForUI | inherited | Everything a context event already exposes to the UI. |
ThreadTimelineEntry
/** One conversation timeline across every channel: context events interleaved
* with channel messages (email, whatsapp, slack, ...) that are not already
* attached to an event. */
export type ThreadTimelineEntry =
| { kind: "event"; at: string; event: ThreadEventForUI }
| { kind: "message"; at: string; message: ChannelMessage };| prop | type | description |
|---|---|---|
| kind: "event" | { kind: 'event'; at: string; event: ThreadEventForUI } | A context event, stamped with its createdAt as ISO at. Its anchored channel messages travel inside event.channelMessages. |
| kind: "message" | { kind: 'message'; at: string; message: ChannelMessage } | A channel message that is not anchored to any event — e.g. an inbound whatsapp that arrived between turns, or a broadcast you sent from the product. |
id appears in some event's channelMessages) are rendered inside that event and skipped as standalone entries; unanchored messages are interleaved with the events, sorted chronologically by at. No message appears twice.buildThreadTimelineQuery
For manual queries — server components, scripts, or custom hooks — the agent domain exports the exact InstantDB query useThread is built on: the thread, its context items in order, and the channel messages linked to each item and to the context.
export function buildThreadTimelineQuery(threadKey: string) {
return {
agent_threads: {
$: { where: { key: threadKey } },
context: {
items: {
$: { order: { createdAt: "asc" as const } },
channelMessages: {},
},
channelMessages: {},
},
},
};
}import { buildThreadTimelineQuery } from "@ekairos/agent";
// reactive, client-side
const { data } = db.useQuery(buildThreadTimelineQuery("whatsapp:+5491155550123"));
// one-shot, server-side (admin client)
const result = await adminDb.query(buildThreadTimelineQuery("whatsapp:+5491155550123"));Rendering the timeline
"use client";
import { useThread, type ThreadEventForUI } from "@ekairos/agent/react";
import type { ChannelMessage } from "@ekairos/channel";
import { db } from "@/lib/db.client";
export function ThreadView({ threadKey }: { threadKey: string }) {
const { thread, timeline, append, sendStatus } = useThread(db, {
key: threadKey,
apiUrl: "/api/thread",
});
return (
<div>
<h1>{thread?.title ?? threadKey}</h1>
<ol>
{timeline.map((entry) =>
entry.kind === "event" ? (
<EventEntry key={entry.event.id} event={entry.event} />
) : (
<MessageBubble key={entry.message.id} message={entry.message} />
),
)}
</ol>
<form
onSubmit={async (e) => {
e.preventDefault();
const input = e.currentTarget.elements.namedItem("text") as HTMLInputElement;
await append({ text: input.value });
input.value = "";
}}
>
<input name="text" disabled={sendStatus === "submitted"} />
</form>
</div>
);
}
function EventEntry({ event }: { event: ThreadEventForUI }) {
return (
<li>
<article>{/* render the agent event (text, reasoning, tools) */}</article>
{/* anchored channel messages render inside their event */}
{event.channelMessages?.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
</li>
);
}
function MessageBubble({ message }: { message: ChannelMessage }) {
return (
<li data-direction={message.direction}>
<span>{message.channel}</span>
<p>{message.text}</p>
<time>{new Date(message.createdAt).toLocaleTimeString()}</time>
</li>
);
}One branch on entry.kind covers the whole multichannel conversation: agent events (with their anchored deliveries inside) and free-standing channel messages, in one chronological stream.