@nightowlsdev/react
UIDrop-in React + Tailwind chat UI for Night Owls swarms, plus a fully headless hooks layer that renders live run state.
What it does
@nightowlsdev/react renders the live state of a swarm run, assistant messages, agent handoffs/delegation, tool activity, status, scratchpad, presence, and human-in-the-loop follow-ups/approvals, from the Night Owls wire format. Use the styled <SwarmChat> (or floating <SwarmFab>) wrapped in <SwarmProvider>, import the precompiled styles.css (no Tailwind build needed in the host), and optionally wire Supabase Realtime via the /supabase adapter for durable background runs. It merges three transports (interactive SSE, durable Realtime, hydrate/backfill) into one deduped seq-ordered log feeding a pure reducer, and parses AI SDK v7 SSE itself, so it does NOT depend on @ai-sdk/react or @mastra/*. For custom markup, import from @nightowlsdev/react/headless to get the hooks, reducer, and stream parser with no styled components; theming is via --owl-* CSS tokens, a theme prop, a classNames slot map, and an override-component registry, with the framework-agnostic @nightowlsdev/theme API re-exported. The experimental ./studio subpath adds <AgentStudio>, an embeddable admin surface for editing agent config in your database, with visual builders for per-agent rules and workflows (and a read-only workflow DAG) as the default slots since 2.7.0, keeping the JSON editors as an escape hatch. Since 2.8.0, <SwarmChat> also exposes the FR-002 client-tool seam directly via clientTools/confirmClientAction/onClientTools, so a browser-executed tool (camera, selection, upload, apply-to-scene) runs inside the adopted chat UI without rebuilding it on the raw useSwarmChat hook. As of 2.11.0 the ./studio subpath also ships <AIStudio> and <SkillStudio>, both STABLE (their props are frozen on arrival; <AgentStudio> alone remains experimental). <AIStudio> composes agents, skills, knowledge, tools and the graph workbench into ONE operator console via a section registry: a section whose client you do not pass simply does not render, sections mount lazily on first activation, readOnly can only ever NARROW what the server already permits, and graph-react is resolved at runtime so a host without that optional peer still builds. <SkillStudio> is the skill library and store, where the Import button is reachable only from a preview, because preview is what mints the server-issued token that import redeems.
Install
pnpm add @nightowlsdev/reactKey exports
- SwarmProvider
- useSwarm
- SwarmChat
- SwarmFab
- useSwarmChat
- useSwarmRun
- useSwarmThreads
- useSwarmAgents
- useSwarmScratchpad
- Timeline / MessageBubble / Composer / DelegationCard / AgentPile
- ThemeEditor
- ComponentsProvider / useComponents (override registry)
- buildTimeline / groupTimeline
- /headless and /supabase subpath exports
- ./studio: AIStudio (one console with a section registry; STABLE)
- ./studio: SkillStudio + createSkillEndpointsClient (skill library and store; STABLE)
- ./studio (experimental): AgentStudio, RuleBuilderVisual, WorkflowBuilderVisual, WorkflowDag, useStudioFeeds
Usage
import { SwarmProvider, SwarmChat } from "@nightowlsdev/react";
import "@nightowlsdev/react/styles.css";
export function Room() {
return (
<SwarmProvider mode="dark" theme={{ "--owl-accent": "#E8A33D" }}>
<SwarmChat agentSlug="editor" threadId="thread-1" />
</SwarmProvider>
);
}What it provides
react is the client that renders a Night Owls run's live event stream: assistant messages, agent handoffs and delegation lanes, tool activity, status, the shared scratchpad, presence, and the human-in-the-loop follow-ups and approvals — all from the Night Owls wire format. You get a styled drop-in (<SwarmChat> / floating <SwarmFab>) with a precompiled stylesheet, a fully headless hooks layer (/headless) for bespoke UI, and an embeddable operator console (./studio: AIStudio, SkillStudio, the experimental AgentStudio). It parses AI SDK v7 SSE itself, so it depends on neither @ai-sdk/react nor @mastra/*.
When to use it
- You render a Night Owls swarm in a React app and want the chat / Room UI — delegation, HITL approvals, scratchpad, presence — without building it.
- You want a headless data layer (hooks + reducer + SSE parser) to render a completely custom UI over the same wire.
- You want to embed an operator console — the AI Studio, the Skill Store, or the experimental Agent Studio — inside your app.
- You need a browser-executed tool (camera, selection, upload, apply-to-scene) to run inside the adopted chat via the FR-002 client-tool seam.
- A conversation has grown long enough that readers need to search it and jump back to a hit — the opt-in search dock plus anchor navigation does that in place, over the runner's search route.
When not to
- You are not on React — use @nightowlsdev/theme (framework-agnostic tokens) with your own renderer, or consume the wire directly.
- You only need the marketing/app chrome around the chat (nav, footer, buttons) — that is @nightowlsdev/ui, not this.
- You want to render a static past transcript with no live stream — read your history route directly; the live-merge machinery here is overhead for that.
Alternatives
- @nightowlsdev/react/headlessYou want the hooks, the pure reducer, and the SSE parser but none of the styled components — build your own markup on the same data layer. Same package, a different entry.
- @ai-sdk/react (useChat)You are not on Night Owls and want a generic chat hook. You lose the swarm wire — delegation lanes, HITL approvals/questions, the scratchpad, engine-capability affordance gating — which is exactly what this renders.
- @nightowlsdev/uiYou need the chrome AROUND the swarm (layout, nav, primitives), not the chat surface itself.
Strengths
- Drop-in: one <SwarmChat> plus the precompiled styles.css — no Tailwind build in the host.
- Engine-wall clean: it parses AI SDK v7 SSE itself and depends on neither @ai-sdk/react nor @mastra/*.
- Three transports (interactive SSE, durable Realtime, hydrate/backfill) merge into one deduped, seq-ordered log feeding a pure reducer — so a suspend→resume can't interleave the transcript.
- Fully escapable: a /headless entry for custom UI, a component-override registry per built-in slot, a classNames slot map, and --owl-* theme tokens (the whole @nightowlsdev/theme API is re-exported).
- Renders the entire HITL surface — approvals, questions, delegation lanes, scratchpad, presence, the per-conversation task list, attachments, and the client-tool seam.
- Capability-probing affordances degrade to nothing: the search dock retires itself on the route's absent-404, and a result whose anchor is unusable renders as text instead of a control that would jump somewhere unrelated.
Limits & trade-offs
- A React 18/19 client library only ('use client') — the live-stream hooks need a <SwarmProvider>; there is no server render of the running stream.
- It is a CLIENT, not a server: it needs the Night Owls route family (chat/resume/events/history/threads/agents/active) standing behind it — e.g. from @nightowlsdev/runner-nextjs — or it has nothing to talk to.
- Live streaming of durable background runs needs the /supabase Realtime adapter wired into the provider (and the host DB); without it, a resume won't push to other clients.
- Engine-capability gating is hand-fed: react can't read the engine, so the host must read swarm.engine.capabilities server-side and pass it via <SwarmProvider capabilities>; absent it, react assumes a native/full engine.
- The ./studio <AgentStudio> surface is experimental and may change shape in minor releases (<AIStudio> and <SkillStudio> are stable); the built-in visual design is opinionated — deep changes go through the override registry or /headless.
- The search dock is minimal on purpose — one input, one list, no filters, facets or infinite scroll — and it only jumps within its own thread; anything richer, and any cross-conversation result, is the host's to build on the same route plus the exported anchor helpers.
- There is NO scroll management: no stick-to-bottom, no scroll restoration, no observers — live-tail following is the host's to build, and a host bottom-pin deterministically beats jumpToThreadAnchor unless it is released on result activation and re-armed only on a user scroll back to the bottom, the next send, or a TERMINAL false resolve — never on a SUPERSEDED one, where a later jump is still about to land — and where "a user scroll" has to be a gesture flag (wheel / touchmove / keydown / pointerdown), never event.isTrusted, which is true for the jump's own scroll as well. Telling those falses apart needs FR-089's lastJumpResult; a raw-hook host reads it off the hook and a SwarmChat host off onActions, because the dock's own jump is internal and the boolean never leaves the component. FR-080's anchor exactness is also opt-in: only a transcript that wires the reveal handshake (or uses SwarmChat) gets it, and dropping 2.27.0's earliest-row fallback turns some formerly-true jumps into an honest false in every mode.
How it works
A host wraps the tree in <SwarmProvider> (endpoints, optional Realtime seams, theme/mode, an optional engine-capabilities descriptor) and mounts <SwarmChat agentSlug threadId>. Under the hood the chat runs useSwarmChat, which merges the interactive SSE stream, an injected durable Realtime feed, and a hydrate/backfill fetch into one log deduped and ordered by the global event seq, then reduces it with a pure reducer into UI state (messages, handoffs, tool activity, followup, status). It parses the AI SDK v7 UI-message SSE itself (parseUiMessageStream / chunkToEvent), so no engine or @ai-sdk/react types leak in. Theming resolves a theme name/object to the --owl-* CSS vars; every built-in renders through a slot registry so a host can replace any single piece and keep the composition.
The search dock and jump-to-result
<SwarmChat messageSearch={{ endpoint }} /> mounts a MINIMAL dock above the transcript: one input, one result list, no filters or facets. It is deliberately small — its job is to prove the plane end to end and give a host something to ship on; anything richer is a host component built on the same route plus jumpToThreadAnchor. It is OPT-IN and off by default (the opposite of the long-thread notice), because searching costs a model call — the query is embedded — against a plane the deployment may not have wired.
Absence is silent in both directions. Omit the prop and nothing renders: no input, no probe request, no warning. Mount it against a deployment with no plane and the route answers a stable 404 { ok:false, reason:"absent" } — the capability probe — and the dock RETIRES, returning null and leaving no node behind. An input that stayed on screen with a line of apology would still be an affordance for a feature the deployment does not have.
A result is navigable in one of two ways, and the rule lives in one place. Every match carries anchorSeq — the latest events.seq at or before that message, as a decimal string — which react parses through parseAnchorSeq under a SAFE-INTEGER guard: null, a non-decimal string, or a value at or beyond Number.MAX_SAFE_INTEGER is NON-NAVIGABLE and the row renders as plain text rather than a control that lands somewhere unrelated. The cap is one below MAX_SAFE_INTEGER because the pager asks for beforeSeq = anchor + 1, so it is the anchor's SUCCESSOR that has to stay exactly representable. Widening the whole client to bigint for a value no deployment has reached was declined; capping is the adopted ruling.
useSwarmChat().jumpToThreadAnchor(anchorSeq, { limit?, root? }) does the navigation: it fetches the thread-events page just newer than the anchor (beforeSeq = anchorSeq + 1, the pager's existing exclusive-cursor idiom), merges it through the SAME lane slicing and seq-dedup the pager uses, then scrolls to the nearest rendered row at or before the anchor. It resolves true when a row was found to scroll to and false otherwise — and since FR-080 it resolves true ONLY when the row it was aiming at was found and scrolled to. false covers five legitimate, non-error cases: a non-navigable anchor; no threadId or no thread-events endpoint; a SUPERSEDED jump (a thread switch, or a later jump the reader started); a page that could not be fetched; and the target row never rendering. "The page carried nothing for this lane" is NOT a case of its own — an empty slice skips the merge and falls through to the poll, so it is the last case or a success, never its own answer. And false now carries a guarantee it could not before, in every transcript mode: NOTHING SCROLLED, so the reader is exactly where they were. It deliberately does NOT move the pager's cursor: the merged page is an island around the anchor, and adopting its cursor would make the next "load earlier" skip everything between the tail and the jump.
The jump is SAME-THREAD only, and the dock compares THREAD ids, not containers. A container-scoped search legitimately returns lane hits — searching conversation c1 matches a delegate's message whose threadId is c1:writer — and those share a container, but the jump pages this hook's thread and then applies the same lane slice the hydrate does, which drops every event belonging to another lane. Such a row is still SHOWN (the search is container-wide on purpose) and becomes navigable only through the host's optional onOpenSearchResult(match), since reaching another conversation or another lane means changing a thread id the host owns. Pass root whenever more than one transcript can be mounted (a SwarmFab beside a page chat, a two-pane layout): seq is global across the container, so without it a marker in another mounted chat can win the nearest-at-or-before comparison and that chat scrolls instead. <SwarmChat> passes its own root element for you.
react ships NO scroll management, so live-tail following is the host's — and a host bottom-pin will fight the jump. Nothing in the package pins, follows or restores a scroll position: the scrollIntoView inside scrollToAnchorMarker is the only scroll call in the source, there is no MutationObserver / IntersectionObserver / ResizeObserver, no scrollTop or scrollHeight, and no scroll-behavior, overflow-anchor or scroll-snap CSS — a source-scan guard re-checks that on every test run, so a future stick-to-bottom cannot land quietly. The failure a host hits is NOT a race. mergeEvents always returns a NEW array, so even a jump page whose events the client already holds produces a fresh events identity and re-fires any pin keyed on it, while jumpToThreadAnchor's first poll tick finds an already-loaded anchor (a short thread, or a hit inside the hydrated tail) and scrolls SYNCHRONOUSLY, in the same task, before that commit. The pin therefore lands last and snaps the reader back to the tail — deterministically, with no live run and no frame-boundary luck involved; during a live run it re-fires on every streamed token. The rule is unconditional and does not depend on how the pin is scheduled: RELEASE it when a result is activated, and re-arm only on a user-initiated scroll back to the bottom or on the next send — never on a timer, and never on "new content arrived", which is the jump's own merge. Re-arm on a TERMINAL false too, and since FR-080 that is backed by a guarantee rather than a convention: the jump never settles for a nearby row, so false means NOTHING SCROLLED and there is no landing for a re-armed pin to destroy — in every transcript mode. "Terminal" is doing real work in that sentence, and FR-089 is why it is now there: a SUPERSEDED false must NOT re-arm, because a later jump is already in flight and about to land, and re-arming there destroys ITS landing — the very defect this contract exists to prevent, reproduced by following the contract.
"User-initiated" means a GESTURE FLAG, not event.isTrusted — and that is the first thing a host gets wrong. isTrusted reports who constructed the event OBJECT, not who caused the scroll: the scroll that follows our own scrollToAnchorMarker → scrollIntoView is dispatched by the user agent, so it arrives with isTrusted === true exactly like a wheel-driven one. A pin whose re-arm is gated on it therefore re-arms off THE JUMP'S OWN SCROLL and yanks the reader straight back off the message they searched for — worst near the tail, where the landing sits close enough to the bottom to read as a return to it. The distinction the rule needs is USER-GESTURE vs PROGRAMMATIC, and only gesture events carry it: raise a flag on wheel / touchmove / keydown / pointerdown, consult that flag FOR THE RE-ARM ONLY — the release on result activation stays unconditional — and clear it on activation, so the next jump starts from no gesture. This came back from an adopter implementing the contract as published: react ships no pin, so there is nothing here for a test of ours to get wrong, and a host is the only signal available.
FR-080 is a behaviour change, not a pure addition, and two of those false results used to be true in EVERY mode. 2.27.0 answered a failed search by scrolling to the OLDEST rendered row and reporting success; that earliest-row fallback is gone everywhere, so an anchor older than the lane's retained history, an island page filtered out of the lane, a target compaction folded away, only structurally unmarked events (handoff / question / the ask tool) at-or-before the anchor, a render slower than every wait plus the final check, or simply an anchor with only newer rows on screen, now returns false instead of landing somewhere unrelated. Supersession is the second: a jump the reader has moved on from — a later jump, or a thread switch — returns false rather than dragging the view back late. Both were kept global deliberately; restoring them for the non-managed modes would have reinstated the exact wrong-landing defect this FR closes, in two paths out of three, to protect the word "additive". A third false is MANAGED-ONLY: a Timeline that resolved a target but never acknowledged it while an older raw marker stayed visible, where 2.27.0 would have landed on that older row and called it success. This therefore ships as a minor with a documented behaviour-change list rather than as a pure addition.
There are three transcript modes, and which one a host is in decides which guarantee it gets. Timeline stamps ANCHOR_REVEAL_ATTR (data-owl-anchor-reveal) on the transcript container on EVERY render, never only while a jump is live — because "the reveal has not committed yet" must stay distinguishable from "this transcript never wired the contract". managed means the reveal prop was passed (reveal={null} between jumps still counts; only omitting it does not): the timeline resolves the destination over every item, including rows a collapsed card hides, and acknowledges it on that row with the jump's own token, so when a managed jump completes the exact-token handshake and returns true, it lands on the timeline-acknowledged row — a managed jump can still time out before acknowledgement and return false, so the guarantee is about where a true lands, not that every managed jump succeeds. unwired means a Timeline rendered without the prop: target selection is 2.27.0's rendered-DOM rule (nearest rendered row at-or-before the anchor), keeping that rule's wrong-row risk because this component folds rows into collapsible cards whether or not anyone wired the signal, and it earns one console warning per hook on the first jump naming the one-line fix. undeclared is a custom transcript that declared nothing: behaviourally IDENTICAL to unwired, differing by diagnostics alone, and silent, because a custom transcript is not misconfigured. There is deliberately no fourth flat state — it would be a pure synonym for undeclared and a public attribute that could never be withdrawn.
A host rendering its OWN transcript has two contracts to honour, and both are exported. First, stamp ANCHOR_MARKER_ATTR (data-owl-seq) on its rows: an anchor names a seq, not a node, so that attribute is the only thing jumpToThreadAnchor can aim at, and without it every jump fetches, merges and then resolves false. That alone earns the rendered-DOM rule, which is the right answer for a transcript that hides nothing. Second, a host that FOLDS content away — collapsible sections, virtualisation — must wire the reveal handshake, because a row absent from the DOM cannot be found however long the jump polls: take anchorReveal ({ seq, token }) off useSwarmChat(), declare data-owl-anchor-reveal="managed" on the container, reveal the folded content, and stamp ANCHOR_TARGET_ATTR (data-owl-anchor-target) on the single row it resolves, valued with that jump's TOKEN — the token names one CALL rather than one anchor, so two overlapping jumps to the same anchor cannot satisfy each other's polls. Resolve that row with the exported anchorTargetOrder / cardHoldsOrder / rendersAnchorMarker instead of re-deriving the rule by hand, which is how a jump quietly starts landing on the wrong message. Also exported from both the main and /headless entries: anchorTranscriptMode, usesRenderedAnchorRule, scrollToRevealedAnchorTarget, createAnchorRevealMint, ANCHOR_TOKEN_SHAPE, and the types AnchorReveal, AnchorTranscriptMode, AnchorRevealMint, UseSwarmChatOptions and UseSwarmChatReturn. The user-visible win is the case that used to be unreachable: a hit whose only at-or-before content sits inside a COLLAPSED DelegationCard had no rendered marker at all, so 2.27.0 either found nothing or landed on an unrelated row, and SwarmChat now opens the card holding it and lands on the right row — wrong destination becoming correct destination wherever a collapsed target coexists with another rendered marker. Separately, non-managed jumps gained an extra settle before the first poll, which can observe a late row 2.27.0's shorter schedule missed. The expansion is a nudge rather than a lock: it clears once the jump settles, so the reader can collapse the card again and a repeat jump re-opens it.
The dock renders under the messageSearch slot (classNames and data-slot), its strings are overridable via messageSearch.strings, and MessageSearchDock plus the anchor helpers (parseAnchorSeq, isNavigableAnchor, findAnchorMarker, scrollToAnchorMarker, MAX_NAVIGABLE_ANCHOR_SEQ, ANCHOR_MARKER_ATTR) are exported standalone so a host on the raw hook can build its own result list and still make the navigability decision the same way — re-deriving it with Number(anchorSeq) is exactly the silent wrong-jump the cap exists to prevent.
Observing the jump: lastJumpResult, and who can see what (FR-089)
The re-arm rule above was published before there was any way for a component host to follow it, and that is the defect FR-089 closes. jumpToThreadAnchor resolves a boolean TO ITS CALLER. A raw-hook host is its own caller and sees it. A SwarmChat host is not: the search dock's onJump is wired internally, SwarmLaneActions carried exactly one field (resumeRefusal), and the result never left the component — so the class of host most likely to have a scroll-pin, the one that did not build the transcript, could not implement the rule at all. The scoping was also wrong as written: the docs stated re-arm-on-false as a universal rule when it was raw-hook-only in practice.
The seam is useSwarmChat().lastJumpResult: the NEWEST PUBLISHED jump outcome, latched, as { token, ok, reason } — newest published rather than last settled, because a jump that settles after a newer click has answered is dropped, and the latch resets to null when the lane or conversation changes. It is a discriminated union on ok, so ok:true narrows reason to null and ok:false narrows it to one of four codes — and tsc, not a comment, is what stops a host reading a reason off a success or forgetting to branch on a failure. SwarmChat republishes exactly the same value on SwarmLaneActions, so a component host reads it from onActions' third argument alongside resumeRefusal.
The reason codes are four for FR-080's five false cases, and every one of them changes what a host would DO. superseded: a later jump or a thread switch took the view — the ONE code a pin must not re-arm on. not-found: the jump ran to the end of its budget and the anchor's row never rendered (another lane, compaction, or nothing marked at-or-before the anchor); terminal, and nothing scrolled. fetch-failed: the anchor's page could not be fetched — a transport failure, a non-OK status, or a non-JSON body; terminal, and the only code for which retrying the SAME jump makes sense. unavailable: the jump never began, either because the anchor is outside the navigable domain or because the deployment has no thread and no thread-events endpoint. Those last two are FR-080's cases 1 and 2 collapsed, deliberately: both are pre-conditions checked before any work, both are synchronously knowable by the host through isNavigableAnchor and the provider's endpoints, and both have the same remedy — do not offer the affordance. Splitting them would publish a distinction with no distinct host behaviour behind it. Do not hand-write the predicate: shouldRearmScrollPin(result) is exported from both the main and /headless entries and IS the rule.
Firing an undiscriminated false would have been worse than shipping nothing, which is why the reason code is the load-bearing half rather than the boolean. But swallowing the superseded case would have been the FR-081 mistake again, and hosts have a second use for it: cancelling a "jumping…" affordance attached to the click that lost. So the seam names why. It reports each PUBLISHED outcome rather than every settle: the latch is monotone in click order, so a loser that settles first is heard while one that settles after a newer answer is dropped. The token is what makes that second use work — it is the same identity anchorReveal carried while that jump was in flight, so an outcome can be tied back to the click that started it.
It is LATCHED, not a per-jump callback, and the trade is stated rather than hidden. A pin only cares about the newest outcome; intermediate results are noise. A latch also matches the resumeRefusal idiom a host already knows, whereas a callback would re-open the stale-closure and ownership class this hook's ownership rules exist to contain — and a callback stays purely ADDITIVE on top of a latch later if a second consumer ever needs one, where the reverse migration would not. The cost: INTERMEDIATE RESULTS MAY BE UNOBSERVABLE, because two jumps settling in one React commit publish only the later identity. Nothing is lost by that, since the re-arm rule is idempotent — re-arming an already-armed pin is a no-op — so a host applies it on every change without tracking what it has already seen. Latching is MONOTONE IN CLICK ORDER: a loser that settles first is published, which is what lets a host retire a jumping affordance for the click that lost, but a loser held on a slow network settling AFTER the winner is dropped rather than latched over it — the value only ever moves forward through the reader's clicks. So this is deliberately NOT a per-click channel: a late loser, and an unavailable click raised while a jump is in flight, produce no publication of their own, and the token exists so a per-jump callback stays purely additive if an adopter needs guaranteed per-click delivery. The latch is also scoped to its thread, reading null from the first render of a different threadId, so a terminal outcome from the conversation the reader just left cannot re-arm the pin of the one they opened. Both fields on SwarmLaneActions are referentially stable, so a host can list them as effect deps or latch them into state without looping. A THROWN jump both latches fetch-failed and still rejects: the rejection is unchanged for a caller that awaits it, and the latch is there because the built-in callers discard that promise, which had left an auth failure observable by nobody at all.
Multi-lane docks: routing a refusal to the lane the user can see (FR-088)
One SwarmChat per agent lane, all mounted, one shown — SwarmFab is that shape with chrome on top. FR-073 renders a refused resume on the lane that OWNS the run, and a delegate lane's ask box resumes on the ROOT hook, because the run never moves off its owner. So the refusal is the root's state, and in this shape the root's mount is the one parked at display:none: the explanation for why the answer did not land renders where nobody can see it, which is FR-073's own bug one layer down. SwarmFab solves it internally, and until 2.29.0 the only way for a host composing lanes itself to learn how was to read SwarmFab.tsx — which an adopter did, and then told us that reading our source is not a documentation strategy.
The decision stayed a RECIPE rather than becoming native visibility inference, for the reason FR-080 rejected an adjacent inference: a component deciding it is hidden would have to inspect computed style or an ancestor chain, and a host that deliberately mounts a hidden lane to pre-warm it would be surprised. What changed is the FORM. The routing is now exported as useRootLaneChannel and laneFollowupProps, and there is a compiled worked example in the repo — packages/react/examples/multi-lane-dock.tsx — in the typecheck scope and rendered by the suite, rather than a fenced block that can drift from the component it demonstrates.
Route by VISIBILITY, never by who asked. Asker-based routing was tried inside SwarmFab and reproduced the bug one layer further down: the root lane renders the WHOLE subtree, so a delegate's ask box shows up in the root transcript too and a user can answer it without ever selecting that lane — asker routing would then send the notice to the delegate's parked mount while the reader sat on the root. attempt.from stays on the refusal as ATTRIBUTION for a host that wants it; it is not a routing input.
Three parts of the recipe fail silently when re-derived, which is the whole argument for exporting them. First: undefined, NEVER null. SwarmChat tests followupRefusal !== undefined — itself a deliberate FR-073 gate fix — because passing the prop at all means "I own this lane's refusal channel", and null is a VALUE on that channel meaning the owner has none right now. Routing null at a lane therefore SUPPRESSES that lane's own live refusal. laneFollowupProps returns props to SPREAD and gives an unclaimed lane no key at all, so there is nowhere for a host to write ?? null. Second: a ROOT-ONLY guard on the onActions latch, which lives inside useRootLaneChannel. Latch a delegate lane's report and the two ping-pong — the delegate reports its own, usually null, refusal, that wipes the root's from the channel, the routing drops, and whether the user ever sees an explanation comes down to which lane's effect happened to run last.
Third, and this is the trap, because copying SwarmFab is the obvious move and it is wrong here: do NOT copy SwarmFab's conditional resumeRefusalNotice={false} on the root. SwarmFab may suppress the root's own notice when another lane is selected because it OWNS the root's visibility — it is the thing that applied the display:none. A generic host's "which lane is visible" is a CLAIM. If that claim is stale, or the layout shows several lanes at once, the suppression turns a redundant-but-visible notice into NO notice at all — the silent failure FR-073 exists to close, reintroduced by the recipe meant to spread its fix. The hidden-root double render is the fail-open direction: at worst the explanation appears twice, once on a mount nobody is looking at. laneFollowupProps never emits that prop.
useRootLaneChannel also carries rootJumpResult, so a dock's FR-089 re-arm is one line: shouldRearmScrollPin(channel.rootJumpResult). It is the ROOT lane's outcome, deliberately — the guard that makes refusal routing correct is the same guard that keeps this root-only, and a jump outcome is genuinely per-lane. A dock whose root lane renders the whole subtree is pinning the root's transcript anyway; one that pins a DELEGATE lane hands that lane its own onActions instead, since a lane may take a different callback from the rest and the hook simply never sees it.
LongThreadNotice: the no-action wording
The long-thread notice is informational and never blocking: it does not disable the composer or rotate anything, because rotation means minting a thread id and only the host owns those. The affordance is therefore the optional onStartNew, and without a handler the notice is a sentence rather than a button.
Until FR-072 it showed the SAME sentence either way — "…Starting a new chat keeps it fast" — which is advice a reader with no button cannot take. It was reported as the notice telling operators to do something the UI would not let them do. There are now two defaults: message (used when onStartNew IS supplied) and messageNoAction ("This conversation is getting long ({n} events). Older messages load in pages."), and the notice picks between them by whether the action exists.
A caller's own strings.message still ALWAYS wins, in both cases. A host that overrode the sentence chose its wording deliberately, and silently swapping in the no-action default because it happens not to pass onStartNew would override that decision — so the override is read from the supplied strings, not from the merged defaults. Hosts wanting a third wording override messageNoAction the same way.
Examples
Styled drop-in, with durable Realtime
Wrap in <SwarmProvider>, import the precompiled styles, inject the Supabase Realtime seam so background/HITL resumes stream live.
import { SwarmProvider, SwarmChat } from "@nightowlsdev/react";
import { createSupabaseRealtime } from "@nightowlsdev/react/supabase";
import "@nightowlsdev/react/styles.css";
import { supabase } from "./supabase-client";
export function Room() {
return (
<SwarmProvider theme="ink" mode="dark" realtime={createSupabaseRealtime(supabase)}>
<SwarmChat agentSlug="editor" threadId="thread-1" />
</SwarmProvider>
);
}Headless — build your own UI on the hook
The /headless entry gives the hooks + reducer + parser with no styled components. useSwarmChat still needs a <SwarmProvider>.
import { SwarmProvider, useSwarmChat } from "@nightowlsdev/react/headless";
function Chat() {
const chat = useSwarmChat({ agentSlug: "editor", threadId: "t1" });
return (
<div>
{chat.messages.map((m) => (
<p key={m.id}><b>{m.agentSlug}:</b> {m.text}</p>
))}
{chat.followup ? <em>Waiting for your answer…</em> : null}
<button onClick={() => chat.send("hi")} disabled={chat.hydrating}>Send</button>
</div>
);
}
export function App() {
return <SwarmProvider><Chat /></SwarmProvider>;
}The search dock, with a cross-conversation opener
endpoint is the mounted searchRoute() path. A hit in THIS thread jumps in place; anything else is only navigable through onOpenSearchResult, because switching conversations means changing a thread id the host owns.
import { SwarmProvider, SwarmChat, type MessageSearchMatchView } from "@nightowlsdev/react";
import "@nightowlsdev/react/styles.css";
export function Room({ threadId }: { threadId: string }) {
return (
<SwarmProvider>
<SwarmChat
agentSlug="editor"
threadId={threadId}
// Omit this prop entirely and NOTHING renders — no input, no probe, no warning.
messageSearch={{
endpoint: "/api/swarm/search",
onOpenSearchResult: (m: MessageSearchMatchView) => router.push(`/chat/${m.threadId}`),
}}
// With no onStartNew the long-thread notice now says "Older messages load in pages."
// instead of advertising a "New chat" button that does not exist.
longThreadNotice={{ threshold: 2000 }}
/>
</SwarmProvider>
);
}A browser-run tool inside the adopted chat
The FR-002/FR-035 client-tool seam: register a handler with one prop; confirmClientAction gates the needsApproval ones. Register a tool in exactly one place.
import { SwarmProvider, SwarmChat } from "@nightowlsdev/react";
import "@nightowlsdev/react/styles.css";
export function SceneChat() {
return (
<SwarmProvider mode="dark">
<SwarmChat
agentSlug="director"
threadId="scene-1"
clientTools={{ apply_to_scene: async (input) => applyToScene(input) }}
confirmClientAction={(action) => window.confirm("Run " + action.tool + "?")}
/>
</SwarmProvider>
);
}Doing the parts it doesn't support
- A richer search UI than the dockBuild it on the same seams: POST the runner's search route yourself, decide navigability with parseAnchorSeq / isNavigableAnchor (never Number(anchorSeq) — that is the silent wrong-jump the cap prevents), and navigate with useSwarmChat().jumpToThreadAnchor. findAnchorMarker / scrollToAnchorMarker / ANCHOR_MARKER_ATTR are exported for a custom scroll target too.
- Opening a result from another conversation or lanereact cannot: reaching either means changing a thread id the host owns. Pass messageSearch.onOpenSearchResult(match) and route to that conversation yourself; without it such a row renders as plain text rather than a button that would silently do nothing or scroll somewhere unrelated.
- Stream a durable background run to other clientsreact is a client; the durable run lives server-side. Inject a Realtime seam — createSupabaseRealtime (and the scratchpad/presence/todos variants) from @nightowlsdev/react/supabase — into <SwarmProvider realtime>, and stand the run up with a durable runner (runner-background / runner-nextjs) over storage-supabase.
- Render outside ReactConsume the wire yourself: the pure reducer (reduceEvents) and the SSE parser (parseUiMessageStream / chunkToEvent) are exported from /headless, and the whole framework-agnostic @nightowlsdev/theme token API is re-exported. Drive any renderer from those.
- Replace one built-in pieceUse the component-override registry: pass components={{ MessageBubble: MyBubble }} to <SwarmProvider> (or ComponentsProvider). Every slot's props contract is exported, so an override is type-safe whether written from scratch or wrapping the built-in.
- An admin/operator consoleThe ./studio subpath ships <AIStudio> (a section registry: agents, skills, knowledge, tools, the graph workbench), <SkillStudio> (the skill library + store), and the experimental <AgentStudio>. graph-react is an optional peer, resolved at runtime, so a host without it still builds.
Related
- core — The engine whose typed SwarmEvent stream this renders.
- runner-nextjs — Stands up the App Router route family (chat/resume/events) this client talks to — including searchRoute(), the endpoint the search dock probes and queries.
- theme — The framework-agnostic --owl-* design tokens react re-exports and themes against.
- ui — The chrome AROUND the chat — layout, nav, primitives — sharing the same tokens.
- graph-react — The graph workbench <AIStudio> mounts as its knowledge section (optional peer).
- client-tools — The guide for the browser-executed tool seam <SwarmChat> exposes.