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 oftimeline.row. - A custom row for your provider's events. The
<providerId>:<kind>and<pluginId>/<kind>rungs. - Ask the user a structured question.
providers/interactions.requeston the server plus aninteraction.rendereroccupant. - A
/deployaction in the composer. Acomposer.plusMenurow and acomposer.actionsbutton. - A banner or a submit gate.
composer.stackfor a branch-behind banner,composer.submitfor 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: send → thread.send { mode: "queue" }, steer → thread.send { mode: "steer" }, queue → thread.queue.create, stop → thread.stop, create → thread.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
- A bridge item or a server append lands in kernel-store; the reducer upserts a row and core publishes
kernel/store.rowskeyedthread:<id>(§4.8, D12).ui-timeline-view's row store applies the op andrenderSlot("timeline.row", props, { keys })walks the ladder; your renderer mounts under its own crash boundary, and a throw latches it soOriginal's rung paints instead (§4.4). interaction.requestopens apending_interactionsrow; the composer body chain selects the takeover,composer.stackshows onlyshowDuringTakeovercards, and the renderer mounts withview: "takeover"while the editor stays mounted hidden (§5.9, §7.8).resolvedispatchesinteraction.resolve; the broker relays to the bridge, the row becomesresolved, and the same renderer re-mounts withview: "row".- On Enter the composer runs the submit chain in order;
serializebuilds the request,dispatchruns the command,draftsclears on success.
Pitfalls
- Rung 0 checks
payload.result.ui(§5 preamble) while §7.6's tool item grammar carriesui: { renderer, payload } | nullat the top of the payload; parse defensively and dump a real row before shipping. contributes.reducersand per-kindui-timeline-view/kindspolicy (expandable, summary verbs,textfor 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, andcomposer.interactiondoes not exist (§5.20). useComposer()anduseComposerView()are spec (09 §3.14) and not in the built slice (§10.5); readComposerOwnerfrom props.composer.typeahead.sourcehas no sources in the slice; a bare/is literal text, so/deployinserts throughcomposer.plusMenu, not a typeahead (§5.9).composer.stack'schrome/showDuringTakeovertransport is not aRegisterOptionsfield; thestoreform above follows the builtcomposer.submitpattern but is not verified againstui-composerin this slice (§4.2, §5.9).SubmitContext.requestisnullbefore order 600; a middleware under 600 cannot read the command (09 §3.8; not in the reference —SubmitLikeContextin provider-claude-codeapp.tsxcarries onlyselection,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 —
keyedregistration,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— builtclaude-code:task,claude-code:approval:plan, plus-menu, and submit middleware registrations.