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.
Navigation & information architecture¶
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/integrationsand requiresAIManager: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 thinredirect()pages (Tools reusescomponents/ToolsTable.tsx). The legacy?tab=agent-templatesdeep link aliases to?tab=templates, and?tab=handoff(the former "Handoff" tab) aliases to?tab=external-consult. - Multimodal (
components/tools-templates/MultimodalPanel.tsx, gated onManageTools+ thea2uifeature 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/catalogsand/settings/multimodal-configsnow redirect here (preserving the old?tab=endpoints|componentsdeep link →&mtab=). - External Consult (
components/tools-templates/ExternalConsultPanel.tsx, gated onManageTools) 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 sharedExternalConsultForm) + the tenant policy modal, same/api/warm-transfer-profilesand/api/tenants/{id}/transfer-policyback 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 (
agentsrows withis_template=TRUE) the New Agent Wizard clones. Edited in the standard agent editor.AgentTemplatesPanel.tsxadds New (viaPOST /api/templates) + Delete, gated onManageToolslike 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=falsefrom the list API) — editing those needs platform-admin. - Stage — reusable pipeline nodes (
pipeline_templateskind=stage). Edited in a modal that hosts the sameServiceConfigFormthe inspector uses (StageTemplateEditorDialog.tsx). May define subagents. - Subagent — reusable specialist subagents (
pipeline_templateskind=subagent). Edited in a modal hostingSubagentConfigFormwithdisableNested(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.tsxis the shared table). Authors seed new nodes from these via the canvas "+" picker (see below).
- Agent — full agent blueprints (
- Configuration uses
/settings/*subpages and starts at/settings/groups. The/settingsroot is Profile Settings and stays accessible to all authenticated users for read-only identity, role/permission display, and Personal Access Token creation./settings/api-tokensis now theManageConfigurationtenant-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 markedadminOnlyinsettings/layout.tsx, socanAccessSubTabrequires the tenantadminrole (or platform admin) on top ofManageConfiguration— 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.tsexportsGET/POST/PUT/DELETEhandlers. - Permission checks wrap every handler:
withAuth(...),withAnyAuth(...),withSessionAuth(...), orwithManagementAuth(...).npm run audit:routesfails the build on a handler that uses none of them without an allowlist entry — see route auth. - Database access goes through
lib/db-*.tsmodules; 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.typealready set, so the inspector lands on the right category. The list comes fromgetStageTypeOptions(pipelineType), filtered to the types valid for the agent's pipeline type (getAllowedServiceTypes, the same gatingServiceConfigForm'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'spipeline_type). Picking one seeds the new stage from the template'sServiceStageconfig. The seed clones the config but re-derivesidand dropsposition(useAddStage/useInsertNodeBetweentake an optionalseedStage), so thepipelineStructureSignatureinvariants 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 fullSubagentDefviaseedSubagentAtPath. - 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
NodeConnectionSelectormodal for placeholders. Text-to-text/chat agent Preview panels keep template variables below the chat iframe and do not auto-add the voice-onlyani/dnisphone 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 viaisSelected, driven byselectedNodeId), 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 bylaneId(lanes carry one, same-lane parallel branches don't), not by side. Same-lane branches stack atMAIN_PIPELINE_X + n·BRANCH_SPACING(branch 0 = the trunk); output lanes start further right at a dynamic secondary-lane X. The staticSECONDARY_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 thesecondaryStartXarg intogetLaneBranchX/getLaneBaseX/getLaneZoneCenterXby every consumer that has node context: the authoritativeuseLayout.ts, the add-lane hook (useAddLaneRouteinuseAddBranch.ts), load-timeflow-lane-conversion.ts, and theLaneBackground.tsxstripe — all must agree or the stripe/nodes/edges disagree. Symmetry rule: the same-lane branch adder (useAddBranch/parallel-at-start) counts columns with the samecountSameLaneRightBranchColumnshelper, which excludes lane nodes. Lane nodes carry abranchXtoo, so if the audio-branch counter included them, audio branches would skip columns past a lane (gaps) and, becauseuseLayoutrecomputes 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, gappedbranchX(e.g. 785 & 1555 with 1170 gone). Positions come from the storedbranchX, so the gap would persist AND a lane whose clearance is computed from the now-smaller count could land on a stranded survivor.useLayouttherefore re-derives each render viacompactRightBranchColumns(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 usesnextRightBranchX(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 onlaneBranchIndex, notbranchX, 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 atgetLaneBranchX(lane, index, …), the hole would persist as an empty column and a re-added branch (formerlymax+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:useAddLaneRouteallocatesnextLaneBranchIndex(existing)(the smallest free index, so a re-add fills the hole), anduseLayoutresolves every lane node through a per-lanecompactLaneBranchIndicesremap (dense rank) before computing X, so survivors close the gap each render. The lane placeholder id is deterministic (placeholder-lane-<lane>-b<index>), souseAddLaneRoutealso dedups by id before concat — a re-add right after a delete can't be silently dropped. (Config round-trips are already dense:extractOutputLanesdrops empty branches andgenerateLaneNodesre-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
isBranchis 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.connectToNoderemoves 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/edgedatapayloads and a churning identity re-renders the canvas.- Two render-phase ref writes:
useCanvasRecenter'sselectedNodeIdRefand the shell'sexternalOnRequestAddRef. 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
initialElementsmemo: the rule followsaddBranchinto asetTimeout-deferredopenBranchSidebarRef.currentand then cannot provepipelineToFlowElementsdoesn't invoke the callbacks during render (it stores them in nodedata). That one carries a scopedeslint-disable-next-linewith 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:
- Token highlighting (
lib/codemirror/prompt-tokens.ts) — colors{{variable}}/{{TOOL:…}}/{{SUBAGENT:…}}/{{COMPONENT:…}}(A2UI catalog components, teal) and drives the@/{{/\autocomplete.Decoration.mark()only. - WYSIWYG markdown preview (
lib/codemirror/markdown-preview.ts, propmarkdownPreviewonPromptEditor, 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 onselectionSet, not justdocChanged). Hidden markers areDecoration.replace({})and are also published asEditorView.atomicRangesso arrows skip them. Parser is a barenew 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, soonChangestill 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.
Dropdown sections + the {{COMPONENT:…}} token (multimodal UI)¶
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 adepth(0 = stage, 1+ = subagent nesting, rendered as indentation) and akind(stage|subagent) that routes a pick tohandleEditStageorhandleEditSubagent. Parallel/swimlane list-of-lists entries are skipped (no single stage to open). - Tabs — Properties / Behavior, hoisted to the page as
expandedTaband reset topropertieson each stage selection. The switcher is hidden for subagents (they use one scrollingSubagentConfigForm, not Properties / Behavior) and for stages with no behavior surface. - Subagents in full screen — selecting a subagent renders the same
SubagentConfigFormthe sidebar uses, chrome-less, via the page's sharedrenderSubagentInspector(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.TesterSidebarPanelwithhideHeader); its header is hoisted into the overlay's top bar as the right column via the exportedPreviewHeader(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.
PreviewRecordingControlsplaces status, Test & Record, Record, Stop and all download actions in aPreviewControlWidgetin the left column. The recorder and capture refs stay inPhoneCallTest; 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, nolaneId) — 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_Xcolumn) the whole bottom region shares one standard spacing: the pre-output merge diamond sits oneEDGE_GAPbelow the deepest branch's last real node (parallelMergeTop), the end node oneJUNCTION_TERMINAL_DROPbelow the merge, and every branch's trailing "+" is centered between its last node's bottom andparallelMergeTop. 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 equalJUNCTION_MERGE_OFFSET(FlowEditor) so the diamond lands exactly onparallelMergeTop. 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 againstai-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 lintis a zero-error gate for hand-written source. Generated bundles and build artifacts should be ignored ineslint.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.
DraftSavein collab-gateway compares the durable projection, drains edits arriving during a write and acknowledges no-ops without writes/history. Agent SQL usesIS DISTINCT FROMto 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
Savebutton 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), andServiceConfigFormsuppresses itsform.watch→onChangeemission for programmaticsetValuecalls (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.