Files
zooqueen 252e1cb076 Generated clients call the API where it is mounted
`insights/mount.py` is explicit: `/v1/` is the mount and `/api/` is the older
spelling, kept on the wire for released CLI builds and personal-API-key
holders and rewritten once at the edge by `ApiRewriteMiddleware`. Our own
frontend was still on the older spelling: the orval clients were generated
two days before the mount moved and the move did not regenerate them, so 70
files and 2323 paths said `/api/`.

That is not only a spelling. `prepareUrl` in `lib/api.ts` rewrites `/v1/*` to
the selected region's host in OAuth mode and nothing else, because that is
what the cloud CORS allowlist covers — so every generated call quietly stayed
same-origin, and `getExportsContentRetrieveUrl` builds an `<img src>` that
never reaches `lib/api.ts` at all.

The clients are regenerated from a spec that needs postgres, the datastore and
Django, so this is the path strings only, which is the whole of what the mount
move changes in them: orval copies paths verbatim from the spec, and the spec
takes them from the URLconf. Doc comments carried the same stale prefix and
move with them; their Python docstrings already say `/v1/`. The two frozen
OAuth client-metadata paths are identifiers rather than API surface and appear
in none of these files.

The Slack task prompt told the agent to POST to
`$INSIGHTS_API_URL/api/projects/...`, which is a live request path, not prose.
2026-08-24 23:13:08 -07:00
..
…
…
…
2026-08-09 09:16:43 -07:00
…
…
…

Insights AI agent-run surface — consumer guide

This directory is the conversation-agnostic agent-run UI: a library that renders and drives a streamed agent run — the thread, tool cards, approvals, the composer. If you're embedding a run, showing a status badge, or registering a product's tool cards, you consume it from here.

Building on the surface (adding a thread-item type, a permission rule, stream telemetry)? That's the contributor guide — AGENTS.md — not this file.

1. How to import — the one rule

Import from a domain-scoped api/<module> entry. Never reach into deep internal paths (../components/..., ../logics/...); the api/ modules are the contract and internal files move behind them. The products/* path alias is configured in tsconfig, so the import is absolute:

import { ReadonlyRunSurface } from 'products/insights_ai/frontend/api/readableRun'
import { isTerminalRunStatus } from 'products/insights_ai/frontend/api/logics'

Prefer the narrowest module that does the job. The split exists to preserve code-splitting: the side-effectful tool registry (api/tools) and the markdown/virtualization-heavy thread (api/primitives) must not leak into a bundle that only needs a status helper from api/logics. A status badge that imports a fat path drags presenters and the registry into its chunk; importing api/logics alone does not.

There is deliberately no root index.ts barrel — a barrel that re-exports every tier would re-introduce the exact bundling problem the split solves. Always import an api/<module>.

2. Which surface do I use?

Pick the lowest tier that does the job.

Tier Module What's in it Use when
1 — Prepackaged surfaces api/readableRun + api/runSurface + api/runner ReadonlyRunSurface (lazy, code-split read-only embed); the RunSurface compound (Root + slots, eager) for custom layouts; EmbeddedRunner (lazy TaskTracker product for inline hosts) "Just show a run" → ReadonlyRunSurface (inbox embeds). "Drive a run / custom layout" → RunSurface (tasks). "Embed the whole /tasks product" → EmbeddedRunner (Max).
2 — Compound primitives api/primitives Thread + atoms (.Message/.Markdown/.Reasoning/.Failure/.Activity/.ToolCall), ThreadView, Composer.*, AttachedContextBar, QueuedMessageList, RunLogSkeleton, activity primitives + RunActivity, message presenters, permission/question/resource surfaces Custom layout, or a bespoke/compact thread.
3 — Headless logic + types api/logics + api/types runStreamLogic, runInteractionLogic, status helpers (isTerminalRunStatus, INITIAL_PERMISSION_MODE), thinking-message helpers, context injection (attachedContextLogic, useAttachedContext, contextPickerLogic), tool-stream subscriptions (toolStreamEventsLogic, useToolStreamListener); folded-thread + tool types, AttachedContextItem, ToolStreamEvent Status badge, automation, context injection, tool-event listeners — no presenters, no registry.
4 — Extension seam api/tools toolRegistry, registerToolRenderers, lookupToolRenderer, GenericMcpToolRenderer, DataToolRow, ToolActivity, FilePath, diff helpers Your product renders tool cards (insights, dashboards…). Register them from your own scene.

The Tier 1 surfaces are built on api/primitives (Tier 2), which consumes the headless api/logics/api/types (Tier 3). Going down a tier trades convenience for control and a smaller chunk.

ReadonlyRunSurface is lazy by default — code-split behind a RunLogSkeleton Suspense fallback — because the embeds that use it (the inbox detail views) show it as a secondary panel. The RunSurface compound (api/runSurface) is eager: import it only where the surface is the primary content of an already-code-split route (the /tasks runner scene composes its own live-composer layout), so it doesn't pay a second chunk fetch + Suspense flash for the one thing the route exists to show.

3. Recipes

Each recipe shows the granular import to copy.

Read-only embed (inbox-style)

import { ReadonlyRunSurface } from 'products/insights_ai/frontend/api/readableRun'
;<ReadonlyRunSurface taskId={task.id} runId={run.id} interaction="read-only" />

The prepackaged, lazy read-only surface: thread (plus the meta bars for a live run), no composer and no approval prompt. It streams fresh frames while running when interaction='live', and replays the snapshot once when 'read-only'. This is what all three inbox embeds drop in.

Live embed with composer (tasks-style; caller owns the composer + draft/queue)

Compose the RunSurface compound (api/runSurface, eager) and pass your composer UI as the RunSurface.Composer children — the slot owns prompt-vs-composer precedence and the null-bootstrap gate; you own the composer. See scenes/TaskTracker/components/TaskRunChat.tsx for the full wiring.

import { RunSurface } from 'products/insights_ai/frontend/api/runSurface'
import { Composer, QueuedMessageList } from 'products/insights_ai/frontend/api/primitives'
import { runInteractionLogic } from 'products/insights_ai/frontend/api/logics'

// Bind runInteractionLogic (the follow-up/queue facade) keyed by the same runId RunSurface.Root binds.
const { draft, isSubmitting, isBusy, queuedMessages } = useValues(runInteractionLogic(props))
const { cancelRun } = useActions(runInteractionLogic(props))
;<RunSurface.Root taskId={task.id} runId={run.id} interaction="live">
  <div className="@container/thread flex flex-col h-full overflow-hidden">
    <div className="flex-1 min-h-0">
      <RunSurface.Thread />
    </div>
    <RunSurface.Resources />
    <RunSurface.Composer>
      <Composer.Root
        value={draft}
        onChange={setDraft}
        onSubmit={submit}
        loading={isSubmitting}
        isTurnActive={isBusy}
        onStop={() => cancelRun()}
      >
        {/* …Composer.Frame / Field / Textarea / Submit… */}
      </Composer.Root>
    </RunSurface.Composer>
    <RunSurface.ContextUsage />
  </div>
</RunSurface.Root>

Pass isTurnActive + onStop to make the send button a Stop button while the agent is working a turn and the input is empty (clicking cancels the run); with drafted text it stays Send and queues the follow-up. Omit both for a send-only composer.

Custom layout via the RunSurface compound

RunSurface.Root (Tier 1, api/runSurface) binds the stream logic and bootstraps the run; the slots (.Thread/.Composer/.Resources/.ContextUsage) compose into any layout — there is no default. Omit RunSurface.Composer for a no-input surface (that's exactly what ReadonlyRunSurface does); render it with composer children for an interactive one. For something even more bespoke, drop to the Tier 2 primitives (ThreadView, ResourcesBar, Composer.*, ContextUsageBar) and bind runStreamLogic yourself.

Optimistically open a run before it exists

To show the thread the instant a user hits send — their message + a "spinning up" indicator — before the create/run round-trips finish, mount RunSurface.Root in its pending state (a null runId keyed by a client streamKey), seed it via runStreamLogic.startOptimisticRun(message), then supply the real runId once created; the surface attaches it (preserving the seed) and the live SSE echo dedups the message.

import { runStreamLogic } from 'products/insights_ai/frontend/api/logics'
import { RunSurface } from 'products/insights_ai/frontend/api/runSurface'

const streamKey = `draft-${uuid()}`
const stream = runStreamLogic({ streamKey })
stream.mount() // hold it across the render swap; release when done
stream.actions.startOptimisticRun(message) // empty → "spinning up" + the typed message

// render the pending surface (no run yet):
;<RunSurface.Root taskId="" runId={null} streamKey={streamKey} interaction="live">
  <RunSurface.Thread />
</RunSurface.Root>

// …after api create/run resolve, set runId on the same surface to attach + stream it.

The attach is idempotent and seed-preserving via runStreamLogic's bootstrappedRunId / awaitingOptimisticAttach state — so the run can be adopted by a different surface that mounts later, not only by an in-place runId flip. A consumer that navigates (e.g. /tasks/new → /tasks/:id) keeps the seeded instance alive (hold the manual .mount()), then has the destination surface bind the same streamKey plus the real runId: RunSurface.Root sees the instance is already bootstrapped for that run and adopts it without a reset() — no skeleton re-flash, the thread is continuous across the unmount/mount. If that destination also drives runInteractionLogic, pass it the same streamKey (a streamKey ?? runId connect-key) so the composer reads the exact stream the thread renders; runInteractionLogic still keys its own per-run state by runId.

The tasks runner composes exactly this (scenes/TaskTracker/taskTrackerSceneLogic.ts + TaskCreateThread for the pending phase, then TaskDetailPage → TaskRunLog → TaskRunChat adopting the seeded stream after navigation).

Bespoke / compact thread via Thread.* atoms

import { Thread } from 'products/insights_ai/frontend/api/primitives'

// Compose only the atoms you want — e.g. a compact inline preview that drops the activity/tool chrome.
;<Thread.Root items={items}>{(item) => <Thread.Message item={item} />}</Thread.Root>

Register product tool renderers (Tier 4)

import { registerToolRenderers, type ToolRegistryEntry } from 'products/insights_ai/frontend/api/tools'

// Call once from your scene's entrypoint. Tools without an adapter fall through to the generic MCP card.
registerToolRenderers([
  { key: 'my-product-tool', displayName: 'My tool', icon: <IconWrench />, Renderer: MyToolRenderer },
])

This is the generic per-product mechanism. The surface itself is its first consumer: the Insights product data-tool widgets live in components/tool/widgets/ and self-register via widgets/registerDataToolRenderers (side-effect-imported by ToolCallCard), so every surface that renders a tool card gets them.

Headless status badge / automation

import { runStreamLogic, isTerminalRunStatus } from 'products/insights_ai/frontend/api/logics'

const { currentRunStatus } = useValues(runStreamLogic({ streamKey }))
const done = isTerminalRunStatus(currentRunStatus)

No React presenters, no tool registry — this import lane stays out of those chunks.

Inject context the agent should see

Register the resource(s) the user is looking at; while registered, every message sent from the surface is prefixed with invisible context blocks describing them (the user only ever sees their own text): type: 'instructions' items render into <insights_trusted_context> (our own injected guidance — never put user/ingested data there), everything else into <insights_untrusted_context> behind hardening prose. Items are abstract — type is any string ('insight', 'trace', 'text'…), plus key/label/value — and are deduped across providers and across sends within a task (the whole resume chain of runs, matching the backend's per-task semantics; except text, which always resends).

import { useAttachedContext } from 'products/insights_ai/frontend/api/logics'

// In your scene/component — registered while mounted, removed on unmount:
useAttachedContext([{ type: 'insight', key: insight.short_id, label: insight.name }])

JSX-only call sites can render <AttachedContextProvider items={...} /> (api/primitives) instead.

From a kea logic, connect to attachedContextLogic and register through a disposable (the repo-preferred teardown idiom — see /using-kea-disposables); the returned cleanup deregisters on unmount, no beforeUnmount needed. Pass pauseOnPageHidden: false: the registration costs nothing while idle, and the default hide-pause would silently drop context from a queued follow-up that flushes while the tab is hidden.

afterMount(({ actions, cache, values }) => {
  cache.disposables.add(
    () => {
      actions.registerContext('my-scene', [{ type: 'dashboard', key: values.dashboard.id }])
      return () => actions.deregisterContext('my-scene')
    },
    'attachedContext',
    { pauseOnPageHidden: false }
  )
})

Re-dispatching registerContext with the same provider id (e.g. from a subscriptions handler when the resource changes) is an upsert — see logics/contextPickerLogic.ts for the full shape.

Let the user pick context (the composer @-affordance)

AttachedContextBar (api/primitives) is the prepackaged picker + chips row — drop it into Composer.Header, the wrapping row at the top of the frame above the textarea (that's exactly what TaskComposer and TaskRunChat do, so /tasks and the side panel already have it; the footer stays free for the model/effort pickers):

import { AttachedContextBar, Composer } from 'products/insights_ai/frontend/api/primitives'
;<Composer.Header>
  <AttachedContextBar />
</Composer.Header>

The @-button opens a TaxonomicPopover over events/actions/insights/dashboards/notebooks/error-tracking issues; a selection becomes a flat { type, key, label } ref in contextPickerLogic (api/logics) — no entity loading, the agent fetches details. The chips render everything in contextItems, whatever provider contributed it. Closing a chip the picker owns removes the pick; closing any other provider's chip dismisses that key (attachedContextLogic.dismissContext), which sticks even while the provider keeps re-registering the item — re-picking it through the popover restores it.

React to the agent invoking a tool

Subscribe to tool-call lifecycle events (resolved tool names) — e.g. refresh a list when the agent creates a dashboard. Replay events are suppressed by default so a page reload never re-triggers your handler; opt in with includeReplay if you need them.

import { useToolStreamListener } from 'products/insights_ai/frontend/api/logics'

useToolStreamListener({
  tools: ['create_dashboard'],
  onEvent: (event) => {
    if (event.phase === 'completed') {
      loadDashboards()
    }
  },
})

From a kea logic, either connect to toolStreamEventsLogic and listen to its emitToolEvent action (you filter everything yourself, including event.source), or register a subscription through a disposable — same shape as the context recipe above, calling registerToolListener(listenerId, { tools, onEvent }) in setup and deregisterToolListener(listenerId) in cleanup, with pauseOnPageHidden: false (tool events fire while the tab is hidden and missed live events are not redelivered).

Note: for exec-wrapped Insights tools the resolved name can be unknown at phase: 'started' (the command streams in later) — match on completed when you need certainty.

Editor mutations use useMcpToolApplyBack with a stable targetKey for the resource being edited. The send paths snapshot the active targets for that stream before making a request. A response is applied only when exactly one snapshotted target matches the tool, and changing editors creates a new activation that cannot inherit the old response. The default turn_end mode applies only the final matching completion.

4. Coupling boundary

This surface couples to the tasks run API by design (products/tasks/frontend/generated/api) and never to Max or the conversations API — Max is a consumer, not a dependency. The full rule (and the grep gate that enforces it) is in AGENTS.md.

5. Replacement-ready by design

This surface is the intended replacement for Max's legacy LangGraph thread. The new-vs-legacy switch is a consumer concern (Max routes agent_runtime === 'sandbox' conversations through runStreamLogic and renders this surface; its LangGraph path renders its own thread). The surface never branches on Max/LangGraph/conversation. Anything the LangGraph path owns that the surface doesn't express generically (agent modes, billing context, scene attachments) stays in Max and feeds the surface through generic props / the tool registry / streamKey — lifted to a generic seam here, never special-cased. The four-tier contract above is unchanged whichever side of the switch is active. See AGENTS.md for the contributor-side detail.