channel docs

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

@ekairos/agent/react
export function useThread(db: any, options: UseThreadOptions): ThreadValue
proptypedescription
db *anyInstantDB react client (the one with useQuery).
options.key *stringStable thread key (agent_threads.key). The hook resolves the thread and its context from it.
options.apiUrl *stringEndpoint that runs the thread reaction server-side (append posts here). Reads never go through it: data is reactive InstantDB.
options.onContextUpdateUseContextOptions["onContextUpdate"]Callback fired when the context updates.
options.prepareAppendArgsUseContextOptions["prepareAppendArgs"]Transforms the arguments before append sends them.
options.prepareRequestBodyUseContextOptions["prepareRequestBody"]Customizes the request body posted to apiUrl.
options.enableResumableStreamsUseContextOptions["enableResumableStreams"]Opts into resumable streaming for in-flight turns.

ThreadValue

type
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[];
};
proptypedescription
threadThreadMeta | nullThread metadata (id, key, title, status, timestamps, contextId); null until the thread row loads.
messagesChannelMessage[]Every channel message attached to the thread's context, ordered by createdAt, with itemId/contextId resolved from the links.
timelineThreadTimelineEntry[]Context events + unattached channel messages, in chronological order.

Inherited from ContextValue (the reactive context surface from @ekairos/events/react):

proptypedescription
eventsContextEventForUI[]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() => voidStops the in-flight turn.
contextStatusContextStatusLive status of the context (e.g. open_idle, running states), derived reactively from the context row.
sendStatusSendStatusStatus of the current append request.
context, contextId, sendError, ...ContextValueThe rest of ContextValue carries over as-is: apiUrl, context, contextId, activeExecutionId, turnSubstateKey, sendError.

ThreadEventForUI

type
/** A context event enriched with the channel messages linked to it. */
export type ThreadEventForUI = ContextEventForUI & {
  channelMessages?: ChannelMessage[];
};
proptypedescription
channelMessagesChannelMessage[]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.
...ContextEventForUIinheritedEverything a context event already exposes to the UI.

ThreadTimelineEntry

type
/** 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 };
proptypedescription
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.
The timeline rule: messages anchored to an item (their 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.

@ekairos/agent
export function buildThreadTimelineQuery(threadKey: string) {
  return {
    agent_threads: {
      $: { where: { key: threadKey } },
      context: {
        items: {
          $: { order: { createdAt: "asc" as const } },
          channelMessages: {},
        },
        channelMessages: {},
      },
    },
  };
}
usage
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

app/threads/[key]/thread-view.tsx
"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.

Next

database…