Skip to content

ai-manager file layout

Status: Current | Last reviewed: 2026-10-12

Where to put what when extending the admin UI. Read this before adding a feature.

Pipeline and workflow collaboration bridges capture each Y.Doc's hydration baseline in a layout effect before subscribing to updates. This keeps refs out of render and preserves local edits made before the first remote sync.

Agent and workflow editor errors and warnings use the shared notification catalog: concise labels and guidance first, useful diagnostics behind View details. Add new issue copy there rather than rendering raw validation or provider output in a notification.

The sidebar (components/app-sidebar.tsx) has two groups:

  • Navigation (top): Home, Insights and the unified Agents repository. Feature and permission gates still apply; workflows appear as Background agents.
  • Manage (bottom): Tools & Templates and Settings. Settings opens /settings/integrations and requires AIManager:ManageConfiguration.
  • The user menu's Settings entry remains the separate Profile Settings page at /settings, available to every authenticated user.
  • The Settings Access group and agent Share button are hidden while private-agent sharing is deferred. Existing permissions and backend access controls remain.

The teal sidebar and shared Open Sans typography live in app/studio.css. Browse-page titles use one responsive 24–32px/700 scale; section headings use 16px/600; editor titles use 18px/600; inspector and field headings use 14px/600. Body and metadata use 14px and 12px respectively. Compact sidebar captions use 10px. Keep equivalent surfaces on these tokens rather than adding page-specific font weights. Collapsed navigation buttons are square and centred within the rail.

Both editors start with their preview/tester collapsed. The header holds identity, versioning and management actions; canvas controls stay on the canvas. Inspectors have one title: a hosted Workflow NodeEditor uses hideChrome to avoid repeating its host heading. The workflow graph inherits the available panel height instead of imposing a 500px minimum inside the resizable editor.

AgentPageHeader and WorkflowPageHeader share CanvasEditorToolbar: identity, version selector, centered canvas title and action pills use the same layout. VersionSelector defaults to pipeline endpoints and uses scope="workflow" for workflow versions. Navigation uses explicit version URLs; unversioned workflow editor links prefer drafts without changing the published-first API contract. The header shows View only and Open draft / Create draft as applicable. Workflow validation and action/save errors use EditorNotificationsProvider and the header bell, not canvas banners. Publishing retains its validation gate.

WorkflowSettingsDialog replaces the workflow side sheet and shares Agent Settings' studio-settings-dialog, heading, content-scroll and footer styles. It keeps the canvas and collaboration shell mounted, uses handleConfigChange for draft autosave/undo, and leaves identity metadata on its separate save paths. Published configuration remains read only; template changes still use the canvas header's explicit Save. Done, Escape and close return focus to the opening button. Provider-rate-limit rows stack on narrow screens so the modal never needs a horizontal page scroll.

Workflow Activity uses AuditLog(entityType="workflow"), WorkflowRunHistory and WorkflowRunDetail. Execution History opens directly onto its table; selecting a run replaces the table inside the same dialog. Back restores the row focus and list scroll position. Legacy /workflows/:id/runs and /runs/:runId URLs redirect to the canvas with activity=executions and optional run query parameters. Run lists and details poll every three seconds while visible, including terminal runs, with no Live preference, refresh button or relative update timestamp. Hidden tabs and closed dialogs stop polling; returning resumes it. The run detail omits observed provider cost. History remains scoped to the workflow and tenant.

Agent and workflow Change History query their base identity across drafts and published versions. Activity has no version picker or Versions subheading; the canvas header owns version navigation. Audit rows show the event's version number, Draft for draft actions, or a dash when no version was recorded. Cloning from v3 still produces a Draft event. Workflow publication awaits an audit write with details.versionNumber using the same transaction client, so failed publication cannot leave a published audit event. The optional writeAuditLog client preserves existing callers while allowing this atomic write. Conversation History includes test/draft conversations by default in the per-agent modal and labels each row's version. The Voice Agent header no longer links to Director. Audit ownership resolves the numeric ID against workflow_configs, including snapshot/summary access; the unrelated legacy workflows table is not an ownership source. Header presence uses the existing workflow collab room; Preview and Settings remain separate actions. More holds version-specific export and permission-gated deletion with confirmation. Both editor kinds mount AssistantChatPopup independently of Preview; collapsing it hides rather than unmounts the conversation.

Turn control and Idle detection use CapabilityCard inside the capabilities section. Turn control is always available and has no global off switch. Idle is discovered through Add capability and writes the existing turn_control.idle fields. turnControlConfigPatch preserves other turn settings and handles legacy config; card fold state remains browser-local. No tool ref or backend schema was added for these presentation changes.

  • Tools & Templates (app/(main)/tools-templates/page.tsx) consolidates several former Settings sub-pages into one tabbed page (?tab=), in order: Tools, Multimodal, External Consult, Templates, Prompt Templates. The old /settings/{tools,prompts,templates,catalogs,warm-transfer} routes are now thin redirect() pages (Tools reuses components/ToolsTable.tsx). The legacy ?tab=agent-templates deep link aliases to ?tab=templates, and ?tab=handoff (the former "Handoff" tab) aliases to ?tab=external-consult.
  • Multimodal (components/tools-templates/MultimodalPanel.tsx, gated on ManageTools + the a2ui feature flag) is the former Settings → Integrations → Multimodal (/settings/catalogs) listing — A2UI catalogs, cross-catalog components (with the preview sheet), and external endpoints. Its inner tab is ?mtab= (catalogs|components|endpoints) so it doesn't collide with the page-level ?tab=. The per-catalog detail page lives at /tools-templates/catalogs/[id], with old /settings/catalogs/[id] links redirecting there. /settings/catalogs and /settings/multimodal-configs now redirect here (preserving the old ?tab=endpoints|components deep link → &mtab=).
  • External Consult (components/tools-templates/ExternalConsultPanel.tsx, gated on ManageTools) is the former Settings → Integrations → Warm Transfer (/settings/warm-transfer), renamed (product name "External Consult"; storage keys unchanged — warm_transfer_profiles). Predefined consult profiles (edited with the shared ExternalConsultForm) + the tenant policy modal, same /api/warm-transfer-profiles and /api/tenants/{id}/transfer-policy back ends. /settings/{warm-transfer,warm-transfer-profiles,transfer-policy} redirect here.
  • The Templates tab (components/tools-templates/TemplatesPanel.tsx) is a segmented control over the three "agent-shaped" template kinds:
    • Agent — full agent blueprints (agents rows with is_template=TRUE) the New Agent Wizard clones. Edited in the standard agent editor. AgentTemplatesPanel.tsx adds New (via POST /api/templates) + Delete, gated on ManageTools like the rest of the tab. Templates are tenant-scoped: a tenant creates/edits/deletes its own; the globally-seeded built-ins (owned by the platform tenant) are visible to all (RLS) but render read-only here (canEdit=false from the list API) — editing those needs platform-admin.
    • Stage — reusable pipeline nodes (pipeline_templates kind=stage). Edited in a modal that hosts the same ServiceConfigForm the inspector uses (StageTemplateEditorDialog.tsx). May define subagents.
    • Subagent — reusable specialist subagents (pipeline_templates kind=subagent). Edited in a modal hosting SubagentConfigForm with disableNested (SubagentTemplateEditorDialog.tsx). Never carries nested subagents — enforced in the form and server-side in /api/pipeline-templates.
    • Stage/Subagent CRUD is /api/pipeline-templates (PipelineTemplatesPanel.tsx is the shared table). Authors seed new nodes from these via the canvas "+" picker (see below).
  • Configuration uses /settings/* subpages and starts at /settings/groups. The /settings root is Profile Settings and stays accessible to all authenticated users for read-only identity, role/permission display, and Personal Access Token creation. /settings/api-tokens is now the ManageConfiguration tenant-token audit/revocation page, not the normal self-service token creation surface.
  • Tenant Settings (renamed from the former single Telemetry group) holds the tenant-wide service connections: Knowledge (/settings/knowledge) and Telemetry (/settings/telemetry). Both sub-tabs are marked adminOnly in settings/layout.tsx, so canAccessSubTab requires the tenant admin role (or platform admin) on top of ManageConfiguration — operators hold that permission but must not see this group. Knowledge settings gate whether the Knowledge tool type exists for the tenant at all; see ai-manager.md.

Per-type accent colors (agent subtypes + service categories) live in lib/agent-accents.ts — the single source of truth. Do not scatter hex literals.

Top-level directories

apps/ai-manager/
├── app/                Next.js App Router pages + API routes
├── components/         React UI components
├── lib/                core logic: types, DB, crypto, schema, integrations
├── hooks/              custom React hooks
├── contexts/           React contexts (auth, tenant, theme)
├── proto/              gRPC client stubs
├── public/             static assets
├── scripts/            one-shot utilities (init-db.ts, etc.)
├── messages/           i18n locale files
├── styles/             global CSS
└── tests/              Jest unit + integration tests

app/

Next.js 16 App Router.

  • app/(auth)/ — login, callback, logout. Edge-runtime where possible.
  • app/(main)/ — authenticated routes. Wraps every page in the auth provider and tenant context.
  • app/api/ — REST endpoints. One folder per resource (api/agents/, api/workflows/, api/integrations/, api/trigger-rules/).

Conventions:

  • Each route.ts exports GET / POST / PUT / DELETE handlers.
  • Permission checks wrap every handler: withAuth(...), withAnyAuth(...), withSessionAuth(...), or withManagementAuth(...). npm run audit:routes fails the build on a handler that uses none of them without an allowlist entry — see route auth.
  • Database access goes through lib/db-*.ts modules; do not write SQL directly in route handlers.

components/

React components, organized by feature when there are many, alphabetically when few.

Critical components:

Component Purpose
PipelinesTable.tsx Agents list view, filtering, sorting, column visibility, cloning, and actions. The ID column is visible by default and provides a per-row copy button; the list-column storage key must be versioned when defaults change.
FlowEditor.tsx Pipeline canvas (ReactFlow)
ServiceConfigForm.tsx Inspector form for a stage. section prop (prompt/properties/all) drives the Properties / Behavior tabs. Prompt/tools/subagents/capabilities render under Behavior, including agent-local inline Python tools stored in the stage config. Its hooks and provider-specific blocks live in service-config/
flow/CanvasViewBar.tsx Canvas toolbar (top-right overlay) — Undo / Redo, the Canvas View popover (Structural / Sub-Agents toggles), and the Variables tool folded in as a bordered section (variablesSlot ← AgentVariablesPanel embedded). One stacked tool set
flow/AddNodePanel.tsx "Add to flow" picker (Link-Sub-Agent style). Type-preselected stage options ("Add Speech-to-Speech", "Add Translation", …, filtered by pipeline type via stage-add-options.ts) + a branch option per branchable frame type + Add Sub-Agent. Renders in the inspector slot, replacing the node editor. See adding a node
editor/NodeInspectorPanel.tsx Left inspector; Properties / Behavior tabs + expand button. Properties is first/default. Shows Behavior for conversational stages via stageHasBehaviorTab(). Start / End block selections render lifecycle settings; speech Audio Input also owns settings.swimlane voice branching behavior
editor/NodeExpandedView.tsx Full-screen node editor overlay. A two-column collapsed header — editor controls (‹ Canvas \| Node ▾ \| Properties / Behavior tabs) left, hoisted PreviewHeader right (width-matched to the tester column) — so the inspector + embedded (header-less) Preview render chrome-less below it. See full-screen node editor
editor/SubagentLinkPicker.tsx "Link Sub-Agent" picker (start from scratch + saved subagent templates)
editor/SubagentConfigForm.tsx Recursive sub-agent sidebar editor. Session-switch entries at every supported depth expose the default-all transfer graph as Can transfer to and persist explicit opt-outs in disabled_downstream_agents.
tools/external-consult/ExternalConsultCapability.tsx External Consult config — the builtin:warm_transfer node capability (rendered in the Capabilities section per voice/GECX node, not the Tools picker or Operations sheet). Uses the shared ExternalConsultForm. See warm-handoff.md
tools/message-injection/MessageInjectionCapability.tsx Receiving-side builtin:message_injection policy. Rendered as a Capabilities toggle with priority/response defaults; it is deliberately hidden from ToolSelector rows.
tools/human-supervision/HumanSupervisionCapability.tsx builtin:human_supervision capability toggle: pick a tool or workflow to request supervisor support (workflow mode renders the chosen workflow as a tool-style SelectedWorkflowRow — a Definition link to the workflow, a pop-out modal, and a chevron that expands a WorkflowInputMappingEditor binding the workflow's declared inputs to literals or $var: runtime bindings), a supervisor chat agent, whether to stream transcripts, and transcript-handling guidance. The picked tool/workflow is exposed to the LLM as a callable function at runtime (workflow mode follows the workflow's execution_mode — sync blocks and returns outputs, async/batch fires-and-forget with an execution_id); the supervisor-chat / transcript-streaming half is now wired too — a supervisor chat agent + stream_transcripts (default on) exposes agent-host's stream_transcript_to_supervisor relay, which on escalation opens a chat conversation running that agent and pumps the live call's finalized turns into it. A singleton, hidden from ToolSelector rows, registers no callable tool of its own.
WorkflowGraphTab.tsx Workflow editor canvas
flow-kernel/FlowCanvas.tsx Shared ReactFlow wrapper (dot-grid background, controls, minimap) — used by the workflow editor
AutosaveIndicator.tsx Draft autosave state + error chip
collab/PresenceStack.tsx Multi-user presence avatars

Workflow canvas layout is scheduled by flow/useAutoLayout.ts. The pure layout assigns flow-ordered rows and uses Dagre for column ordering, including virtual cells for connections that skip rows. Square connectors follow the reserved columns. See the layout and persistence contract.

The End inspector's output-source browser scopes node groups by forward reachability from START, not backward ancestry from an explicit END edge. LangGraph accepts a reachable leaf with no outgoing edge as a terminal node, so an END-ancestry walk can reduce a valid workflow's browser to only System and Inputs even though the leaf result is present in final state. Nested legacy For Each body nodes remain excluded; their outputs are exposed through the enclosing loop result. The Radix ScrollArea around these groups needs an explicit height rather than only max-height: its viewport is height: 100%, so a max-height-only root grows with its children and never develops scroll overflow. Keep the height capped by Radix's popover-available-height variable and leave the vertical scrollbar visible so large Inputs/Node lists remain discoverable and wheel-scrollable.

For Each body ownership is canonicalized from its body_ref path before layout. This repairs older drafts where a body node has valid loop edges but is missing parent, clears the node's stale top-level coordinates, and rebuilds the container's config.body order. Hiding phases discards phase-relative positions only for top-level nodes; container-relative body positions are retained. Each sibling scope is laid out separately, so a For Each container nested inside a phase keeps parent-relative coordinates throughout layout and persistence. Explicitly dragged positions remain authoritative. Legacy phase-child coordinates that fall outside the parent phase are treated as stale absolute coordinates and recomputed from the graph. For Each nodes render a compact collection/step-count card on the parent canvas. The maximize button (Open For Each body) enters the nested canvas; published workflow versions expose the same focused view in read-only mode. That focused canvas passes a WorkflowSentinelContext into the ordinary start/end node renderers, so the boundaries are labelled Each item and Next item instead of pretending the body is an independent workflow. Each item names the source collection and the fixed runtime scope (iterator.current_item, iterator.index, iterator.total, and iterator.parent_node); Next item explains per-item result collection and the node_results.<foreach-id> aggregate. Selecting either boundary opens ForeachBodySentinelInspector in the page's Inspector. It writes collection/scheduling fields or completion/error fields back to the enclosing For Each node while nested step edits update only config.body_graph. The nested editor maintains one current container snapshot for both paths so a body edit cannot overwrite a just-saved boundary edit. Published versions allow the same selection for inspection but disable every field.

For Each and standalone subworkflow cards have maximize only, with no inline expansion or embedded preview. The palette offers For Each for new nested bodies; existing standalone subworkflow nodes keep their call-once semantics. New loops seed body_graph: {nodes: [], edges: []} without a legacy self-loop body_ref. focusedLoopBody projects older sequential config.body loops using their runtime order. Opening is read-only; replaceFocusedContainer migrates the edited loop atomically on its first body edit, removing its legacy children and body edges while preserving the enclosing workflow. Layout-only body_ref edges never become branching execution edges during this conversion. Focused loop chrome explains Each item → body steps → Next item, then continuation in the parent.

WorkflowInspectorHostContext provides the live page-owned Inspector slot and selection/add-node callbacks to focused canvases. The covered parent suppresses its Inspector and keyboard shortcuts, and restores its selected node when the focused view closes. A focused canvas starts with the Inspector collapsed; selecting a step or For Each boundary opens the same resizable column, with the same collapse rail and full-screen editor. It never adds a second fixed sidebar inside the canvas. Nested writes patch the latest parent config so unrelated changes made while focused are retained. They also refresh the covered parent Inspector's container snapshot, so later settings edits cannot restore an old body. workflow-subflow-layout.spec.ts checks maximize-only cards, straight paths, sidebar ownership, legacy-body conversion, new loops, edits, insertion and reload. workflow-canvas-header.spec.ts covers draft navigation, responsive chrome, notifications, Activity, Preview and the assistant.

Note: when adding a new pipeline stage type, the inspector form goes here, but the field schema goes in lib/service-definitions/. See universal-pipeline-builder.md.

Adding a node — the "Add to flow" picker

The canvas "+" buttons (end-of-pipeline placeholder + between-node edges) no longer hover-expand an in-canvas action popover. A click opens the "Add to flow" picker — AddNodePanel, styled like the Link Sub-Agent sidebar — in the inspector slot, replacing the node editor. It offers:

  • Type-preselected stage options — "Add Speech-to-Speech", "Add Speech-to-Text", "Add Translation", etc. Each inserts a stage with service.type already set, so the inspector lands on the right category. The list comes from getStageTypeOptions(pipelineType), filtered to the types valid for the agent's pipeline type (getAllowedServiceTypes, the same gating ServiceConfigForm's type dropdown uses — imported from this one module so they can't drift). A search box filters the list.
  • From a template — stage templates (GET /api/pipeline-templates?kind=stage, filtered by the agent's pipeline_type). Picking one seeds the new stage from the template's ServiceStage config. The seed clones the config but re-derives id and drops position (useAddStage / useInsertNodeBetween take an optional seedStage), so the pipelineStructureSignature invariants hold — a template's old id/position is never persisted.
  • A Branch option per branchable frame type of the upstream node. The start node follows the agent modality: text-to-text/chat agents expose Text Branch, not Audio Branch.
  • Add Sub-Agent (when applicable) — opens the Link Sub-Agent picker, which now also lists subagent templates (?kind=subagent); picking one clones the full SubagentDef via seedSubagentAtPath.
  • Connect to — for a branch placeholder, a list of the existing nodes the branch can wire into (instead of adding a stage). Replaces the old NodeConnectionSelector modal for placeholders. Text-to-text/chat agent Preview panels keep template variables below the chat iframe and do not auto-add the voice-only ani/dnis phone variables.

Preview's variable collector includes in-conversation subagent prompts stored in stage.config.topics.topics[].instruction_addendum, alongside main prompts and separate-call/session-takeover subagents. A variable referenced only in a specialist prompt appears in Template Variables as the prompt is edited; it does not need a duplicate reference in Main. AS-135 regression coverage exercises this in the editor and after reloading the saved draft.

Wiring: the add hooks live inside FlowEditor's React Flow context, but the picker renders in the page's inspector panel — outside the canvas. FlowEditor reports an AddRequest (view data + commit closures bound to the target node ids) up through the new onRequestAdd prop; the agent page stores it as addRequest and renders the picker, calling the closures to commit. Edges reach requestAdd through AddRequestContext so it doesn't have to thread through every edge-data site. AddStageMenu's disableHoverMenu turns the "+" into a plain button (no hover). When a host doesn't wire onRequestAdd (e.g. read-only), the picker falls back to a docked in-canvas panel and edges fall back to immediate insert. useAddStage / useInsertNodeBetween take an optional stageType arg to preselect the type. Closing the picker routes through the page's handleCloseInspector, not just setAddRequest(null), so the inspector does not fall through to the empty "Select a node" state after an abandoned add.

The main end-of-pipeline placeholder is an authoring affordance only. FlowEditor still keeps it as a React Flow node so the same add callbacks and selection plumbing apply, but flow/useLayout.ts treats the terminal main placeholder as layout-transparent when it points at the EndNode: the EndNode is positioned one normal edge gap below the last real stage, and the placeholder is centered inside that gap. Branch placeholders and lane placeholders continue to reserve normal rows because they represent editable empty branch/tail state.

Branch placeholders are node-like cards

A branch placeholder no longer shows inline action buttons or in-canvas dialogs. There are two states (PlaceholderNode):

  • Fresh / empty branch (isFreshBranchPlaceholder — a placeholder whose parent is the branch's origin, not a node inside the branch): renders a node-like dashed card, selectable (ring via isSelected, driven by selectedNodeId), with edit + trash icons. Both same-lane parallel branches and output-lane branches (transcript/suggestion lanes) sit to the right of the main trunk — they're told apart by laneId (lanes carry one, same-lane parallel branches don't), not by side. Same-lane branches stack at MAIN_PIPELINE_X + n·BRANCH_SPACING (branch 0 = the trunk); output lanes start further right at a dynamic secondary-lane X. The static SECONDARY_LANE_START_X (1200) only cleared one right-stacked branch column, so adding a transcript/suggestion lane when 2+ same-lane branches already exist made the lane overlap the second branch column. Instead, computeSecondaryLaneStartX(countSameLaneRightBranchColumns(nodes)) (in lane-config.ts) pushes the lane past the rightmost occupied same-lane branch column (max(1200, MAIN_PIPELINE_X + (n+1)·BRANCH_SPACING)), so it never overlaps for any branch count and never moves left of 1200 for the common 0/1-branch case. The computed value is threaded as the secondaryStartX arg into getLaneBranchX/getLaneBaseX/getLaneZoneCenterX by every consumer that has node context: the authoritative useLayout.ts, the add-lane hook (useAddLaneRoute in useAddBranch.ts), load-time flow-lane-conversion.ts, and the LaneBackground.tsx stripe — all must agree or the stripe/nodes/edges disagree. Symmetry rule: the same-lane branch adder (useAddBranch/parallel-at-start) counts columns with the same countSameLaneRightBranchColumns helper, which excludes lane nodes. Lane nodes carry a branchX too, so if the audio-branch counter included them, audio branches would skip columns past a lane (gaps) and, because useLayout recomputes the lane's X to clear the audio branches, the two schemes would chase each other and could converge on the same column — the "audio → transcription → audio" overlap. Audio branches always pack the leftmost right-columns (indexed among themselves); lanes always sit to the right of all of them. Delete/compaction rule: deleting a MIDDLE same-lane branch leaves survivors at their stale, gapped branchX (e.g. 785 & 1555 with 1170 gone). Positions come from the stored branchX, so the gap would persist AND a lane whose clearance is computed from the now-smaller count could land on a stranded survivor. useLayout therefore re-derives each render via compactRightBranchColumns(nodes) — it ranks the surviving columns left→right and remaps each onto the tight grid (rightBranchColumnX(1..k)), so survivors close the gap and lane clearance matches reality. It's display-only (positions aren't persisted; nothing fights it). Correspondingly, the adder uses nextRightBranchX(nodes) — one column past the rightmost existing same-lane column, NOT count·spacing — so adding a branch while survivors are still stale-gapped can't duplicate a survivor's column (which compaction would then collapse into an overlap). Secondary-lane branches key on laneBranchIndex, not branchX, and get the SAME gap-closing treatment. Deleting a middle transcription/suggestion branch leaves a HOLE in the index sequence (e.g. {0, 2}); since each lane branch is positioned directly at getLaneBranchX(lane, index, …), the hole would persist as an empty column and a re-added branch (formerly max+1) would drift ever further right — the "transcription branch doesn't come back" bug. Fixed with the lane-index mirrors of the same-lane helpers: useAddLaneRoute allocates nextLaneBranchIndex(existing) (the smallest free index, so a re-add fills the hole), and useLayout resolves every lane node through a per-lane compactLaneBranchIndices remap (dense rank) before computing X, so survivors close the gap each render. The lane placeholder id is deterministic (placeholder-lane-<lane>-b<index>), so useAddLaneRoute also dedups by id before concat — a re-add right after a delete can't be silently dropped. (Config round-trips are already dense: extractOutputLanes drops empty branches and generateLaneNodes re-indexes by array position on reload; the remap only fixes the LIVE pre-flush state.) Clicking the card or its edit icon opens the Add to flow sidebar (Add a Stage / Branch / Connect to); the trashcan deletes the empty branch.
  • Trailing placeholder (once a stage has been added to the branch — its parent is a pipeline node inside the branch): falls back to the normal "+" button, but isBranch is still set so the sidebar keeps the Connect to section. When the branch is already wired (isConnected), the section relabels to Reconnect to and adds a Disconnect action — so the "+" between the branch's last node and the connected node re-points or drops the connection. connectToNode removes any prior connection-edge from the source first, so reconnect is a clean re-point. The output target label is modality-aware: Audio Output for speech agents, Text Output for text-to-text/chat agents.

When requestAdd fires with isBranch, FlowEditor enriches the request with connectTargets (getConnectableNodes(placeholderId) + getNodeLabel, mirroring the retired modal), onConnect (connectToNode(target, _, placeholderId)), onDisconnect (when connected), and onDelete. After Add a Branch is chosen from any add menu, useAddBranch's onBranchCreated auto-selects the new placeholder and opens its sidebar; output-lane routes created by useAddLaneRoute do the same with a right-side, initially unconnected lane placeholder instead of inserting a Transcript Input filter card or prewiring the lane to Output. Saved lanes that already carry explicit transcript filters still render that filter card so the existing filter settings remain editable. The legacy inline-button card + BranchTypeSelectionDialog survive only as a fallback when no onRequestAdd host is wired.

Stage id is auto-managed; stages carry a description

A stage's id is derived from its label, not hand-entered — there's no Stage ID field in the inspector. On every label edit, ServiceConfigForm's form.watch subscription recomputes the id via uniqueStageId(label, reserved) (slugify the label, then append -2, -3, … if the slug collides with another stage). reserved is reservedStageIds (the page passes every other stage's id, excluding this one). An empty label keeps the existing id so a stage is always addressable. The orchestrator slugifies labels the same way for injection target names, so ids stay consistent with the runtime.

Because the id changes as you type, id is excluded from pipelineStructureSignature — otherwise each rename would force a full canvas rebuild (flash / branch teleport / lost inspector anchor). The external-update effect's leaf-patch path matches stages by index (node-N → pipeline[N]), so an id change is applied in place with no rebuild. (Position is excluded for the same reason; see the signature comment in FlowEditor.)

The node's display label falls back the same way the inspector auto-fills it. PipelineNode renders stage_name || getProviderServiceLabel(provider, service) || id || "Untitled stage". The getProviderServiceLabel step (e.g. "OpenAI Realtime API") is the same derivation ServiceConfigForm writes into an empty stage_name when you open the node (its auto-fill effect, gated by shouldAutoFillStageName). A template stage that ships without a stage_name (e.g. the "OpenAI Speech Agent" builtin, whose stage id is llm-main) would otherwise render its raw id on the canvas and then visibly flip to the derived label the instant the inspector opened and wrote it back. Keeping both paths on getProviderServiceLabel removes the flip; the raw id is only the last-resort fallback for a stage with neither a name nor a resolvable provider/service.

Stages also carry an optional description (a field in the inspector). When populated it renders in the node's body on the canvas (PipelineNode, clamped to 3 lines), so authors can annotate what a stage does. It's leaf content — not in the structure signature — so editing it patches in place.

Collapsible param groups keep their open state across config edits

Service-definition params can be bucketed into collapsible param groups (e.g. Background Audio, Turn Detection, Greeting on the S2S definitions) via a group key plus a paramGroups entry in the definition's ui. DefinitionDrivenFields renders each group as a Collapsible and tracks expand/collapse in local openGroups state, seeded from each group's collapsed default by an init effect.

That init effect must be keyed on a content signature of the param groups (groupSignature — the [id, collapsed] pairs), not on the definition object identity. ServiceConfigForm re-creates the definition object literal ({...serviceDefinition, params, ui: {...}}) on every render, so a definition-keyed effect re-runs on every unrelated config change and resets openGroups to the defaults. That's what caused the "toggling Background Audio collapses its own group" bug: clicking the background_audio_enabled checkbox changed config → re-render → fresh definition → effect re-fired → the collapsed: true group snapped shut mid-toggle. Keyed on groupSignature, the effect only re-initializes when the groups themselves change (e.g. switching service), so the user's open state survives. Don't revert the dep array to [definition].

The service-config/ module — what came out of ServiceConfigForm

ServiceConfigForm.tsx was 4,034 lines with no test file. Its hooks and its provider-specific blocks now live under components/service-config/, leaving ~2,500 lines of form scaffolding and conditional composition in the parent. The split is a boundary, not a rewrite: every extraction was move-only, and the two module headers that record an intentional deviation say so explicitly.

Module What it owns
provider-flags.ts deriveProviderFlags(provider, service, serviceType) → the ~40 isElevenLabsTTS / isNovaSonic / isS2SWithoutPrompt booleans the whole form branches on. Pure, and the one piece with real test coverage (provider-flags.test.ts)
prompt-storage.ts messagesToPrompt / promptToMessages — the converters between config.template.inline (primary) and config.messages (backward-compat mirror). Pure, tested
use-voice-catalogs.ts usePalabraVoices, useElevenLabsVoices, useTtsPreview — voice-catalog fetches + the shared preview Audio element
use-integration-options.ts Integration dropdown options for the stage's provider
use-warm-transfer-context.ts Agents + consult profiles for the External Consult capability
NodeCapabilitiesAndBehaviors.tsx The whole Capabilities section (Multimodal UI, Video, and the four CapabilityCard entries)
EndSessionAnalysisPanel.tsx End-session analysis config
sections/*.tsx Provider-specific Behavior-tab blocks: TranslationSections, VoiceEnableBlocks, ElevenLabsVoiceSection, PromptSection

Three rules govern anything added here.

Preview and voice-catalog state is threaded in as props — never re-call the hook inside a section. useTtsPreview(config, integrationId) is called once, in ConfigFieldsEditor, and its isPlayingPreview / isLoadingPreview / onPlay* values are passed down to every block with a Play button. Calling it again inside ElevenLabsVoiceSection or TtsVoicePickerSection would create a second Audio element and a second copy of the playing/loading state, so the two Play buttons on screen would disagree — one showing a stop icon while the other still offers play, and two overlapping previews audible at once. useElevenLabsVoices has the same constraint (a second copy gets its own 300 ms search debounce and its own result list). That is a behaviour change disguised as a cleanup.

A section that writes more than one config key must take onChange, not just the field helpers. handleFieldChange and handleRemoveField each spread the same config from their closure, so two calls in one tick lose the first write. The ElevenLabs voice-mode switch (static ↔ dynamic) sets one key and clears another, so it does a single onChange with both edits — which is why that component takes all three.

The guards stay at the call site. ConfigFieldsEditor still reads as a list of {cond && <Section … />} lines, and the guard expressions are byte-identical to the ones that were inline. PromptSection's three exports are the case that matters: !isAgentAssist && !isS2SWithoutPrompt gates the greeting and the prompt editor, and the exact negation (isAgentAssist || isS2SWithoutPrompt) gates NoPromptFallbackSection. Narrow one side without widening the other and the Behavior tab renders nothing at all for that service — which reads as a broken form rather than as a service whose behaviour is configured elsewhere. That is the failure the fallback block exists to prevent (see Behavior tab availability).

ai-manager's test runner has no React renderer (node --import tsx --test over *.test.ts[x]), so none of the components or hooks above are unit-testable. That is the reason pure logic is pulled out into a sibling .ts rather than left inside the .tsx: provider-flags.ts and prompt-storage.ts are the parts that can be pinned by npm test, and both found a real bug when they were.

The tool-selector/ module — what came out of ToolSelector

ToolSelector.tsx was 2,769 lines with no test file. Its dialogs, its per-tool panels and its pure helpers now live under components/tool-selector/, leaving ~740 lines of picker + selection state in the parent. Same method as service-config/ above: every extraction was move-only, proven by diffing the new module's body against a pre-edit capture.

Module What it owns
inline-tool-helpers.ts Minting and migrating inline tool definitions — sanitizeToolName, the three createInline*Tool factories, legacyPythonCodeToSource, normalizeInlineToolForEditor / canonicalizeInlineToolForSave. Pure, tested
tool-row-helpers.ts hasToolOverrideValue, workflowToolHref, toolDefinitionHref, summarizeBuiltInConfig. Pure, tested
types.ts ToolOverrideConfig + WarmTransferToolContext, here rather than in the shell to break an import cycle. ToolSelector.tsx re-exports the latter so existing callers keep their path
use-tool-references.ts useToolReferences(open, hasSelectedConfigured) — the reference-list + tool-health fetch
ToolIcon.tsx Icon-name → lucide glyph, shared by the picker and the selected rows
SelectedToolRow.tsx One selected-tool row: badge, drift state, edit affordances, collapsible bindings
ToolSettingsPanel.tsx Enabled operations + parameter bindings for one tool, including the four-layer schema fallback
BuiltInToolConfigPanel.tsx The definition.configEditor switch for built-in capabilities
Inline{Python,Api,Knowledge}ToolDialog.tsx The three inline-tool authoring dialogs

Three things here are contracts rather than implementation details.

tool_filters absent is not the same as tool_filters: []. An absent key means every discovered operation is enabled; an empty array means none are. That is why setEnabledOperations deletes the key when the author has selected everything instead of writing the full list, and why hasToolOverrideValue tests Array.isArray rather than length. Store the full list and the set is frozen — an operation added to the tool later arrives disabled in every existing stage. Turn the isArray check into a length check and the override is discarded, silently re-enabling everything.

ToolSettingsPanel's schema resolution is a four-layer fallback and every layer is load-bearing: config.cached_tool_schemas (saved at discover) → tool_health.live_schema.tools (last refresh tick) → synthesized from config.input_schema (OpenAPI / Python) → synthesized from config.operations (legacy OpenAPI). A tool that was never re-saved after the cached_tool_schemas field existed has only layer 2 or 3, so dropping one renders "no bindable parameters" for a tool that plainly works.

Every setDraft in the three inline dialogs is functional (prev => …). This is the repo-wide inline-tool-dialog invariant, and the dialogs are where it was earned — PythonToolForm fires setParameters and onInputSchemaChange in the same tick, so a { ...draft } spread makes the second call clobber the first and the new parameter row appears for one render then reverts.

The same no-React-renderer constraint applies as for service-config/, so inline-tool-helpers.ts and tool-row-helpers.ts are the two modules npm test can pin — they are ToolSelector's first test coverage of any kind.

What came out of FlowEditor — converters, overlays, and the sync engine

FlowEditor.tsx was 4,542 lines and is now 1,646. Same move-only method as the two modules above, proven the same way (diff each new body against a capture taken before the cut), but split into three passes because the risk is not uniform: the pure converters could be extracted and tested, the UI overlays could be extracted and eyeballed, and the sync engine could only be extracted and reasoned about.

Module What it owns Tests
pipeline-converters.ts pipelineToFlowElements (config → graph), flowElementsToPipeline (graph → config), pipelineStructureSignature, MAIN_PIPELINE_X 24
frame-flow.ts computeFrameFlow — the DAG walk behind edge colour (see Edge color) 12
junction-overlay.ts computeJunctions — the fork/merge diamonds and their connector edges 18
canvas-history.ts useCanvasHistory — undo/redo stacks + the ⌘Z/⌘⇧Z/⌘Y handler 11
useInjectionPromptModal.ts Injection-prompt dialog state (which edge, the draft, the two node labels) —
CanvasViewOverlay.tsx The Canvas View tool card + the "N nodes hidden" reveal pills, and the three hidden-count memos —
useCanvasRecenter.ts Selection centring + the width ResizeObserver; owns the canvas container ref —
usePipelineSync.ts The two-way sync engine: the guard refs, sync-in, sync-back, and the output_lanes leaf patch —

The sync engine is five exported hooks in one file, and both halves of that are deliberate. One file, because the guard protocol is a single contract — four latches, each with a release, each earned from a real echo-loop or lost-edit bug — and splitting them across files is how a future edit changes one guard without seeing the other three. Five hooks rather than one, because the four effects are interleaved with other effects in the shell: useInitialElementsSync runs before useLayout(), the other three run after the render memos. React flushes effects in hook-call order, so collapsing them into a single usePipelineSync() call would reorder them — not a move, a behaviour change, and exactly the class of change that once produced "Maximum update depth exceeded" on a freshly-created Translation agent. So each hook is called from the shell at the line its effect used to occupy, and usePipelineSyncGuards returns the whole latch set as one object that the other four take as guards:.

Nothing in npm test covers those four effects, because there is no React renderer. The guarantees behind the extraction are mechanical rather than behavioural: every moved body is byte-verbatim, every dependency array (including the one eslint-disable-next-line react-hooks/exhaustive-deps) moved unchanged, and every effect kept its position in the hook order. A behavioural change in that file needs a live two-window check — two browsers on one agent, edit in both, confirm no echo loop and no lost edit. Green unit tests are not evidence there.

Extraction escalates the React Compiler lint rules, and that is a feature. The compiler bails out of a very large component before analysing it, so FlowEditor.tsx was silently exempt from react-hooks/refs and react-hooks/preserve-manual-memoization. Shrinking it turned four dormant violations into errors, none of them in code that had changed:

  • handleConfigureInjection's empty dependency array (preserve-manual-memoization). Fixed by making it honest — [t, setEdges, setNodes] — after checking all three are identity-stable, because that callback is baked into ~6 node/edge data payloads and a churning identity re-renders the canvas.
  • Two render-phase ref writes: useCanvasRecenter's selectedNodeIdRef and the shell's externalOnRequestAddRef. Both became an effect keyed on the value. Safe only because each ref is read from an event handler or a later effect, never during the same render — and in the recenter hook the effect must stay first in the hook, or a freshly-selected node centres against the previous selection for one frame.
  • One genuine false positive, on the initialElements memo: the rule follows addBranch into a setTimeout-deferred openBranchSidebarRef.current and then cannot prove pipelineToFlowElements doesn't invoke the callbacks during render (it stores them in node data). That one carries a scoped eslint-disable-next-line with the reasoning inline.

When you shrink a component and new lint errors appear in lines you didn't touch, diff them against HEAD before assuming you caused them — and prefer making the code honest over widening the suppression.

Two policies that look like one, deliberately left un-unified. useCanvasRecenter re-fits the graph (fitView, which recomputes zoom), while WorkflowGraphTab translates the viewport by half the width delta on the next animation frame (pan and zoom survive a panel toggle). A comment in the workflow editor claimed it mirrored the agent editor's behaviour; it never did. Both are correct as written, so de-duplicating them into a shared hook is a behaviour change to one of the two canvases and needs to be decided as a product question first. The false comment was the more dangerous half — it made "adopt the same hook in both" read as a refactor.

What came out of WorkflowGraphTab — panels, phases, and the graph-sync hook

Workflow layout uses workflow-layout.ts. Each phase gets a flat Dagre pass; its fitted container then participates in the parent pass. For Each cards stay compact and their focused bodies run the same layout independently. The converter retains legacy container support for old representations, but the workflow UI never expands those bodies inline. Forward edges assign the earliest possible topological row; END follows all other rows. Equal-width cells align columns, measured card heights set row spacing, router output order breaks branch ties, and DFS return edges are excluded from ranking without changing runtime edges. Nested positions remain parent-relative. Notes stay fixed and act as placement obstacles.

The converter repairs incomplete legacy phase geometry in the view. A phase position without a saved size or any positioned direct child is an obsolete anchor for automatic contents, so it must not pin a newly fitted group. Phases then follow graph rows with WORKFLOW_ROW_GAP between their measured bounds, including after toggling phase visibility. The config is not rewritten by opening or toggling the view. Sized groups and positioned contents retain their manual anchors, including (0, 0). A phase drag saves the current fitted box and direct-child geometry together; drag-start captures the visible Undo baseline.

Edges that skip rows participate in ordering through full-width virtual cells in every intervening row. The layout reserves a continuous column for each long or return edge and includes those columns when fitting containers. These cells never become workflow nodes. Start and End share the entry column, stay outside the step rows, and cannot be dragged independently; the same rule applies to focused body boundaries. Their center is derived from entry successors so it survives reload.

workflow-edge-routing.ts converts column hints into absolute, square paths and checks them against current card bounds, including after manual movement. Direct paths bend halfway through the card gap when clear; paths obstructed by taller neighbors use the safe row exit. Retry paths and self-loops also avoid their own endpoint cards. WorkflowFlowEdge uses real handle endpoints for drawing. Node handles align with card centers: standard cards compensate for their asymmetric accent border, and router titles truncate within a width determined by output count. This prevents small elbows between nodes in the same column. Ordinary + controls center between visible card borders. At a router fork they follow the output arm; before a join they center in the individual path ending at that join. workflowJoinGroups groups incoming edges by target, target handle and edge kind. Their routes meet at one visible dot, and one deterministic owner edge renders the shared + between the dot and target card. Layout reserves an extra WORKFLOW_JOIN_STEM before a joining row, including End/Loop markers. When a join enters the first row of a phase, the same extra space is added below the phase title so its dot and shared + cannot overlap the header. Each control stays on its own path segment as measured heights change. Badges sit outside the button's fixed sizing box with an intrinsic width, a maximum width and automatic height; long labels wrap inside their pills. layoutEdgeColumns and layoutRoute are derived canvas data only and must never enter workflow config or Yjs. Initial fitting, Auto layout and the Fit view control include routed edges as well as cards, so an early-exit column remains visible.

The canvas Auto layout action ignores stored positions and sizes, then writes the complete expanded geometry through the normal config/Yjs path once. Its optional historyBase captures the visible positions before arranging, so Undo can restore layouts that previously existed only in React Flow. Hidden phases use an independent flat projection; those absolute positions must never be saved as phase-child coordinates. Ordinary layout honors non-overlapping saved positions, including (0, 0), and places new nodes around them. Computed container styles are output only; only domain size fields count as saved constraints.

If saved sibling cards overlap, workflowScopesWithOverlappingPositions releases that scope's positional constraints for display. Its rows and branch columns are rebuilt together using measured sizes; other sibling scopes and notes retain their manual geometry. This covers repeated placeholder coordinates such as the published workflow 1932 as well as partial overlaps caused by larger cards. The repair also runs after config merges so a label edit cannot restore the bad coordinates. It is view-only in drafts and published versions: no autosave, draft creation or release mutation occurs just from opening the workflow. The first manual drag in a repaired scope saves its visible sibling geometry together, with the previous visible arrangement captured for Undo, so unchanged placeholder coordinates cannot undo the move on reload. Layout also watches committed positions, so generated Start/End markers update after a drag or Undo rather than only after reopening. Live drag coordinates and label-only edits do not trigger that geometry watch.

useAutoLayout updates the controlled node state through its functional setter, then fits and reports the committed geometry on the next frame. Do not route that write through React Flow's queued node replacements: a replacement can overtake the ResizeObserver update for a restored node, erase its measurements and leave it hidden after Redo.

Inserting through an edge's + calls commitInsertion: it captures the pre-insert geometry for Undo, then saves the structural edit with workflowConfigForReflow. This releases node/phase positions and fitted group heights in the current graph while preserving note geometry, group widths and separately edited nested definitions. Layout runs again as the new card is measured, so an estimated midpoint can never pin it over a neighbor. Reflow is derived on reload from the same graph; measurement passes do not create extra autosave writes or undo entries. The insertion requests one viewport fit after card and container measurements settle, keeping new and downstream steps visible. An insertion between nodes in the same phase inherits that phase; a body insertion retains its For Each parent.

WorkflowEdgeInsertion distinguishes edge and before-target. A branch insert matches the selected condition and kind as well as its endpoints, so two router outputs reaching the same target are not both split accidentally. A shared insert redirects all incoming edges of the selected kind to the new node, then adds one continuation to the previous target. Conditions and labels stay on their original source edges. Inserting before Decision affects every output path before the decision; independent router outputs are never moved onto an ordinary node as if they were an unconditional fork. Body refs retain their For Each ownership. Missing/stale selections do not append orphan nodes.

Bare workflow editor links prefer an available draft through workflow-editor-version.ts, preserving query parameters and holding the loading shell until navigation finishes. Explicit published links stay pinned. The workflow GET API retains its published-first behavior for runtime and integration consumers.

Validation includes pure geometry/round-trip tests and workflow-layout.spec.ts, covering the button, autosave, Undo/Redo, reload, nested containers and version navigation against the local stack. An unequal-branch/early-exit scenario also checks rendered square segments against unrelated cards, boundary alignment, edge-inclusive fitting and stable positions after reload. workflow-insertion-layout.spec.ts checks repeated edge insertions, unequal card sizes, branch joins, phase growth, Undo/Redo and reload without clicking Auto layout after insertion. It measures actual button centers against rendered card borders, arms and join dots before and after insertion and reload, including labelled router edges and body markers. workflow-junction-insertion.spec.ts also verifies common work before a decision, individual branch edits, shared work after joins, condition preservation, body ownership, Undo/Redo and pill text bounds. workflow-phase-layout.spec.ts covers the four-phase shape reported in workflow 1731 using synthetic configuration: stale phase-only anchors, consistent measured group gaps, header clearance, repeated visibility toggles without config writes, shared insertion into the next phase, manual phase dragging, Undo/Redo and reload. workflow-overlap-layout.spec.ts checks automatic collision repair on a real published fixture without changing its config, clear square branch paths, label-only draft edits, and adopting a repaired layout through manual dragging, Undo/Redo and reload.

WorkflowGraphTab.tsx was 4,687 lines and is now 1,963. Same move-only method and the same proof as the three modules above. Two things about the sequencing are worth copying rather than re-deriving: dependency order beat plan order (a component two siblings render needs its own module before either of them moves), and one planned row split in two (state vs. chrome) because the phase code was three blocks ~1,500 lines apart in the same component.

Module What it owns Tests
node-defaults.ts defaultLabelForNodeType, defaultConfigForNodeType, PHASE_COLOR_OPTIONS, uniquePhaseId 14
workflow-config-normalize.ts withRouterOutputsMigrated + normalizeWorkflowConfig — the three-pass pipeline every config crosses 12
useWorkflowGraphSync.ts Domain → ReactFlow projection, node/edge state, the external-change merge effect, dagre, drag-stop + resize persistence —
useWorkflowPhases.ts All phase state and the four phase writes —
panels/NodeEditor.tsx The node inspector + its five private per-type editors —
panels/WaitConditionEditor.tsx Wait-node conditions + durationToDisplay 4
panels/PhaseDialogs.tsx Phase context menu + the create/edit dialogs (presentational) —
panels/KeyValueEditor.tsx The shared key/value rows (two callers, hence its own module) —
panels/ResizableBlockPanel.tsx The right-hand drawer + its localStorage width —
NestedSubworkflowEditor.tsx The subworkflow / foreach drill-in canvas —

normalizeWorkflowConfig is the Yjs-dedup firewall and its pass order is load-bearing. Router migration, then foreach body derivation, then dedupConfigLists — dedup last, because the router pass can mint a duplicate edge (a legacy routes.default edge rewritten to out_else beside a modern out_else edge the author already drew), and a duplicate keyed row renders as two React children with one key, which pegs the CPU until the tab dies. All three passes are identity-preserving on an already-canonical config, which is what lets the one-shot load migration compare with !== instead of writing forever. The extraction is also where this got its first tests: 12, half of them on the legacy router shape that nothing had covered.

NestedSubworkflowEditor and WorkflowGraphTab import each other, and that cycle is safe only because of the declaration form. Both are hoisted export functions and neither reads the other at module evaluation time, so by the time a render resolves the reference both modules have finished evaluating. Turn either into a const arrow or a memo(...) wrapper and the binding is in its TDZ when the other module's body runs — and the failure mode is a blank drill-in canvas, not a build error, because bundlers resolve the cycle without complaint. If one side ever needs a non-declaration export, pass the outer tab in as a prop instead of importing it.

The edge-delete suppression handshake stayed in the shell on purpose. suppressedNodeDeleteEdgeIdsRef is written by handleDeleteElements and read by handleEdgesChange; moving the ref and the filter into useWorkflowGraphSync while its other half stayed behind would put two halves of one protocol in two files. So the hook returns the raw onEdgesChange and the shell owns the wrapper. This is the same judgement as the sync engine's five-hooks-in-one-file above: a single mechanism belongs in a single file even when a line-count-driven split would separate it.

One duplicated constant was collapsed, deliberately. SENTINEL_START/SENTINEL_END were declared identically in both WorkflowGraphTab.tsx and flow/workflowFlowConverters.ts; the converters now export them and both readers import from there. It is the only non-move edit in the whole pass, and it is here because the new hook would otherwise have been a third copy.

The original extraction tests do not exercise the canvas. Those 30 tests cover the pure modules (node-defaults, workflow-config-normalize, durationToDisplay); the merge effect, the drag persistence, the drill-in, and the phase chrome are guaranteed only mechanically — byte-verbatim bodies, unchanged dependency arrays, unchanged effect order. Changing behaviour in any of them needs the browser pass from the plan: router + foreach, rename a router output and confirm the edge follows, collapse and expand a foreach, drill into a subworkflow, delete a node and confirm the edges stitch, move a phase.

What came out of the agent editor page — matchers, chrome, and four hooks

app/(main)/agents/[agentId]/page.tsx was 3,032 lines and is now 2,294. Same move-only method and the same proof as the four modules above, with one addition the page forced: a hook that absorbs an effect must be called at the position of the first line it absorbed. Moving a useState/useMemo/useCallback is safe because hook order only has to be stable; moving a useEffect past another effect changes which one runs first. Both new effect-carrying hooks (use-agent-tester-embeds, use-agent-versioning) are therefore called exactly where their old code sat, and only non-effect hooks ended up below them.

Module What it owns Tests
lib/pipeline-stage-patch.ts patchStageInPipeline + patchStageInOutputLanes — the pure search half of handleStageChange 21
components/editor/AgentPageHeader.tsx The toolbar: version selector, presence, autosave chip, save/publish/export/delete —
components/editor/AgentPageDialogs.tsx AgentSettingsDialog, AgentPublishDialog, CcaasMappingDialog (presentational) —
hooks/use-ccaas-routing.ts The CCaaS availability list, its fetch/reset lifecycle, and the publish patch —
hooks/use-agent-tester-embeds.ts The three tester iframe URLs + their signed embed tokens —
hooks/use-active-pipeline.ts activePipelineAccessor, selectedStage, navigableNodes, reservedStageIds —
hooks/use-agent-versioning.ts Version state, the agent load, publish, version select —

handleStageChange has FOUR match attempts, not three, and a miss is silent. The plan counted three; the fourth is config.output_lanes, where a second audio branch is serialized as a lane processor and never appears in config.pipeline at all. There is no error path and no autosave safety net: an edit that matches nothing is dropped while the inspector still shows the keystroke, because the caller advances the snapshot regardless. That is the whole reason this row led with 21 characterization tests — one per attempt plus the precedence between them, so removing or reordering any of them fails a test instead of losing an operator's edit. Two of the four exist only for stages with no id yet (updated.id for a stage that just minted one from its label; positional node-N for template-seeded stages), and the lane matcher keys on the stable service id rather than the node id because a freshly-added lane stage's node id is node-<uuid> until a round-trip.

Every walker recurses, and that is not decoration. A single parallel group is a flat ServiceStage[]; a multi-branch parallel (two+ audio swimlanes) is a list-of-lists [[stage…],[stage…]]. A one-level .map compares a branch array's .id (undefined) against the target, matches nothing, and the stage reads as absent — which drops the edit in patchStageInPipeline, opens the inspector on a stale snapshot in selectedStage, and under-reports reservedStageIds so a duplicate id passes validation.

activePipelineAccessor is an accessor because the pipeline lives in two places. A plain agent stores stages at config.pipeline; a unified translation agent stores one per direction at config.translation.flows[direction].pipeline and the editor shows one direction at a time. write(next) returns a whole new config with the right branch replaced. Code that reaches for config.pipeline directly works on plain agents and silently edits the wrong thing on a translation agent.

use-agent-versioning takes eight page setters, and they must be raw useState setters. One fetch resolves the agent, its editability, the JSON mirror, the validation result and the version triple together, so the load can't be split without a second source of truth. The setters are deliberately absent from loadPipeline's dependency array (still [router, t]), which eslint reports as missing — that is the safe side: a useState setter is referentially stable so nothing stale is captured, whereas an inline-arrow caller plus a "complete" dependency array would rebuild loadPipeline every render, refire the load effect, and re-fetch the agent forever.

Publish's collab drain and the tester's are two different flushes and both are ordering-critical. Publish POSTs /api/auth/collab-flush with final: true before the publish POST, so a teammate's in-flight Y.Doc edit can't leak into or vanish from the snapshot; the tester's flushCollabBeforeTest stayed in the page because it has to be defined before handleStartChat, which is upstream of the embed hook's call site. Neither flush is fatal on failure — the DB already holds the last debounced write.

What deliberately stayed in the page. The four thin modals (Activity, Share, Export picker, AgentCore) — extracting a <Dialog> with two children buys nothing. flushCollabBeforeTest, per above. The showPublishDialog reset effect, because it resets gradual-rollout controls and CCaaS state and splitting it would reorder effects. And the collab wiring itself: enableYjs={versionStatus === 'draft'} means versioning decides the mode and the page owns the wiring.

The extraction surfaced one piece of dead code, left in place. The page computed voiceTranscriptSrc and passed it to nothing — its only consumer was a debug console.log. PhoneCallTest re-derives the same URL from the four parts it is handed. The hook still returns it (it is the correct URL, and the duplication belongs downstream), but the page no longer destructures it. Collapsing the two is a behaviour change, not a move, so it is a follow-up rather than part of this pass.

File names follow the hooks/ convention, not the plan's. The plan wrote useCcaasRouting.ts / useAgentTester.ts / useSubagentAccessor.ts; hooks/ is kebab-case use-*.ts in 15 of 16 existing files, so the new hooks match the directory. useSubagentAccessor was also a misnomer — what it returns is the active pipeline accessor and the selection derived from it, nothing subagent-specific — hence use-active-pipeline.ts.

2,294 is not the plan's ~1,200, and the remainder is not more of the same. What's left is ~130 lines of imports, ~1,330 lines of state and 25 handlers (handleStageChange, handleSave, the subagent-navigation cluster, the four export paths, the lane/swimlane writers), and ~830 lines of render. Getting under 1,200 means splitting the render tree and the handler cluster, which the plan scoped no rows for; every row it did scope is done. As with FlowEditor and WorkflowGraphTab, nothing in npm test renders this page: the 21 new tests cover the matchers, and everything else is guaranteed mechanically — byte-verbatim bodies, unchanged dependency arrays, unchanged effect order.

Behavior tab availability

Some stages have no editable system prompt because their conversational behavior is configured elsewhere (a provider console, AWS, or a processor's Properties tab). Examples: ElevenLabs Conversational AI Agent, Bedrock managed Agent, Google GECX, Palabra/Google/OpenAI translation, Deepgram STT, and the LLM-backed processors.

The Behavior tab is broader than the prompt editor: it also owns tools, subagents, skills, and node capabilities. The single source of truth for tab visibility is lib/stage-prompt.ts's stageHasBehaviorTab(stage), which keeps promptless conversational stages on the Behavior surface while hiding it for stages with no behavior controls. The same module also decides which tab the inspector opens on: defaultTabForStage(stage) returns Behavior once the stage's required properties are satisfied (stageRequiredPropertiesSatisfied — presence-only, for non-hidden required params with no default; integration_id-style params fall back to the stage's own top-level field), else Properties. Both the agent page's handleEditStage and NodeInspectorPanel's initial/reset state call it, so a stage opens on the same tab from either surface. Deliberately NOT validateServiceConfig: an out-of-range temperature is not a reason to hide the prompt. stageHasEditablePrompt(stage) still tracks whether the prompt editor itself should render and must stay in sync with the isS2SWithoutPrompt / isAgentAssist gating in ServiceConfigForm.tsx.

GECX (Google CES) and Krisp Voice Translation have no Behavior tab at all. stageHasBehaviorTab returns false for both, so their inspectors land on Properties. Krisp's languages, voice, vocabulary/glossary, audio-processing, and output controls all render there. GECX renders its connection fields (Region → GECX Agent → Deployment), Tools, Session variables, node capabilities (Multimodal UI, External Consult), and the GECX-specific tool-dispatch table (CESConfigPanel) inside Properties. There is no longer a "Google GECX" card/header — GECX renders like any other param-driven service. Connection fields come from the service definition (Region/App/Deployment dropdowns backed by /api/ces/agents + /api/ces/deployments); the old raw Project/App-ID/Location/Deployment/App-version text inputs are gone (Project comes from the Google integration; App version was removed).

Node-owned operational capabilities live in the selected stage's Behavior tab (Properties for GECX), not in the agent Operations sheet. Every entry in that section renders inside the shared CapabilityCard and is collapsible — Multimodal UI and Video input (inline in ServiceConfigForm's NodeCapabilitiesAndBehaviors) plus Message Injection, Human Supervision, External Consult, Customer Authentication and Payments (each owns its card). Cards start collapsed (six expanded editors made the inspector unusably tall), expand when you switch the capability on, and remember open/closed per capability in localStorage (ai-manager:capability-card:<builtin id>) — never in stage config, since collab would broadcast one author's layout preference to everyone in the room (same rule as Canvas View). The enable Switch sits outside the collapse trigger, so flipping a capability never toggles the fold; a capability with nothing to configure renders no chevron and no body. CAPABILITY_BUILTIN_IDS is the ToolSelector-only classification for refs whose toggles live there: receiving-side builtin:message_injection, builtin:human_supervision, builtin:warm_transfer, builtin:customer_authentication, and builtin:payments. Their refs remain in config.tools as runtime enable gates but are filtered from selected Tool rows, so a capability omitted from builtInTools is never mislabeled unresolved. This classification is deliberately separate from SINGLETON_BUILTIN_IDS: Customer Authentication is multi-owner, and Authentication, Payments, External Consult and Transfer Agent all expand to their real callable tool names in prompt autocomplete (BUILTIN_PROMPT_TOOL_NAMES beats the singleton skip — see Dropdown sections). The callable Message Injection sender is the separate builtin:inject_message_to_conversation Tool, whose custom editor exposes only target_conversation_id. handleStageChange on the agent page enforces singleton ownership for receiving Message Injection, Human Supervision and IVR Navigation. Human Supervision (HumanSupervisionCapability) renders its card only while builtin:human_supervision is enabled in config.tools (the same enabled-only rule the other capability cards follow — an agent that carries the ref never hides its state), and is offered through the capability library only on voice-capable stages (isVoicePipeline && isConsultCapableStage, the External Consult gate — the relay streams a live CALL's transcript, so a text-chat agent, e.g. the supervisor chat agent itself, is never offered the capability it is the target of; the library entry reuses the exported buildHumanSupervisionEnablePartial, same rule as every built-in entry). The supervisor chat agent instead gets a read-only Capabilities entry (SupervisingForCard, the readOnly no-switch variant of CapabilityCard): "This agent serves as the supervisor chat agent for N agent(s)", each name linking to /agents/{id} — fed by GET /api/agents/[agentId]/supervising-for (withAuth('AIManager:ManageAgents'), tenant-scoped), which scans current published + draft agent_versions for stages with the ref enabled whose builtin_tool_configs.human_supervision.supervisor_agent_id names this agent, deduped per agent. It renders nothing while loading or when no agent references this one. Human Supervision is a singleton whose slug registers no callable tool (so it stays out of BUILTIN_PROMPT_TOOL_NAMES) — but the tool or workflow its card attaches IS LLM-callable, and the prompt editor's Tools dropdown and Run-an-action picker offer it via buildHumanSupervisionToolItems (data-sources.ts), which reads the attachment out of config.builtin_tool_configs.human_supervision.request_support (the one callable a config.tools walk can't see; the ref's presence in config.tools is the gate — a leftover config block with the capability off resolves nothing). Tool mode routes through the ordinary tool-reference lookup; workflow mode lists the workflow under its authored name but inserts the SANITIZED runtime name (sanitizeWorkflowToolName, mirroring the orchestrator's workflow_tool_naming.py: "Request Salesforce Approval" → {{TOOL:request_salesforce_approval}}), because that lowercase snake_case string is what the runtime registers — a token spelling the display name would ship literal. The same display-vs-runtime naming applies to ordinary type_id='workflow' tool rows. Its card selects a tool or a workflow to request supervisor support, a supervisor chat agent, a stream-transcripts toggle, and transcript-handling guidance, stored at config.builtin_tool_configs.human_supervision. Transcript-handling guidance is an override: left blank, a platform default applies — transcripts arrive as live-call context and the chat agent stays silent unless something important happens or the supervisor asks. Choosing a workflow renders it as a collapsible, tool-style SelectedWorkflowRow (Definition link → /workflows/{id}?focus=input via workflowToolHref, a pop-out modal, and an inline chevron) whose body is a WorkflowInputMappingEditor; the editor is sourced from the selected workflow's config.input_parameters (already present on the /api/workflows list payload) and binds each declared input to a literal or a $var: runtime binding into request_support.variable_mappings. A workflow switch or the row's X clears the prior mappings (same channel-discriminator rule as the tool/workflow id). At runtime the orchestrator exposes the picked request_support tool/workflow to the LLM as a callable function — tool mode wires the tool_id, workflow mode wires the workflow_id as a synthetic workflow tool with variable_mappings applied as parameter bindings — so the agent invokes it when it decides a human is needed. The invocation follows the workflow's declared execution_mode: a sync workflow blocks the tool call for up to its timeout and returns the End node's mapped outputs, while an async/batch workflow fires-and-forgets and returns an execution_id immediately. For the normal Salesforce escalation pattern, use a short async workflow containing the request action and then END. The separate supervisor chat injects the human response into the live call; that response is not a workflow output. A wait is needed only when a separately requested business process consumes a later callback. The legacy human_review handler is hidden from the authoring palette and should not be generated for Human Supervision. Async semantics still matter for older callback workflows: forcing those sync bypasses the persisted execution needed to suspend them. See Human Supervision for the two paths and related-thread binding. A blank mapping (an input left empty in the editor) is treated as "no binding" and dropped, never sent as an empty string: an empty string would both override a value the model chose and crash a typed input (async-agent rejects '' as a UUID). A workflow input is required unless its parameter declares required: false, so leaving an optional input blank falls back to its default while a required one surfaces a clean "missing field" error. The supervisor chat agent / transcript-streaming half is a SEPARATE runtime path from request_support: when supervisor_agent_id is set and stream_transcripts is not false, the orchestrator hands the model agent-host's stream_transcript_to_supervisor MCP tool (bound + hidden supervisor_agent_id/transcript_guidance, live call id via $var:conversation_id, merged onto the same agent-host MCP server as inject_message_to_conversation). On escalation the model calls it; agent-host creates a chat conversation running the supervisor chat agent, seeds transcript_guidance once, and runs a background pump forwarding the call's finalized [Caller]/[Agent] turns until it ends. External Consult (warm transfer) is a Capabilities-section item (ExternalConsultCapability) next to Multimodal UI, voice-only (see warm-handoff.md). The Multimodal UI catalog picker is collapsed by default, summarizes selections as pills, and shows only catalog names plus version policy controls—never catalog URLs or scope badges. GECX exposes the selected catalogs to Google as OpenAPI tools (/api/agents/[agentId]/ces-openapi-urls), and its External Consult variant pins a profile + shows the consult MCP URL, writing the legacy ServiceStage.config.ces_integration key.

The auto-injected chips (inside the prompt editor)

An auto-injected chip strip renders inside the prompt editor's framed box, below the document (InjectedPromptChips.tsx, via PromptEditor's footer slot — the frame div wraps the CM host and the footer as SIBLINGS, because CodeMirror appends its DOM imperatively and sharing a div with React children lets reconciliation and CM fight over child order): one chip per block the platform appends to the authored prompt at runtime — the date/time preamble, tenant global instructions (Settings → Tenant Settings → Instructions), and each enabled capability's guidance. Clicking a chip opens a detail panel with per-stage suppress/edit/remove writing the stage's config.auto_injections; removed chips render struck-through-dashed, customized ones amber. The chips are React chrome, NEVER document text (the #when placeholder-widget rule — stored injected text would ship twice and freeze against default changes). The exact texts are fetched from POST /api/prompt-injections (→ the orchestrator, so the displayed text is rendered by the same helpers the live build uses), keyed on a debounced SIGNATURE of the injection-relevant config (tools + builtin_tool_configs + capability_configs) — NOT once per mount, because the section's config is form.watch("config") whose first render(s) are the pre-reset transient with no tools (a mount-latched fetch showed only the Date & time chip, forever). The payments chip carries a destructive warning when customized — see prompt-auto-injections.md.

Prompt editor decoration layers (tokens + markdown preview)

The shared prompt editor (PromptEditor.tsx) is CodeMirror 6 with two independent decoration providers, both feeding CM's multi-provider EditorView.decorations facet — neither touches the other's DecorationSet:

  1. Token highlighting (lib/codemirror/prompt-tokens.ts) — colors {{variable}} / {{TOOL:…}} / {{SUBAGENT:…}} / {{COMPONENT:…}} (A2UI catalog components, teal) and drives the @ / {{ / \ autocomplete. Decoration.mark() only.
  2. WYSIWYG markdown preview (lib/codemirror/markdown-preview.ts, prop markdownPreview on PromptEditor, default on) — renders headings/bold/italic/code/lists/blockquote inline and hides the raw markers (#, **, `, >, -) on inactive lines, revealing them on whatever line the caret is on (rebuilds on selectionSet, not just docChanged). Hidden markers are Decoration.replace({}) and are also published as EditorView.atomicRanges so arrows skip them. Parser is a bare new LanguageSupport(markdownLanguage) — no markdown keymap/autocomplete, so it can't collide with the token autocomplete. The walk is viewport-bounded (view.visibleRanges); the doc is never mutated, so onChange still emits raw markdown.

Load-bearing invariant — markdown content marks must NEVER set color. Where the two layers overlap (e.g. {{var}} inside a ## heading), both <span>s apply; the markdown mark supplies only font-size/weight/style and the token mark supplies color, so the token color always wins. The markdown walk also early-returns inside any {{ … }} range (via the shared getTokenRanges() exported from prompt-tokens.ts), so */# inside tokens or Handlebars blocks ({{#if}}) never render as emphasis/headings. Don't give a markdown content class a color, and don't fork the brace regex — both files read the one TOKEN_RE.

The @ / {{ autocomplete groups options into sticky sections by CM section.rank: Variables (1) / Tools (2) / Subagents (3), then two sections per enabled A2UI catalog (rank 10+) — <Catalog> · Components and <Catalog> · Data Tools, each sorted by name — so components and declarative/data tools read as clearly-separated subheadings under each catalog. When a stage has multimodality enabled (config.multimodality.enabled), every component and data tool from each attached catalog (config.multimodality.catalogs[] URIs) becomes a referenceable item — fetched once from /api/catalogs/components and grouped by buildComponentCatalogs, which matches catalogs by trailing slug, not scope|slug (the built-ins were rescoped shared→tenant, so a config still holding a v1/<slug> URI must still match the now-tenant row). Real tool_configs/inline tools — plus LLM-callable built-ins like End Session (builtin:end_session → token name end_session) — stay in the flat Tools section via buildAgentToolsList. A capability ref that stands for several runtime tools is expanded into those names via BUILTIN_PROMPT_TOOL_NAMES (node-builtins.ts): Customer Authentication, Payments, External Consult (start_consult / send_to_consult / merge_calls / cancel_consult) and Transfer Agent (send_to_origin / request_merge / abort_consult). That lookup runs before the SINGLETON_BUILTIN_IDS skip — the skip only removes Message Injection and IVR Navigation, which register no LLM-callable tool at all. Getting the order the other way round hid all seven consult tools from an author whose stage plainly had External Consult switched on. Human Supervision's attachment takes a THIRD path: it isn't in config.tools at all, so buildAgentToolsList also reads builtin_tool_configs.human_supervision.request_support (via buildHumanSupervisionToolItems + a gated /api/workflows fetch when the mode is workflow) — see the Capabilities paragraph above; the runtime mirror is build_tool_name_map(stage_config=…) in token_resolver.py. The data flows through the componentCatalogs source on PromptEditor, surfaced by useAgentPromptSources and threaded by every consumer (ServiceConfigForm, SubagentConfigForm, SubagentEditDialog).

Tool-library handoff. toolAddCompletion explicitly calls CodeMirror’s closeCompletion before opening the library from either @ or {{. The editor retains closeOnBlur: false for normal menu interaction and preserves the trigger range until selection, inline-tool creation or cancellation resolves.

Effective tool ids — defaults count. The hook resolves tools from an effectiveToolIds option, not raw config.tools. The ToolSelector renders a service-def tools.default (e.g. ["builtin:end_session"]) as selected before it is ever written to config.tools (DefinitionDrivenFields shows config[name] ?? param.default), so a stage that never toggled tools has an empty config.tools yet behaves as if End Session is on. ServiceConfigForm computes effectiveToolIds = config.tools ?? serviceDefinition.params.tools.default and passes it down, so the prompt dropdown matches what the ToolSelector shows. Without this, defaulted built-ins never appear in the prompt picker.

Row alignment — long text goes to info, not detail. Completion rows are single-line flex (white-space: nowrap, label truncates with ellipsis, detail pinned right via margin-left:auto). A PromptEditorOption.hint is the SHORT inline detail (a kind word like component / data tool); the long per-item description rides PromptEditorOption.info, which CM renders in its side info panel. Putting a full catalog-component description in detail made rows wrap and the bullets misalign — keep descriptions in info.

Picking a catalog item inserts a {{COMPONENT:Name}} token (distinct kind, teal) — not {{TOOL:…}}, because a component's runtime semantics differ. At render time token_resolver.py expands the token into concise "how to render this" guidance — naming the send_a2ui_json_to_client tool, the component type, and its key props — built by build_component_descriptions_map from both the built-in CATALOG_REGISTRY (a2ui_catalogs.py, richer key_props, wins on name collision) and the tenant-authored a2ui_components.props_schema rows (matched by trailing slug). {{TOOL:…}}/{{SUBAGENT:…}} still resolve to a bare runtime name; an unknown {{COMPONENT:…}} is left literal and logged as unresolved. The main system prompt goes through the async DB path (PromptResolver.resolve); the greeting + structured-output closures in universal_builder use the registry-only sync variant (build_component_descriptions_map_sync), so tenant-custom components are registry-resolved there only.

Full-screen node editor (collapsed header)

When a node is maximized (the Maximize button in the inspector header), NodeExpandedView mounts as an inset-0 overlay over the editor area. To free vertical room for prompt editing, the inspector's own header + tab bar are suppressed — NodeInspectorPanel is rendered with hideChrome and a controlled tab / onTabChange — and the overlay's single collapsed header owns them instead. The header is a two-column row mirroring the body below it: the editor controls on the left, and — when the Preview is embedded — the Preview header on the right, width-matched to the tester column so the two headers share one row instead of stacking:

‹ Canvas │ Node ▾ │ [ Properties | Behavior ] │  ‹ Canvas │ PREVIEW            ⤡
└──────────────── editor column ──────────────┘  └──── tester column (34%) ────┘
  • Node selector — a dropdown listing every navigable node, built by flattenNavigableNodes(config). It walks the active pipeline and emits each top-level linear stage followed by its subagents, depth-first, so the hierarchy (config.subagents.agents[] chains, any depth) reads in order. Each entry carries a depth (0 = stage, 1+ = subagent nesting, rendered as indentation) and a kind (stage | subagent) that routes a pick to handleEditStage or handleEditSubagent. Parallel/swimlane list-of-lists entries are skipped (no single stage to open).
  • Tabs — Properties / Behavior, hoisted to the page as expandedTab and reset to properties on each stage selection. The switcher is hidden for subagents (they use one scrolling SubagentConfigForm, not Properties / Behavior) and for stages with no behavior surface.
  • Subagents in full screen — selecting a subagent renders the same SubagentConfigForm the sidebar uses, chrome-less, via the page's shared renderSubagentInspector(chromeless) helper (one body, two hosts — sidebar with its own header, overlay without).
  • Preview stays embedded on the right at full height — maximizing the editor must never hide testing. The overlay renders a header-less tester (renderTesterPanel(false, /*hideHeader*/ true), i.e. TesterSidebarPanel with hideHeader); its header is hoisted into the overlay's top bar as the right column via the exported PreviewHeader (inline). This keeps the Preview label + maximize toggle level with the editor controls and reclaims a header row's worth of vertical space. The Preview header also carries its own ‹ Canvas back button (onBack) so the full-screen preview overlay (testerFullscreenOpen) exits back to the canvas the same way the node editor does.
  • Recording keeps the ordinary Preview header mounted. PreviewRecordingControls places status, Test & Record, Record, Stop and all download actions in a PreviewControlWidget in the left column. The recorder and capture refs stay in PhoneCallTest; the widget is outside the phone/AI Activity composition. There is no separate recording header, layout branch or header render slot. Toggling Recording therefore preserves the header and phone instance.

Read-only inspection and Preview

Viewer access and published versions show View only in the editor header. Voice/Chat FlowEditor removes mutation callbacks and structural action controls, disables deletion and history writes, and keeps measurement/layout, inspection, panning and zoom available. Workflow graphs retain selectable nodes, Start/End inspectors and nested canvas navigation while disabling connections, dragging, resizing and deletion. Show phases remains a local display preference.

EditorReadOnlyProvider scopes configuration forms, including portalled dialogs, subagents, capabilities, workflow node/sentinel settings and routing rules. Nested providers cannot unlock an inherited lock. Shared text controls use native read-only semantics so values can be selected and copied; selectors, toggles, sliders and mutation buttons are disabled. Configuration buttons default to locked inside this scope; use readOnlySafe only for inspection, disclosure, copy, navigation or preview actions. Bespoke styled buttons use UnstyledButton for the same behavior. CodeMirror also rejects document-changing transactions while read only; saved configuration refreshes carry externalConfigUpdate. Root mutation callbacks remain guarded in addition to these UI controls.

Keep Preview outside the configuration scope. Both editors provide a header Preview action and identify the version being previewed. Workflow Preview receives the loaded canvas version (id:draft or id:vN), even when a different draft exists; its component key resets inputs/results on version switches. Preview inputs are labelled temporary and do not change saved configuration. Agent regression scenarios remain available through Saved tests.

Validation: e2e/studio-read-only.spec.ts covers Viewer inspection for Voice, Chat and Workflow, read-only prompts/properties/subagents, nested workflow inspection, phase visibility, exact-version Preview dispatch and Builder editing access. Preview dispatch is intercepted before execution; this suite does not validate provider availability. components/editor/read-only-code-editor.test.ts checks transaction blocking, selection, external updates and editable drafts.

Canvas panels, Preview, and recentering

Maximized phone Preview has a Lucide Presentation button beside minimize for Demo Mode. TesterSidebarPanel owns this ephemeral view state: it hides the header, setup widgets and properties while showing the phone and AI Activity in the existing viewport overlay. Exit demo or Escape restores the previous view and keyboard focus; AI Activity's original toggle value is preserved. No agent config, transport or call state changes. CSS hides mounted controls, and the phone and any already-open AI Activity observer retain their DOM positions and identities. PhoneCallTest fits a two-column presentation within 1200 px, up to native phone scale, with a 640 px canvas breakpoint for stacking on narrow screens. Recording continues using the existing composition and fixed output dimensions. Return from Demo Mode to change setup, recording or caller controls. The control is offered only for layouts containing the full phone, not text chat or compact Copilot.

Shareable Preview. The Link2 Copy preview link button beside Presentation (and beside Exit demo) captures the current test inputs and opens the recipient in Demo Mode where the full phone supports it, otherwise maximized Preview. lib/preview-link.ts owns the versioned #preview=v1.<base64url UTF-8 JSON> format. Its allowlist covers display toggles, Voice/Chat and event display, avatar preference, template variables, AI Activity display settings, Telephony connection/destination/flow/label, and Mock Caller settings. It excludes config, Debug, runtime call state, credentials and Copilot/translation target-agent objects. Copying drops Studio demo-run query parameters and injected run IDs.

usePreviewLink reads the fragment once per agent navigation, after hydration. The page restores its controlled fields before mounting the panel; nested settings hooks accept initial overrides so stored personal preferences cannot clobber the shared setup. Minimize clears the fragment and the initial override. The panel uses native history.replaceState to keep the address synchronized without remounting the phone or adding history entries. Invalid or future fragments fall back to the ordinary editor. Hash changes alone do not reconfigure an active call; open/reload the link to apply it. A 32 KiB hash cap, 100-variable cap and 2,000-character per-variable cap bound parsing and URL generation; oversized current settings clear any stale fragment and Copy reports the error. The URL is an encoded snapshot, not encrypted storage or an authorization grant.

Opening a link never starts a call, capture, microphone or recording. Existing tenant permissions, integration lookup and call validation still apply. The unauthenticated redirect preserves the query and inherits the browser fragment; Login retains the complete destination in sessionStorage. Only the hash-free destination goes into OAuth state. On return from SSO, the agent page consumes the stored fragment only when the same-origin path and query match. Ordinary in-app login and session-expiry redirects also preserve the fragment. preview-link.test.ts and e2e/preview-link.spec.ts cover the format, fresh browser restoration, storage precedence, idle call state and login return.

Ordinary maximized voice Preview caps the phone at 90% scale and fits smaller windows using the actual space from the canvas top to the viewport bottom, leaving 24 px below the frame. The canvas and its scrolling viewport are observed so window resizing and header wrapping recalculate the fit. Recording uses the same canvas calculation with its existing scale limits.

Voice Preview's Mock Caller provides an adaptive caller prompt, ElevenLabs voice, Nudge caller, Wrap up and direct audio recording. Its session is owned by TesterSidebarPanel; caller settings never enter agent configuration. The toggle and inset pencil occupy one bordered header control; maximized Preview supplies one portal slot used in every mode. Enabling the toggle opens settings with the standard searchable ElevenLabs picker and Play sample. Timing help separates the elapsed call limit from the quiet guard before generating a caller reply. Optional MP4 silence edits preserve synchronized audio/video and expose an original download. Status, the caller direction field, Nudge caller and Wrap up live in a compact PreviewControlWidget in the left canvas column. Its pencil opens the same settings dialog as the header pencil; both disable during a call or recording. The widget matches AI Activity's rounded-xl corners. At a 900 px canvas breakpoint, equal flexible side columns flank the phone's scaled width, with controls flush left and the phone centered. AI Activity matches the scaled phone height. Narrow layouts stack controls above the phone and AI Activity below it. PhoneCallTest owns this shared canvas in both ordinary Preview and Recording; the recording composition uses subgrid to span just the phone and AI Activity. AI Activity uses a compact chronological transcript with expandable inline action rows for Mock Caller and Telephony. The phone fit accounts for the panel. Recording keeps this panel outside its phone/AI Activity crop so private directions never appear in the MP4. See Mock Caller.

The header switches share PreviewToggle typography, color, spacing and disabled styling, ordered Show AI Activity, Recording, Mock Caller, Telephony, SMS. All are maximized-only; sidebar and embedded inspector Preview headers show Run tests and the maximize control without any toggles. Collapsing Preview clears all five switches, ends mock/telephony tests and stops recording. Saved caller and routing settings remain available when the modes are enabled again. Telephony is disabled while Mock Caller is enabled because the caller transport does not support IVR simulation. All modes use the same header.

Expanded voice Preview includes Telephony, whose page-owned switch selects external provider routing in the existing phone. PreviewTelephonyControls uses the same left-column widget shell for Integration and destination selection, outside the handset and recording crop. The phone keeps its home-banner start experience; starting calls still dispatches the selected provider transport. The widget loads a minimal RunAgents catalog on demand and remembers the connection and destination per tenant/user browser tab. The optional Phone label override uses the agent name when blank and never enters provider routing metadata. ANI and other Preview variables sit in the left controls column, outside the captured phone/AI Activity area. TesterSidebarPanel passes the shared TemplateVariablesInput widget through PhoneCallTest.previewVariables; only compact voice Preview and chat keep their variable cards below the test surface. The widget uses a 440 px container breakpoint for two field columns; narrower widgets use one. Its header stays fixed while the fields scroll. The left column is capped at the scaled phone height, with variables shrinking into the space remaining below other controls. Narrow stacked layouts cap the controls at min(300px,35dvh), independent of phone scale, so fitting cannot oscillate. The auth provider keeps one active transport and supplies both audio tracks to the recorder. See Telephony Preview for SIP transfer handling, Genesys discovery, Connect entry paths, and provider activity limitations. Genesys x-inin-cnv from the SIP answer resolves through an owned Preview session into telephonyConversationId. Only AI Activity uses this ID in passive follow mode; the phone must not start an AI conversation from a telephony session ID. The resolved ID remains available after hangup for Conversation End workflows.

Preview's text-only Show AI Activity switch is ephemeral page state, visible only in maximized Preview and Recording. Minimizing clears the switch and hides the panel; the next maximization starts with every switch off. Expanded headers omit Run tests. TesterSidebarPanel lazy-loads PreviewBrainPanel alongside the existing phone/chat. Voice Preview and Recording both use PhoneCallTest.previewSidecar; the capture target includes both the phone and AI Activity when enabled, and only the phone otherwise. Container queries place AI Activity below the primary surface in narrow panels and beside it in wide panels.

AI Activity is a passive observer of connectPhoneEventStream using mode=follow, so opening it during or after a call can replay available events. It consumes transcripts, TOOL_CALL/tool_call, and terminal STATUS events. The pure preview-brain.ts projection merges transcript updates by utterance identity and tool updates by call ID, preserving parallel calls and preventing replayed starts from replacing terminal results. The parent keys the observer by conversation ID. Toggling AI Activity never remounts the primary preview or issues a conversation/action mutation. Closing it disconnects its observer; reopening replays the conversation's available events.

PreviewThinkingSettings supplies a pencil action inside the header toggle and the AI Activity widget. Caller/agent label overrides, minutes/seconds precision, and Show Actions Inline (showActionsInline) are browser preferences scoped to tenant, user, and selected agent version; empty overrides use Caller and the configured agent name. A shared context applies precision to transcript and action times, including lifecycle steps. The recording captures these same rendered labels. No draft write or transcript mutation occurs. Collapse clears the visible dialog and mode, retaining preferences. The inline preference defaults false, including when loading older saved settings. When true, PreviewBrainPanel renders its ordered transcript/action timeline at every width; compact density remains controlled separately. When false, the existing automatic layout applies. Changing the preference preserves the conversation subscription and follows the latest entry if following was enabled.

An accepted end_session displays Ending call until terminal STATUS, then Call ended. In the compact timeline completed closure follows all speech, including late captions, and shows the terminal status timestamp. Details retain the tool's original request time and result. A refused tool result must not be presented as an accepted call end. A single five-second drain after terminal status accepts final caption revisions before disconnecting the passive stream. Component teardown cancels this timer. Other tool execution times and order are unchanged.

Conversation lifecycle workflows use a separate persisted source: GET /api/preview/lifecycle?conversationId=… reads workflow_executions, workflow_runs, and workflow_node_executions. It requires RunAgents plus phone-session authorization, explicitly restricts all reads to the active tenant even for platform admins, and resolves both conversation UUID and transport-session aliases. It returns trigger inputs, final run outputs, and per-step evidence without dispatching work. At most 100 linked execution/run rows and 1,000 node rows are returned; truncated is shown explicitly. usePreviewLifecycle polls serially every 1.5 seconds with cancellation and failure backoff. It keeps polling after terminal conversation STATUS and the transcript drain, since durable end workflows can finish after hangup. Closing AI Activity cancels the request and timer; reopening loads persisted evidence. No workflow is synthesized from configuration alone.

The orchestrator marks trigger inputs with _lifecycle_hook=on_start or on_complete; this survives in execution metadata and distinguishes a workflow used at both boundaries. Older start executions use invocation_source=agent_conversation_start; older end executions are recognized by direct asynchronous invocation plus their enriched lifecycle trigger shape. Legacy in-processor HTTP step loops have no persisted run trace and are not projected. Cards show Conversation Start / Conversation End, the workflow name, expandable context/result, and nested Workflow actions. The step list starts expanded; payloads remain collapsed. A completed run with failed node executions is labeled Completed with errors and surfaces the first node error on its parent card, retaining the persisted run status in the API. This covers workflows that handle an action failure by exiting through a router: completed alone does not mean every action succeeded.

The readiness/read-only/system-summary strip is removed. Systems remain on individual action cards. Routine stream reconnects are silent; confirmed authorization failures and repeated workflow-history failures appear in the main AI Activity header with Retry. The ticket proxy preserves upstream error statuses so transient server errors do not become false permission failures.

System labels and descriptions use the existing tool reference cache and buildAgentToolsList for configured inline/built-in actions. Unknown tools use a generic label rather than inferring a system from the name. Results render as escaped text/JSON inside native disclosures, with no runnable controls. Raw model reasoning events are excluded. The stream follows the server-selected SSE/WS transport and existing conversation authorization; a failed transport probe falls back to SSE. The phone SSE BFF accepts follow and forwards it unchanged; unlike auto, this mode never activates an idle agent session. The browser allocates the voice conversation ID before WebRTC creates its server row, so an initial stream request can return 404. That closes EventSource permanently: connectPhoneEventStream explicitly recreates closed sources with jittered exponential backoff, capped at 15 seconds. It leaves a source in CONNECTING to the browser's native retry, preventing duplicate connections. Recreated sources carry last_event_id; the BFF forwards that cursor or the browser's Last-Event-ID header upstream. Closing the panel cancels retries, ignores late events, and aborts the upstream stream request. See the user guide and browser coverage. The live stream regression creates its own delayed conversation row and runtime-shaped Redis events to check the real BFF, authorization, normalization, and cursor handling without invoking a model or external tool.

usePhonePreviewRecorder crops the current tab to the selected capture target and mixes the local/remote audio tracks. Live microphone recordings apply Krisp noise cancellation to the caller branch before mixing; the remote AI branch connects directly to the mix. This is mandatory, with no user toggle. The separate recording graph never replaces or stops the live call's tracks. It follows track replacement and disconnects ended sources to avoid mixing stale audio twice. Starting a recording checks that the phone and AI Activity fit within the visible tab; offscreen tab content cannot be captured. Its canvas keeps fixed, even dimensions within 1920 × 1080 (or the portrait equivalent), fitting subsequent layout changes without resetting the encoder. Automatic stop waits for a call to have become active before treating the idle state as a hangup, so Test & Record can finish obtaining screen permission before starting the call.

After screen selection, Preparing recording... remains visible while the Krisp SDK and noise model initialize, with a 30-second deadline. Recording and Test & Record's call start wait for a ready, enabled filter. Stop also cancels preparation. A model/browser failure reports an error and creates no raw-audio recording; a running filter error stops recording and leaves the already-recorded portion available. It does not end the call.

lib/preview-recording-audio.ts loads the existing licensed Krisp RTC 2.3.9 SDK from the app's own /krisp/2.3.9/ assets only when recording starts. Processing runs in the browser at 48 kHz; SharedArrayBuffer, BVC, and accent conversion are disabled. Only krispsdk.mjs and the outbound narrowband/pro NC models are packaged. npm run prepare:recording-audio uses the shared prepare_krisp_rtc_assets.py --profile preview-recording extractor; npm dev/build hooks and the Overmind launcher prepare local assets. Local preparation needs Python 3 and hydrated Git LFS archives. Docker builds extract in a separate stage and copy the result into public/krisp; generated files stay out of Git and Docker's source context. AI Manager CI fetches only its two required LFS archives and tracks both archives plus the extractor as build inputs.

Download MP4 uses native H.264/AAC recording where supported. WebM recordings are uploaded as a raw body to POST /api/preview/recordings/mp4, protected by withAuth('AIManager:RunAgents'). The Node handler streams up to 100 MiB into a private temporary directory, checks the container signature, and invokes FFmpeg with fixed arguments, local-only protocols/demuxers, two worker threads, a 180-second conversion deadline, and two concurrent conversions per process. The per-user bucket allows five immediate requests and refills two per minute. Output is H.264/AAC, yuv420p, with fast-start metadata. Files are deleted on completion, failure, or cancellation; no recording is persisted to object storage. The original browser blob remains available for retry after conversion errors. Production and devcontainer images install FFmpeg. AI Manager chart defaults set nginx ingress to a 100 MB body limit and 300-second timeouts; other reverse proxies must permit the desired upload size and request window. See conversion tests and browser recording tests, which inspect decoded pixels/codecs and actual exported PCM. The audio fixture runs the real Krisp model over speech plus fan/hiss, checks noise-only intervals, and measures a remote probe per channel to verify that AI-side audio bypasses cleanup. It also checks model failure/retry, cancellation, and runtime failure without stopping the live tracks.

AI Activity and the phone's Transcript view share reduceBrainEvent for transcript identity, revision merging and speech-time ordering. Audible start is preferred over event/arrival timestamps; a late caller caption is inserted before the agent's reply. A timing-only correction can reposition an unchanged caption, and older revisions cannot replace a newer final or refined transcript.

The editor body is a horizontal ResizablePanelGroup: inspector (left, conditional) → canvas → Preview (right, conditional). All three are real resizable panels — opening the inspector or the Preview shrinks the canvas rather than overlaying it, so the React Flow toolbar (Controls) and MiniMap stay visible and inside the visible area.

Each side panel's resize divider is a CollapsibleResizeHandle — the same component for every sidebar type — a draggable divider with a centered chevron pill that collapses the panel (drag the strip to resize; click the pill to slide it closed). When a panel is collapsed, the host renders a CollapsedPanelRail in its place: a thin labeled vertical tab beside the group (not over the canvas) that re-opens it. panelSide ("left" for the inspector, "right" for Preview) orients the chevrons and border; label / expandLabel / collapseLabel are passed per panel. Collapsing the inspector routes through handleCloseInspector (so it also deselects); collapsing Preview just flips testerOpen. (Full-screen preview is a separate overlay — testerFullscreenOpen — unaffected.)

Because the panels resize the canvas DOM, the canvas auto-recenters on any visible-area change. FlowEditorInner runs a recenter() on: - selection change (deferred one frame so the panel resize settles), and - a ResizeObserver on the canvas container — fires for panel open/close, Preview dock/undock, and window resize.

recenter keeps the selected node centered if there is one, else fitViews the whole pipeline into the visible width. The observer reacts to width changes only and is debounced (~180 ms), which is what keeps it from feeding back on the fitView animation (that animates the viewport transform, not the container width) — the loop the older "no ResizeObserver" comment warned about.

Closing the node inspector (the header X) fully deselects the node via the page's handleCloseInspector (clears selectedNodeId / selectedStageId / snapshot / subagent path), so the canvas node drops its selection ring.

The Preview is shared at full height with the full-screen NodeExpandedView — maximizing the editor must never hide testing. The docked panel uses testerPanel (with its own header); the overlay uses the header-less embeddedTesterPanel plus the hoisted PreviewHeader (see full-screen node editor). Both are produced by the same hoisted renderTesterPanel(fullscreen, hideHeader) helper.

Canvas View show/hide is view-only — never persist it to config

The agent editor's Canvas View toggles (showStructural / showSubAgents, owned by the agent page) and the related node-tab / expanded / picker state are UI-only. They must never flow into config or through handleConfigChange — otherwise collab/autosave would broadcast a teammate's view toggle and mutate the doc. Node hiding is applied in FlowEditor's derived render memos via React Flow's hidden flag, never in node state, so pipelineStructureSignature, the leaf-patch path, and the sync-back start/end guards stay intact.

They are persisted as a personal preference in localStorage (ai-manager:canvas-view:show-structural / …:show-subagents, via useLocalStorageState) so a user's view choice carries across sessions. That's per-browser and never leaves the client — distinct from config, so the collab invariant above is unaffected.

Two behaviors hang off these toggles:

  • Structural hidden = a "published shape only" view. Besides hiding the Start/End nodes, it suppresses the add "+" everywhere: placeholders render handles-only (hideAddButton) and edges drop their "+" (edge.data.simplified). Sub-agent adds are not affected — they live inside the node body, not on placeholders/edges — so you can still build orchestration in this mode.
  • Sub-Agents hidden collapses each orchestrator node's dashed sub-agent box to a single "N hidden · click to reveal" pill (PipelineNode); clicking it flips the toggle back on.

Junction diamonds + balanced auto-layout

GPT Live also uses a derived overlay: live-delegation-overlay.ts draws one backend card to the right of the pipeline and two delegation arrows beside each Live voice stage. The card opens the owning stage's backend inspector in both Responses and Client modes. Client mode names the card and inspector Reasoning. NodeBehaviorSummary renders the owner's subagent/tool/rule/guard counts on this backend card; Live voice cards show Speech Only in the shared NodeSummaryFooter instead. Ordinary pipeline nodes use the same behavior summary. It uses the standard LLM card accent and brain icon tile, with an Edit pencil and no Delete action. The outgoing and returning arrows are labelled Background Thinking and Results. The branch has 220px of horizontal clearance for its label. Selecting either voice or backend centers their combined bounds, preserving zoom, so the wider branch does not move its edit control behind the docked Preview tab. Both the Live voice and backend cards use the shared 250×176 size and port positions in live-node-geometry.ts. The backend stays at its owner's Y position; parallel owners whose backends would overlap reserve additional columns instead of vertical offsets. Their arrows use StraightEdge, keeping Background Thinking and Results horizontal, without audio-branch controls. Layout, pipeline serialization and undo snapshots exclude these elements. Prompt and provider edits still use the ordinary owner-stage update path. See GPT Live editor ownership. Keep PipelineNode's bottom main source handle first: legacy pipeline edges without an explicit sourceHandle must use it rather than a side delegation port.

The canvas auto-layout has two jobs: (1) keep the "+" add buttons and nodes evenly spaced and row-aligned across swimlanes, and (2) mark where branches diverge and reconnect so the eye (and the "+" math) has a clean, fixed convergence point instead of every branch terminal drawing its own edge into a downstream node.

Junction diamonds are a view-only overlay (JunctionNode.tsx). A small, non-interactive lane-accent diamond is rendered wherever:

  • a node has ≥2 outgoing edges (a fork — placed just below the parent's bottom handle),
  • a node has ≥2 incoming edges (a merge — placed just above the node's top handle), and
  • the Output node has ≥2 incoming edges (the output merge — the single point all branch/lane terminals converge into, shortly above Output).

They (and the edges that route through them) are produced by computeJunctions(nodes, baseEdges) and consumed by both the nodesWithCallbacks memo (the diamond render nodes) and the edgesWithCallbacks memo (edge hiding + connectors). All of it is appended to the render arrays only — never written to React state nodes/edges or to config. This is the same view-only discipline as Canvas View show/hide (above), and it matters because the config serializer (flowElementsToPipeline) and the edge-walkers hard-code "end-node" as a topology sentinel — promoting a junction into state would break the round-trip, pipelineStructureSignature, autosave, and collab. Because the canvas is controlled, appended nodes/edges reach the React Flow store via the prop's direct set (no onNodesChange/onEdgesChange), so state never sees them. useLayout filters type === "junction" out of its d3 hierarchy input and its setNodes write-back.

Edges route through the diamond. For a fork, computeJunctions (a) marks the real parent→child edges hidden: true in the display (the Canvas-View hidden pattern — the edges still exist in the store with their ids/data, so add/connect/delete handlers that match real ids are unaffected), and (b) appends overlay connector edges parent→diamond (one trunk segment) + diamond→each child (elbows out). A merge is the mirror: each parent→diamond + one diamond→anchor trunk segment (with targetHandle: "top" when the anchor is the Output node). Connector edges carry isOverlay and the junction's accent stroke.

The "above-the-node +" lives on the fork's diamond→child connectors. Hiding the real parent→child edges would otherwise drop the add button that sat between the fork and each branch node. So each diamond→child connector (a) omits hideAddButton and (b) carries insertSourceId/insertTargetId = the REAL (parent, child) pair, and edgesWithCallbacks injects the normal edge callbacks onto it. EdgeWithButtons reads those overrides so its "+" / Add-to-flow inserts between the real nodes (not the junction), and resolves source/target nodes from them for naming + branch options. All other connectors (parent→diamond, every merge connector) carry no callbacks, so they render button-free. Interactivity verified end-to-end: both the above-node "+" (on the connector) and the trailing "+" (on the placeholder) open the Add-to-flow panel after rerouting.

For an elbowed branch (a child offset from the diamond's column), the connector sets elbowAtSource so the horizontal jog happens at the top (right at the diamond) and the edge then drops straight down into the node — EdgeWithButtons forces the smoothstep centerY near the source and parks the "+" on that vertical segment (at the child's X), so every branch's add button lines up directly above its node at the same row. The straight-down branch (child in the diamond's own column) needs no elbow.

Nodes are not draggable. The auto-layout is authoritative (nodesDraggable={false} in FlowEditor), and useLayout no longer honors a persisted service.position — older drag coordinates are ignored so the layout can produce consistent rows. (The position field may still exist as harmless leftover config; nothing reads it for layout.)

Balanced spacing lives in useLayout.ts:

  • Cross-swimlane row alignment. Secondary-lane branch stages align to the main trunk's per-index depths. The same index alignment also applies to same-lane parallel branch columns (branchX ≠ main, no laneId) — so equal-index stages share a row across the trunk and every parallel column (and their trailing "+" buttons land on the same row) even when branches have unequal length or a tall (subagent, 320-wide) node. Stages deeper than the trunk keep their d3 Y and extend downward.
  • Balanced trailing "+" (secondary lanes). A lane column with fewer stages than the deepest has spare vertical space below its last stage; the layout centers its trailing "+" in the leftover space [lastStageBottom, deepestColumnBottom] so it sits mid-gap and re-centers as a sibling grows.
  • Unified terminal rhythm (parallel-at-start). For parallel-at-start branches (incl. branch 0 in the MAIN_PIPELINE_X column) the whole bottom region shares one standard spacing: the pre-output merge diamond sits one EDGE_GAP below the deepest branch's last real node (parallelMergeTop), the end node one JUNCTION_TERMINAL_DROP below the merge, and every branch's trailing "+" is centered between its last node's bottom and parallelMergeTop. For the deepest branch that's the standard gap with a centered "+"; for a shorter branch the edge to the merge is longer and the "+" rides its vertical midpoint — the "standard length, + centered, except when unbalanced → + at the midpoint of the longer edge" rule. JUNCTION_TERMINAL_DROP (useLayout) must equal JUNCTION_MERGE_OFFSET (FlowEditor) so the diamond lands exactly on parallelMergeTop. Engages only when ≥2 start-fanout branch columns exist; linear pipelines keep the d3 stacking.

A placeholder is never a junction anchor. computeJunctions skips merge anchors whose target is a placeholder (a "+" tail is not a convergence point) and dedupes edges sharing the same (source,target) before counting. Without this, the sync-back effect's transient duplicate edge into a branch's trailing placeholder would spawn a spurious merge diamond and swallow that branch's "+".

Parallel-at-start branch 0 is a branch, not the main pipeline. In a parallel-at-start (pipeline = [[[branch0…],[branch1…]]]), branch 0 renders in the centered MAIN_PIPELINE_X column, so the older sync-back logic classified its nodes as "main pipeline" — which uses inter-node "+" buttons and never gets a trailing placeholder. When such a branch grew past one stage, the new node lost its "+" (and the sync-back removal rule deleted the freshly-created placeholder). The sync-back effect now detects parallel-at-start by reachability: it collects every node reachable from a start-node fan-out child (≥2 real children ⇒ parallel-at-start) into parallelBranchNodeIds, and isParallelBranchNode() gates all three placeholder paths — creation (nodesNeedingPlaceholders), edge wiring (isParentMainPipeline), and removal (preserve the placeholder of a parallel branch terminal). Reachability (not a branch-* id check) is required because a stage added later is node-<uuid>, not branch-i-j.

Edge color = propagated frame flow, not the source node's outputs

An edge's color answers "what's flowing along this edge" — which is not the same as "what the immediate source node declares as outputs". Frames flow through a pipeline. A node only removes the frame types it consumes (its service definition's inputs) and adds the ones it produces (its enabled outputs — disabledBy/enabledBy are evaluated against config, e.g. an S2S with output_audio: false produces no audio); everything else passes through untouched:

flowOut(node) = ( flowIn(node) \ consumed(node) ) ∪ produced(node)

This is computed by computeFrameFlow(nodes, edges, pipelineType) in FlowEditor.tsx — a memoized DAG walk from the start node (which emits ["audio"], or ["text"] for text_to_text) that returns a per-edge Map<edgeId, FrameType[]>. baseEdgesWithCallbacks stamps the result onto each edge's data.flowFrameTypes, and EdgeWithButtons makes that the basis for displayFrameTypes (hence the stroke color + frame badges), falling back to its old local source-node computation (frameTypes) only when propagation isn't wired (e.g. the workflow editor). The user's saved per-edge override (data.frameTypes, the one flowElementsToPipeline reads back into output_frame_types) still narrows the display when set, intersected with the flow so a now-disabled output drops even if it was previously saved.

Why this matters (the behaviors it guarantees):

  • Toggling "Enable Voice" off on an S2S node drops the green at that node's output — it consumes audio but no longer produces it. Correct and immediate.
  • A non-speech / unconfigured node placed after a voice-on S2S does not kill the green. It consumes nothing audio-related (or has no definition yet), so audio passes straight through and the green persists to the end. A node never stops a frame type it doesn't consume — no visual disruption from adding an unrelated/blank block.
  • A TTS after a voice-off S2S turns the edge green again at the TTS output (TTS consumes text, produces audio).
  • "+" placeholders are pure passthrough (no inputs/outputs), so the edge color before and after a "+" is identical — adding/removing a "+" never changes the color flowing through.

The "+" placeholder is passthrough — color must not change across it. A trailing "+" (and any between-node "+") is a placeholder node with no inputs/outputs, so flowOut(placeholder) = flowIn. The placeholder→end (output) edge therefore follows propagation just like every other edge — it is not opted out. (A previous version baked a pipelineType heuristic — ['audio','transcription'] dual-color, etc. — into that edge's data.frameTypes; that made a multi-color S2S→placeholder edge collapse to a single color after the "+", and worse, it polluted the user-override channel. The builder no longer sets frameTypes on the placeholder→end edge at all.) The curated "what the agent emits" summary still lives in EndNode's output rows (built in pipelineToFlowElements), which is the right place for an output-modality view — not the edge color.

Junction connectors inherit the flow — diamonds don't collapse multi-color. A fork/merge/output diamond hides the real edges it intercepts and draws connector edges in their place (see junction section above). Those connectors are single-style.stroke (the diamond's accent), so without help a multi-color edge would lose its colors crossing the diamond — which is exactly what made agent 3032's two-S2S→Output edge read solid green after the last "+". computeJunctions therefore copies each intercepted real edge's flowFrameTypes onto its replacement connector: a fork/merge branch segment adopts that branch's flow (index-aligned, since interceptEdgeIds[i] ↔ branchEndpointIds[i]), and the trunk segment (anchor↔diamond) carries the union across branches. EdgeWithButtons then renders the connector multi-color just like a real edge. If the intercepted edge is lane-scoped, the replacement connector also carries that lane's frameTypes so a transcript-only L-shaped branch leg remains purple-only instead of widening to the trunk's audio/text/transcript union. (Connectors carry hideAddButton, except the fork's diamond→child segment which keeps its "+".)

View-only, same discipline as the junction overlay. flowFrameTypes is derived each render and never written to React state or config; the serializer and pipelineStructureSignature only see the real graph + the user's explicit frameTypes override. Only edges with genuinely independent color semantics opt out of the override: lane and injection edges (a transcript lane stays purple regardless of trunk flow). The junction diamonds' accentOf prefers flowFrameTypes over the saved frameTypes, so fork/merge accents match the trunk.

Sub-agents on the canvas — dashed subtree box

Sub-agents render inside the parent pipeline node's DOM, not as separate React Flow nodes (keeps pipelineStructureSignature / sync-back intact — see the invariant above). When a stage has config.subagents.enabled, PipelineNode grows a dashed-bordered indigo subtree box under the node content that auto-grows as sub-agents are added (SubagentInlineList). Each sub-agent is a clickable card (SubagentInline) opening the inspector scoped to its path. The stage editor's Add Agent and edit controls use the same page callbacks as these canvas cards, so both entry points land in the shared picker/sidebar flow. The box's + Add subagent and a card's + Add nested subagent both route through onAddSubagent / onAddNestedSubagent → the page's handleEditSubagent(null, parentPath), which opens the Link Sub-Agent picker (SubagentLinkPicker) in the inspector slot. Session-switch sub-agent forms derive transfer options by walking the full root hierarchy. Every named destination is checked by default; unchecking one writes an explicit disabled_downstream_agents opt-out and also materializes downstream_agents for compatibility. Disabled names remain visible until re-enabled so authors can clear stale opt-outs after a rename or deletion. Nested levels (sub-sub-agents) get their own dashed frame tinted by a per-depth accent (SubagentInline DEPTH_ACCENTS) so each layer reads as distinct. Nesting is capped at one level (depth 0 owns children; the orchestrator rejects deeper configs).

lib/

Pure logic, no React. Where most business code lives.

Module group Purpose
lib/pipeline-types.ts Canonical pipeline config TypeScript types
lib/workflow-types.ts Workflow / trigger types
lib/service-definitions/ Per-service form schemas (drives inspector UI)
lib/provider-metadata.ts @deprecated but still drives provider/service dropdowns
lib/db.ts Postgres client; sets app.current_tenant per request
lib/db-agents.ts, lib/db-workflows.ts, lib/db-integrations.ts, etc. Per-resource CRUD
lib/crypto.ts AES-256-GCM for integration secrets (secret-storage.md)
lib/permissions.ts RBAC permission constants, role grants, and legacy permission normalization
lib/schema-validation.ts Zod schemas mirroring pipeline-types.ts
lib/integrations/ Per-integration runtime helpers (genesys, salesforce, webex, etc.)
lib/subagent-paths.ts Subagent path helpers (node-N/J/K…): resolve/replace/append/remove at a path, plus flattenNavigableNodes() for the full-screen node selector (stages + nested subagents, hierarchy-ordered)
lib/stage-add-options.ts getAllowedServiceTypes(pipelineType) (shared with ServiceConfigForm) + getStageTypeOptions() — the type-preselected entries the "Add to flow" picker shows

hooks/

Custom React hooks. Lifecycle:

  • use-collab-presence.ts — subscribes to collab-gateway WebSocket presence.
  • use-flow-editor-state.ts — manages FlowEditor canvas state.
  • use-autosave.ts — draft autosave with debounce and error reporting.
  • use-permissions.ts — exposes the user's permission set to UI.

Agent-editor hooks (all extracted from the editor page — see above):

  • use-agent-versioning.ts — version state, the agent load, publish, version select.
  • use-active-pipeline.ts — which pipeline the editor is showing and what's selected in it.
  • use-ccaas-routing.ts — CCaaS availability list + the publish patch.
  • use-agent-tester-embeds.ts — the three tester iframe URLs + their embed tokens.

contexts/

React contexts for cross-tree state:

  • auth-context.tsx — session user, tenant, permissions.
  • theme-context.tsx — dark/light mode toggle.

scripts/

One-shot scripts not part of runtime:

  • init-db.ts — bootstraps a fresh DB with seed data.
  • bench-pipeline.ts — synthetic load against ai-orchestrator.

These run with tsx scripts/<name>.ts directly; they are not imported by the app.

messages/

messages/en.json, messages/es.json, etc. Loaded by next-intl. When you add a UI string, add the key here, not inline.

tests/

Jest test files mirroring source structure. apps/ai-manager/tests/components/FlowEditor.test.tsx tests the FlowEditor component, etc.

Where to add a new feature

Adding... Files to touch
A new pipeline stage type lib/service-definitions/services/<new>.ts, lib/service-definitions/index.ts, lib/provider-metadata.ts, plus orchestrator-side files (see universal-pipeline-builder.md)
A new workflow node type shared/workflow-node-types/, app/(main)/workflows/[id]/... inspector, plus async-agent-side files (see langgraph-construction.md)
A new admin page app/(main)/<route>/page.tsx, components in components/<feature>/, types in lib/types.ts
A new API endpoint app/api/<resource>/route.ts, DB helper in lib/db-<resource>.ts, permission check at top of route
A new integration lib/integrations/<name>/, schema in lib/integration-schemas.ts, encrypted-config encryption via lib/crypto.ts

Conventions

  • TypeScript everywhere. Avoid any; if a boundary still needs it, keep the ESLint warning visible or add a narrow disable comment explaining the boundary.
  • npm run lint is a zero-error gate for hand-written source. Generated bundles and build artifacts should be ignored in eslint.config.mjs, not fixed by editing generated output.
  • Slugs are internal — never a user-facing input or column. Every create flow derives the slug from the human-facing name (a derived slug that would start with a non-letter gets an x- prefix so the route patterns still accept it), duplicate errors speak in names, and the one exception is /admin/tenants (platform-admin-only; the tenant slug is the SSO/Authentik operational key). Swept 2026-08-28 across Capabilities, Multimodal catalogs/endpoints, Catalog detail tables, Skills, Assist Profiles, Tool Packs, and Knowledge tool forms.
  • Server-side database access only. The browser never talks to Postgres directly.
  • Permissions checked at every route boundary. There is no "trusted internal" path.
  • Drafts autosave only when their JSON content or editable identity changes; published versions do not. Clean tab hide/unload does not request a flush. DraftSave in collab-gateway compares the durable projection, drains edits arriving during a write and acknowledges no-ops without writes/history. Agent SQL uses IS DISTINCT FROM to avoid rewriting unchanged rows; audit snapshots include name/description. The autosave indicator waits for initial hydration, checks new-room connection state for up to 10 seconds and polls startup at one-second intervals. A previously connected room losing its connection, persistent startup failure, or an authentication/transport error still shows a save failure. State resets per room.
  • A Save button publishes; an "Auto-saved" indicator means the draft is durable.
  • Editor sync-back must be value-stable under pure view interactions. Pan, zoom, node selection, and opening an inspector must never produce a config change (and therefore never a local-origin Y.Doc write — that's what flips the autosave indicator to "Unsaved changes"). Two guards enforce this: PipelineYjsBridge's outbound path skips the transact when the doc already serializes to the same canonical JSON (and in dev logs which top-level keys differed when it doesn't skip), and ServiceConfigForm suppresses its form.watch → onChange emission for programmatic setValue calls (the integration auto-default on inspector open). If the dev console shows [PipelineYjsBridge] outbound write; changed keys: [...] after a pure view interaction, whatever produced those keys is the bug.

See also