The Composer is the make-or-break feature for Maestro. If operators can read their score graphs, drop new skills onto the canvas, draw edges between port handles, edit per-node config, and save without confusion, Maestro becomes order-of-magnitude more powerful than a configure-and-run product. If the editor is rough, the data model and orchestrator never get exercised.
We shipped it across four sub-phases (5a → 5d), each with its own tag. The arc:
- 5a — schema migration + API CRUD + bulk-save endpoint
- 5b — read-only canvas, edit-mode toggle, drag-drop, edge drawing
- 5c — per-node config editor, agent picker, structured map_over editor
- 5d — Agents catalog UI
This post is the engineering story of how the canvas itself works. The data-model story is in the agents promotion post. The cost story is in the decomposing-cold-leads post.
Why React Flow
Three reasons we picked it over hand-rolling SVG:
- The hard parts are already solved. Pan, zoom, drag, multi-select, keyboard delete, viewport projection — all of it works the day you install it. We wanted to spend our budget on the editor’s domain logic (validation, port handles, save semantics), not on inventing canvas primitives.
- The customisation surface is right. React Flow lets you bring your own node component (we have a
NodeCardrendering label + skill.operation + the loop-iter badge) and your own edge styles. The framework handles selection state and drag interactions; your component handles the visuals. - It’s MIT-licensed. No “pro” gate on the features we needed. (We do hide their attribution badge — the framework explicitly allows this.)
The full React Flow docs are good. We didn’t have to reinvent much.
Layout: dagre with a twist
Dagre is the natural choice for auto-laying-out a React Flow graph. Two reasons:
- It produces stable layouts (deterministic, given a node + edge set).
- It handles right-angle (smoothstep) edge routing well.
But dagre with rankdir: 'TB' (top-to-bottom) on Maestro’s hero scores produced a painful result: the cold-leads score has a top-level main-flow plus a body subgraph (the map_over’s iteration steps), and dagre flattens both into one giant TB column. Reading the resulting layout takes seconds — your eyes have to walk a long vertical chain.
The shape that reads well, by hand: top-level nodes flow left-to-right (find_leads → shortlist → per-lead loop → notify_summary), and the loop body flows top-to-bottom below the main row, centered roughly under its map_over parent.
We compute this by running dagre twice:
function layoutNodes(nodes, edges, preferStoredPositions = false) {
// Identify body subgraphs: nodes referenced by some map_over's
// config.body_node_ids.
const bodiesByMapOver = new Map<string, Set<string>>();
const allBodyIds = new Set<string>();
for (const n of nodes) {
if (n.kind !== 'control') continue;
const ids = n.config?.body_node_ids;
if (!Array.isArray(ids)) continue;
// ...
}
// Top-level nodes: anything not in any body.
const mainNodes = nodes.filter((n) => !allBodyIds.has(n.id));
const mainEdges = edges.filter(
(e) => !allBodyIds.has(e.fromNodeId) && !allBodyIds.has(e.toNodeId),
);
// Layout 1: main flow LR.
const mainPositions = layoutSubgraph(mainNodes, mainEdges, 'LR', 80, 110);
// Layout 2: each body TB, offset under its map_over parent.
for (const [mapOverId, bodyIds] of bodiesByMapOver) {
const bodyNodes = nodes.filter((n) => bodyIds.has(n.id));
const bodyEdges = edges.filter(
(e) => bodyIds.has(e.fromNodeId) && bodyIds.has(e.toNodeId),
);
const bodyPositions = layoutSubgraph(bodyNodes, bodyEdges, 'TB', 120, 110);
// Center under parent, offset below the main flow's bottom edge.
// ...
}
return out;
}
The result reads like the way you’d diagram it on paper: main flow across the top, loop bodies hanging below their parents. Edges connecting main → body render as vertical lines from the parent down into the body subgraph; React Flow’s smoothstep routing handles the visual transition.
We added a “Reset Layout” button in edit mode that re-runs dagre over the live node + edge set. Useful when an operator’s edited canvas has drifted into a tangle.
Multi-port handles
Some nodes branch on output. pipeline.find_contact returns either with contact populated (the lead is already in the pipeline — short-circuit) or with contact: null (continue with enrichment). The Composer renders this as two source handles on the bottom of the node, one labeled exists and one not_found. Edges from the exists handle route to the short-circuit branch; edges from not_found go to the enrichment chain.
In the data model, this is just score_nodes.config.ports: string[]. In the React Flow layer, it means rendering N <Handle> components per node:
{ports.map((port, i) => {
const isSinglePort = ports.length === 1;
const isBranch = port !== 'success';
const isImplicitDefault = isSinglePort && port === 'success';
return (
<Handle
type="source"
position={Position.Bottom}
// CRITICAL: only set `id` on multi-port nodes (more on this below).
{...(!isImplicitDefault && { id: port })}
style={{
background: isBranch ? 'var(--accent-brass)' : 'var(--ink-3)',
width: 8,
height: 8,
...(isSinglePort
? {}
: { left: `${((i + 1) * 100) / (ports.length + 1)}%` }),
}}
/>
);
})}
Branch ports get the brass accent. Multi-port nodes spread their handles evenly along the bottom edge with a left:% override; React Flow’s default transform: translate(-50%, 50%) keeps the half-X-centering and half-Y-below-edge math working.
The bug that ate two evenings
The first version of multi-port handles set id={port} on every Handle, including single-port nodes (where port === 'success'). Symmetrically, the edge-build code set sourceHandle: e.fromPort on every edge.
Internally consistent: id=“success” on the Handle, sourceHandle=“success” on the edge. Both strings the same. Should match.
It didn’t. After the v0.1.39 deploy, the read-only canvas rendered every node correctly — small dots above and below for the handles — but zero edges drew between them. Every connection silently dropped.
The cause turned out to be a subtle React Flow matching rule: for an edge to render, the source-handle id and the edge’s sourceHandle must both be set or both be omitted. Setting both to "success" doesn’t count as a match if the parent node only has one handle. React Flow’s internal logic treats single-handle nodes as “the handle is the implicit default” — and the implicit default is identified by the absence of an id, not by a string id of any value.
Two evenings of staring at handle IDs, three rounds of “but they’re the same string!”, and one unhelpful trip into React Flow’s source. Fix:
const isImplicitDefault = isSinglePort && port === 'success';
<Handle
...
{...(!isImplicitDefault && { id: port })} // omit id for single-port success
/>
And symmetrically in the edge mapping:
const explicitFromPort = e.fromPort !== 'success';
return {
...e,
...(explicitFromPort && { sourceHandle: e.fromPort }), // omit when default
...
};
Both ends now match the rule: if the port is the implicit default, no id, no sourceHandle. If the port is named (a branch port like exists), both get set explicitly.
The lesson: framework-imposed contracts on adjacent fields that “look like a match because they’re the same string” can fail silently. When debugging React Flow rendering issues, check the framework’s matching logic, not just the values.
The bulk-save endpoint
The editor’s primary write path is PUT /api/composer/scores/:id/save, taking a single bundle:
{
expectedVersion: number,
score?: { name?, description?, pipelineKind? },
nodes: {
create: NodeCreate[],
update: { id: string, ...partial }[],
deleteIds: string[],
},
edges: {
create: EdgeCreate[],
update: { id: string, ...partial }[],
deleteIds: string[],
},
}
The endpoint:
- Loads the current state of nodes + edges from the DB.
- Projects the post-save state by applying the bundle’s diff to the current state.
- Validates the projected state (cycle detection, body-edge constraints).
- If validation fails, returns 400 with a structured
{kind, message, details}error — nothing is written. - If validation passes, applies the bundle in one transaction and bumps
scores.versionby exactly one. - Returns the post-save graph for the client to render against.
Three consequences worth noting:
One transaction, one version bump. Whether the operator drags one node or makes 50 edits, the save is a single commit and scores.version increments by one. The version log stays readable — one entry per Save click, not one entry per atomic op.
Validation is server-side. The client does some inline validation (rejecting self-loops at edge-draw time), but cycle detection requires the full post-save graph and is expensive. Pushing it to the server keeps the client simple and lets the server be authoritative on graph invariants.
Validation runs against the projection, not the live state. This matters for cases like “delete node X and add edge Y in the same save.” Validating the live state would reject the new edge (X still exists with its old connections); validating the projection sees X gone and Y present, evaluates the cycle/body rules against that, and accepts.
What edit mode adds
The editor toggle is a URL search param (?edit=1), so the operator can deep-link to the editor and refreshing preserves mode. When editMode === true:
- React Flow’s
nodesDraggable,nodesConnectable, anddeleteKeyCodeflip on. - A Skill Palette sidebar slides in on the left, listing every installed skill expandable to its operations. Each operation is HTML5-draggable; dropping it on the canvas creates a deterministic node at the cursor position.
- The palette header has ”+ LLM” and ”+ Control” stub buttons that drop pre-configured stub nodes at the canvas center (operator fills the details in the side panel).
- The side panel becomes editable — instead of read-only
NodeDetailandEdgeDetail, it rendersNodeEditorwith form inputs for label + per-node config. - A header strip shows dirty state (”· unsaved changes”), a Discard button, a Save button.
The editor’s node-config editor leans on a shared <SchemaForm> component lifted out of the per-skill Test Operation modal — the form-rendering logic that takes a JSON Schema and produces a React form (with text/number/boolean/array fallbacks plus a JSON textarea for unknown types) was already battle-tested in one place. Lifting it into a shared component meant we got the same UX for free in the editor.
Things we deferred
A few things that didn’t make v0.2.0 and probably won’t until real demand surfaces:
- Run-replay overlays — showing a recently-completed run’s per-node outputs as you hover the canvas. The data is in
score_run_nodes; the UI isn’t yet. - Edge transform editor — currently you edit the rename map as raw JSON. A visual field-mapping UI is a clear UX upgrade but most edges don’t need transforms.
- Visual sub-flow containers — body subgraphs are visually grouped today via dashed-left-border styling on body nodes plus the dagre layout placing them below the parent. React Flow supports actual parent-child node nesting; we deferred since the current visual story is legible.
- Branch / gate / parallel control subkinds — schema reserves them; only
map_overis implemented. - Multi-cursor / collaborative editing — single-operator only in v1. Multi-tenant cloud will need this.
The general pattern: ship the editor that handles the lab box’s actual scores, watch what real operators ask for, defer features until the demand is real. Half of the polish ideas we wrote down at planning time turned out to not matter once the editor actually shipped.
What we’d do again
Three things hold up after the dust settled:
Sub-phase ship cadence. Each of 5a / 5b / 5c / 5d ended with a tagged release that ran on the lab box. When 5b’s edge-rendering regression surfaced after the deploy, the diagnosis was bounded — only what changed between v0.1.38 and v0.1.39 could be the cause. Mega-PRs would have made the bug hunt much wider.
Validation on the projected state. Easy to get wrong (validate live state, reject combined deletes-and-adds). Worth thinking about up-front.
SchemaForm extraction before the per-node editor. Lifting the form-rendering logic out of TestOperationModal into a standalone component cost a half-day before we built NodeEditor. Without it, two schema-driven form surfaces would have drifted in subtle ways forever. A small refactor at the right moment saves a lot of paper cuts later.
The Composer’s the foundation everything from here down builds on. Phase 6’s workspace-skill authoring (in-browser Python skills) sits on top of the same canvas + side-panel + save-bundle pattern. Worth the upfront effort.