Timeline rows, interactions, and the composer

The timeline is a list of rows; the composer is the card under it. Both are slots, so a plugin can paint its own row kinds, render a tool's result as a card, put a question in front of the user and receive a typed answer, and add actions, banners, and submit middleware to the composer. This page teaches the timeline.row key ladder, the row and interaction prop shapes, the manifest keys that declare your kinds, and the composer slot family as built. The worked plugin is deploy from Guides 7 and 8.

Use this when

  • Render your tool's result as a card. The tool-ui:<pluginId>/<renderer> rung of timeline.row.
  • A custom row for your provider's events. The <providerId>:<kind> and <pluginId>/<kind> rungs.
  • Ask the user a structured question. providers/interactions.request on the server plus an interaction.renderer occupant.
  • A /deploy action in the composer. A composer.plusMenu row and a composer.actions button.
  • A banner or a submit gate. composer.stack for a branch-behind banner, composer.submit for a gate while a release runs.

What you build

A ReleaseCard for the tool deploy_releases_deploy, a ReleaseRow for the extension kind deploy/release, a ConfirmRelease interaction renderer with its server-side request, a useAsReleaseNote action in the message bar, a /deploy plus-menu row, a DeployNow composer action, a BranchBehindBanner card, and a releaseInProgress submit middleware.

Steps

1. The row and the ladder

Every timeline row is one Row (kernel-store types.ts Row; consumed through §4.8 defineRowStore): { id, threadId, turnId, parentRowId, seqStart, seqEnd, status, kind, payload, presentation }. kind is a core kind (message, tool, file_change, …), an extension kind <pluginId>/<kind>, or one of the bare row kinds turn | interaction | notice. timeline.row (§5.8) is keyed and thread-scoped; the owner passes five keys in order and the first with an occupant wins (§5 preamble):

Rung Key Applies when
0 tool-ui:<pluginId>/<renderer> kind === "tool" and the result declared its UI
1 tool:<payload.name> kind === "tool"
2 <providerId>:<kind> a provider's override of a core kind on its own threads (provider-claude-code registers claude-code:task)
3 <kind> the kind itself; <pluginId>/<kind> for extension kinds
4 * the ui-default-rows painter: label and icon from presentation

Original follows the same order, so a renderer that cannot handle a payload renders <Original {...props} /> and the next rung paints. Within one key the winner is replaces → pin → priority → plugin id, pinned with bb composition pin slot:timeline.row:<key> <pluginId> (§4.4, §5.19).

2. Type the props once

RowRendererProps (§5.8) is { row; providerId; view; expansion; children; Original }. Merge the shapes you read into SlotMap, extending Guide 7's Row:

// src/slot-types.ts (additions)
export interface RowOwner {
  row: Row; providerId: string; children: ReactNode | null;
  view: { depth: number; inClosedStep: boolean; dimmed: boolean; isFrontier: boolean; scopeActive: boolean }; expansion: { expanded: boolean; expandable: boolean; forced: boolean; toggle(): void };
}
export interface Interaction {                         // the built providers/interactions row (§7.8)
  id: string; threadId: string; kind: string; status: "pending" | "resolving" | "resolved" | "interrupted"; payload: JsonObject;
}
export type InteractionResolution = { kind: "submitted"; data: unknown } | { kind: "interrupted" };   // approval/answer branches: §7.8
export type InteractionOwner = { interaction: Interaction; view: "takeover" | "row"; resolve(value: InteractionResolution): Promise<void>; interrupt(): Promise<void> };
export interface ComposerOwner {                       // full shape §5.9; only what these examples read
  composerId: string; mode: "newThread" | "followUp" | "queuedEdit" | "sentEdit" | "embedded"; threadId: string | null; draft: { text: string }; selection: { value: { providerId: string } }; submitMode: "ready" | "queue" | "blocked";
}
declare module "@get-bb/plugin-sdk/app" {
  interface SlotMap {
    "interaction.renderer": { kind: "keyed"; scope: "thread"; props: InteractionOwner };
    "message.action": { kind: "list"; scope: "thread"; props: { row: Row; selection: string | null; isLatestActionable: boolean } };
    "composer.plusMenu": { kind: "list"; scope: "pane"; props: ComposerOwner & { insertText(text: string): void } };
    "composer.actions": { kind: "list"; scope: "pane"; props: ComposerOwner };
    "composer.stack": { kind: "list"; scope: "pane"; props: ComposerOwner & { view: { takeover: object | null } } };
    "composer.submit": { kind: "list"; scope: "pane"; props: Record<string, unknown> };
  }
}

3. A row for your own event kind

Declare the kind in the manifest. KindDeclaration (§1.3): item: true needs schema.open and schema.close; item: false needs schema.payload; deltaPaths, retention, and ignorable default. Keys start with your id; turn, interaction, notice are refused (D11).

"contributes": { "eventKinds": { "deploy/release": { "item": true, "schema": {
  "open":  { "type": "object", "properties": { "version": { "type": "string" } }, "required": ["version"] },
  "close": { "type": "object", "properties": { "ok": { "type": "boolean" } }, "required": ["ok"] },
  "payload": null } } } }

Where the events come from: a provider bridge emits them as thread/delta items with kind: "deploy/release", validated on the host against this schema (§7.6); a server tier appends them through kernel/store.eventsAppend { threadId, events, commandId } (§3.11, actors system | plugin). kernel-store's generic reducer folds an item's open, deltas, and close into one row with the accumulated payload; contributes.reducers is spec and you write none. The renderer registers rung 3, inside setup(app) as in Guide 7:

const releaseBody = z.looseObject({ version: z.string(), ok: z.boolean().nullable().default(null) });
export function ReleaseRow(props: SlotComponentProps<"timeline.row">) {
  const { row, Original } = props;
  const body = releaseBody.safeParse(row.payload);
  if (!body.success) return <Original {...props} />;
  const icon = row.status === "pending" ? "Loading" : body.data.ok ? "CircleCheck" : "CircleX";
  return <section className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
    <Icon name={icon} className="size-4" aria-hidden /><span>{row.presentation?.label ?? `Release ${body.data.version}`}</span></section>;
}
app.slots.inject("timeline.row", (slots) =>                             // inside setup(app)
  slots.register({ name: "timeline.row", kind: "keyed", scope: "thread", key: "deploy/release" }, ReleaseRow));

4. A tool result as a card

A mutation with expose: { tool: true } becomes an agent tool (§8.5). Add ui: { renderer } beside presentation (§2.2 ToolMeta) and every result is wrapped as ui: { renderer, payload: result } on the tool row, which enables rung 0 with the key tool-ui:<pluginId>/<renderer>:

// contracts.ts
deploy: method({
  kind: "mutation", summary: "Deploy a release", input: z.strictObject({ releaseId: z.string() }),
  output: releaseResultSchema, expose: { tool: true },               // output: { releaseId, version, ok, url }
  tool: { presentation: { label: { pending: "Deploying…", completed: "Deployed" }, icon: "Zap", intent: "generic" }, ui: { renderer: "release-card" } },
  renderText: (r) => `${r.version}: ${r.ok ? "ok" : "failed"}`,
}),
const toolUi = z.looseObject({ result: z.looseObject({ ui: z.looseObject({ payload: releaseResultSchema }) }) });
export function ReleaseCard(props: SlotComponentProps<"timeline.row">) {
  const parsed = toolUi.safeParse(props.row.payload);
  if (!parsed.success) return <props.Original {...props} />;        // rung 1+ shows result.content as text
  const r = parsed.data.result.ui.payload;
  return <a className="block rounded-md border border-border p-3 text-sm" href={r.url}>Release {r.version} — {r.ok ? "live" : "failed"}</a>;
}
app.slots.inject("timeline.row", (slots) =>                             // inside setup(app)
  slots.register({ name: "timeline.row", kind: "keyed", scope: "thread", key: "tool-ui:deploy/release-card" }, ReleaseCard));

With the plugin absent or disabled the ladder continues to the stock tool renderer, so the card is a progressive enhancement.

5. A structured question from your plugin

Three parts: the kind in the manifest, the request from the server, the renderer in the app. contributes.interactionKinds is Record<"<id>/<kind>", { schema; resolutionSchema }> (§1.3); every open-kind payload carries a title (§7.8).

"contributes": { "interactionKinds": { "deploy/confirm-release": {
  "schema": { "type": "object", "properties": { "title": { "type": "string" }, "data": { "type": "object" } }, "required": ["title", "data"] },
  "resolutionSchema": { "type": "object", "properties": { "confirmed": { "type": "boolean" } }, "required": ["confirmed"] } } } },
"requires": { "providers/interactions": "^1.0.0" }

The broker is the built service providers/interactions (§7.8 flow): request is the mutation behind the interaction.request command, resolve is behind interaction.resolve, list and show read. Inject it by its contract object from the providers plugin's contracts module (server-side; in the app tier query providers/interactions by id string, as provider-claude-code does). An agent tool call runs as actor { kind: "agent", id: threadId } (§8.5), which is the thread to ask on:

// server.ts
import { interactions } from "@get-bb/plugin-providers/contracts";
export default definePlugin({
  async activate(ctx) {
    const broker = await ctx.inject(interactions, "^1.0.0");
    await ctx.provide(releases, withDefaults(releases, {
      deploy: async ({ releaseId }, call) => {
        if (call.actor.kind !== "agent") throw new KernelError({ code: "precondition", message: "deploy runs from a thread" });
        await broker.request({ threadId: call.actor.id, kind: "deploy/confirm-release",
          payload: { title: `Deploy ${releaseId}?`, data: { version: releaseId } } });   // pending now; the answer arrives as interaction.resolve
        return { releaseId, version: releaseId, ok: false, url: "" };
      },
    }));
  },
});

The renderer is keyed deploy/confirm-release. view: "takeover" is the live form inside the composer body; view: "row" is the recorded state in the timeline. Resolve an open kind with { kind: "submitted", data }; interrupt() runs thread.stop { interrupt: true } and there is no cancel resolution (§5.8).

export function ConfirmRelease(props: SlotComponentProps<"interaction.renderer">) {
  const { interaction, view, resolve } = props;                      // `interrupt()` is also available
  const title = typeof interaction.payload["title"] === "string" ? interaction.payload["title"] : "Confirm release";
  if (view === "row" || interaction.status !== "pending") return <p className="text-sm text-muted-foreground">{title}: {interaction.status}</p>;
  return (
    <section className="flex flex-col gap-2 p-3">
      <p className="text-sm font-medium">{title}</p>
      <div className="flex gap-2">
        <Button size="sm" onClick={() => void resolve({ kind: "submitted", data: { confirmed: true } })}>Deploy</Button>
        <Button size="sm" variant="outline" onClick={() => void resolve({ kind: "submitted", data: { confirmed: false } })}>Not now</Button></div>
    </section>
  );
}
app.slots.inject("interaction.renderer", (slots) =>                                        // inside setup(app)
  slots.register({ name: "interaction.renderer", kind: "keyed", scope: "thread", key: "deploy/confirm-release" }, ConfirmRelease));

resolve is CAS: a second resolver gets conflict. Status changes publish providers/interactions.changed; a defineQuery over providers/interactions.list with that event in invalidateOn keeps a pending count live (the built plan renderer does this). Core approvals use the closed keys approval:command | file_change | permission_grant | plan and question; a provider override is <providerId>:approval:<subject>.

6. An action in the message bar

message.action (§5.8) is a thread-scoped list; the owner passes { row, selection, isLatestActionable } and the stock occupants are copy 10, fork 20, add-to-chat 40. The occupant is data, not a control: the reference says it ships MessageAction { id; label; icon; when(ctx); run(ctx) } and the owner paints the button, so carry the action in store as step 8 does for composer.submit and register a component that renders nothing. The owner, ui-timeline-view, is not in this slice, so this is §5.8's shape, not verified against the built owner.

interface MessageActionCtx { row: Row; selection: string | null; isLatestActionable: boolean }
export const useAsReleaseNote = {
  id: "deploy/use-as-note", label: "Use as release note", icon: "Copy",
  when: (ctx: MessageActionCtx) => ctx.row.kind === "message",
  run: (ctx: MessageActionCtx) => void navigator.clipboard.writeText(`/deploy ${(ctx.selection ?? String(ctx.row.payload["text"] ?? "")).trim()}`),
};
const CopyReleaseAction = () => null;                                   // the owner paints the control from `store`
app.slots.inject("message.action", (slots) =>                           // inside setup(app)
  slots.register({ name: "message.action", kind: "list", scope: "thread", order: 70, store: defineStore({ init: () => useAsReleaseNote, actions: {} }) }, CopyReleaseAction));

7. The composer family

All sixteen composer.* slots are pane-scoped children of ui-composer's two registrations, instanced per owner mount (§5.9). Four matter here.

composer.plusMenu rows get insertText(text) and insertMention(resource); this is where /deploy belongs, mirroring provider-claude-code's Plan row at 300:

export function DeployMenuRow(props: SlotComponentProps<"composer.plusMenu">) {
  return <button type="button" role="menuitem" className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent" onClick={() => props.insertText("/deploy ")}>
    <Icon name="Zap" className="size-4" aria-hidden /><span className="flex-1">Deploy</span><span className="text-xs text-muted-foreground">/deploy</span></button>;
}
app.slots.inject("composer.plusMenu", (slots) =>                                           // inside setup(app)
  slots.register({ name: "composer.plusMenu", kind: "list", scope: "pane", order: 800 }, DeployMenuRow));

composer.actions sits right of the action row (max three inline, then an overflow popover); owner props are ComposerOwner only, so a DeployNow button there calls useReleases()?.deployLatest({}) rather than touching the draft. Register it with when: (o) => o.mode === "followUp" so it stays out of the new-thread composer:

app.slots.inject("composer.actions", (slots) =>                                            // inside setup(app)
  slots.register({ name: "composer.actions", kind: "list", scope: "pane", order: 100, when: (o) => o.mode === "followUp" }, DeployNow));

composer.stack holds the cards above the editor; declared and empty in the slice, plugins register at 800+ (§5.9). The reference says an occupant registers { chrome; showDuringTakeover }, which RegisterOptions lacks (§4.2); the built transport for registration-time data is store, as in step 8. Use when for visibility:

export function BranchBehindBanner(props: SlotComponentProps<"composer.stack">) {
  const status = branchStatus.use({ threadId: props.threadId ?? "" });   // defineQuery over deploy/releases.branchStatus
  if (status.status !== "success" || status.data.behind === 0) return null;
  return <div role="status" className="rounded-md border border-border px-3 py-2 text-sm">Branch is {status.data.behind} commits behind.</div>;
}
app.slots.inject("composer.stack", (slots) =>                                              // inside setup(app)
  slots.register({ name: "composer.stack", kind: "list", scope: "pane", order: 820, when: (o) => o.threadId !== null && o.view.takeover === null,
    store: defineStore({ init: () => ({ chrome: "card" as const, showDuringTakeover: false }), actions: {} }) }, BranchBehindBanner));

8. Gate or wrap the send with composer.submit

composer.submit is a data list slot: the occupant is a SubmitMiddleware { id; order; when(ctx); disabled(ctx); run(ctx, next) } and the component never renders (§5.9). The built pattern, from provider-claude-code, puts the middleware in store and registers a null component. The frozen chain is 100 attachmentsReady … 500 provider middlewares · 600 serialize · 700 dispatch · 800 drafts · 900 navigate; the primary action's disabled reason is the first non-null disabled(ctx) in order.

interface SubmitCtx { draft: { text: string }; thread: { id: string; pendingInteraction: boolean } | null }
export const releaseInProgress = {
  id: "deploy/release-in-progress", order: 650,                         // after serialize, before dispatch
  when: (ctx: SubmitCtx) => ctx.draft.text.startsWith("/deploy"),
  disabled: (ctx: SubmitCtx) => ctx.thread !== null && activeReleases.has(ctx.thread.id) ? { reason: "A release is running", code: "deploy/busy" } : null,
  run: <T extends { ok: boolean }>(_ctx: SubmitCtx, next: () => Promise<T>): Promise<T> => next(),
};
const ReleaseSubmit = () => null;
app.slots.inject("composer.submit", (slots) =>                                             // inside setup(app)
  slots.register({ name: "composer.submit", kind: "list", scope: "pane", order: 650, store: defineStore({ init: () => releaseInProgress, actions: {} }) }, ReleaseSubmit));

The verb decides the command: sendthread.send { mode: "queue" }, steerthread.send { mode: "steer" }, queuethread.queue.create, stopthread.stop, createthread.create; thread.send's mode enum is auto | steer | queue | new-turn and the composer never sends auto (§5.9). To change what is sent, intercept thread.send on the server with ctx.commands.before (§3.4), which sees the same mode and input.

What happens at runtime

  1. A bridge item or a server append lands in kernel-store; the reducer upserts a row and core publishes kernel/store.rows keyed thread:<id> (§4.8, D12). ui-timeline-view's row store applies the op and renderSlot("timeline.row", props, { keys }) walks the ladder; your renderer mounts under its own crash boundary, and a throw latches it so Original's rung paints instead (§4.4).
  2. interaction.request opens a pending_interactions row; the composer body chain selects the takeover, composer.stack shows only showDuringTakeover cards, and the renderer mounts with view: "takeover" while the editor stays mounted hidden (§5.9, §7.8). resolve dispatches interaction.resolve; the broker relays to the bridge, the row becomes resolved, and the same renderer re-mounts with view: "row".
  3. On Enter the composer runs the submit chain in order; serialize builds the request, dispatch runs the command, drafts clears on success.

Pitfalls

  • Rung 0 checks payload.result.ui (§5 preamble) while §7.6's tool item grammar carries ui: { renderer, payload } | null at the top of the payload; parse defensively and dump a real row before shipping.
  • contributes.reducers and per-kind ui-timeline-view/kinds policy (expandable, summary verbs, text for the CLI) are spec; rows of extension kinds are reduced generically (§5.8, §5.14).
  • The plugin-defined interaction key is <pluginId>/<name>; approval:<subject> is closed and snake_case, and composer.interaction does not exist (§5.20).
  • useComposer() and useComposerView() are spec (09 §3.14) and not in the built slice (§10.5); read ComposerOwner from props.
  • composer.typeahead.source has no sources in the slice; a bare / is literal text, so /deploy inserts through composer.plusMenu, not a typeahead (§5.9).
  • composer.stack's chrome/showDuringTakeover transport is not a RegisterOptions field; the store form above follows the built composer.submit pattern but is not verified against ui-composer in this slice (§4.2, §5.9).
  • SubmitContext.request is null before order 600; a middleware under 600 cannot read the command (09 §3.8; not in the reference — SubmitLikeContext in provider-claude-code app.tsx carries only selection, draft, thread).

See also

  • Reference §5 preamble and §5.8–5.9 (rows, interactions, composer), §7.6–7.8 (item grammar, presentation, interactions), §8.5 (tool results), §1.3 (eventKinds, interactionKinds).
  • Guide 7, UI slots — keyed registration, Original, pins, crash boundaries.
  • Guide 8, Pages, panes, navigation, and commands — opening a thread pane from a row.
  • next/plugins/provider-claude-code/src/app.tsx — built claude-code:task, claude-code:approval:plan, plus-menu, and submit middleware registrations.