Skip to main content
Babelfish is TAI42’s paid product family for teams that need agent work to run deterministically in production. It is built on the same open platform this site documents, and it reaches a running server the same way any other plugin does.
Babelfish is proprietary and licensed commercially — it is not part of the Apache-2.0 platform and is not published to PyPI or listed in the marketplace. Product details and pricing live at tai42.ai/babelfish.

What it adds

Babelfish Flows ships as one installable plugin carrying two halves:
  • A flow engine. Deterministic flows registered as ordinary tools through the public contract — run a flow, step it, read its state, and abort it. Because they are tools, everything the platform already does to a tool applies: access control, backends, schedules, and tracing.
  • A flow editor in the Studio. A front-end bundle the runtime serves to the Studio through its integrity-pinned import map, contributing a top-level Babelfish Flows page and its sidebar entry; the states a flow binds live on the platform States screen. On the canvas, nodes can be gathered into named, collapsible groups — a collapsed group renders as one card that hides its members and reroutes their boundary edges, and expanding it reveals them again. Groups persist with the flow and carry across the editable, run, read-only history, and reading views, showing an aggregated member status in Run mode; the collapse/expand toggle itself is a transient view preference and is not saved.
A saved flow is a versioned preset with the graph carried in the preset’s fixed arguments, so flows version, diff, and roll back through the versioning spine like everything else.

The explanation layer

A flow carries an explanation layer aimed at the person reading it, not just the engine running it. Nodes and flows take plain-language descriptions, the editor renders a generated legend that summarizes what a flow does and the pieces it uses, and a dedicated reading view presents the whole flow as an annotated walkthrough — a calm, non-editable mode for reviewing or explaining a flow without changing it.

Presets

Beyond the built-in steps, you can define your own palette nodes from saved flows: promote a flow into a reusable node, give it a category and an icon, and it appears in the editor’s palette alongside the defaults for anyone to drop into a new flow. Babelfish ships two ready-made interaction nodes to start from — send and ask — one full surface each, covering the common conversation moves out of the box. Send delivers one-way and never waits: a text message, display media (a list of {kind: image | link, url, caption?} items), tappable options, a pre-approved template, or a fillable form schema delivering a schema-rendered ask-less form — the outcome is sent or failed, and a form’s submission, if it comes, arrives as the person’s next message on the conversation route. Its face opens on a kind selector — message, media, options, template, or form — that guides the fields while the node stays one delivery whatever the face: kind is face-only and never delivered, every send carries a message or media, and one message carries one interactive surface (a template stands alone). Ask pauses for a person’s reply and always waits, re-asking until the answer is valid or the attempts run out; its format selects how the answer is collected — text (free text, with optional options as tap-to-send suggested replies), confirm (a yes/no — declining is an answer), select (one pick from options), form (an answer validated against schema), or external (an action completed on a surface reached through link, with the callback payload as the answer) — and the same format drives the face’s variant picker. A form ask also takes two per-send extras layered over the schema for ONE send — the same data and pages the platform’s ask surface carries: data.values prefills top-level properties (each shown filled in and validated), data.options supplies a per-send choice list that replaces a string property’s enum for this send, and pages splits the form into ordered titled steps (every property appearing exactly once; absent pages is one page). Both forward to the ask door only when set, validated against the form’s schema. Both nodes take optional display media beside their prompt, every optional field is inert when omitted, and field combinations richer than any single face stay expressible through each node’s full-surface Anything face. Addressing is pre-wired to the conversation. A freshly dropped send or ask node opens already addressed to the same channel and counterpart the flow’s conversation runs on — so one flow works across every channel and always reaches the person of the current conversation. The wiring is a visible, editable jq expression seeded into the node’s addressing slot (through the visual jq editor), never a hidden default: it reads the participant’s own {channel, sender} off the conversation-routed flow’s inputs, and the node’s own literal addressing merges under it. Off a conversation route those inputs are absent, and the expression is written to drop its empty values, so it contributes nothing — the node falls back to whatever you address it to by hand. A reply that rides the route itself needs no addressing at all. A conversation-routed flow also reads .flow_graph_kwargs.turn — the turn the inbound entered as, surfaced on every conversation turn as {id, inbound: {id, kind, source}}. turn.id is the turn’s stable id (the answer record’s id); turn.inbound describes the entry — inbound.kind is "message" or "event", inbound.id is the channel provider message id, the event id, or the api record id, and inbound.source is the channel name, event:<kind>, or api. An event turn additionally carries .flow_graph_kwargs.event ({id, kind, payload}) with .flow_graph_kwargs.message and .sender both null. These arrive under flow_graph_kwargs because the conversation route’s default no-payload_expr kwargs are {message, sender, turn} (plus event on an event turn); a route with its own payload_expr reads .turn and .event on the payload and forwards what it wants. A conversation-target flow preset must not declare additionalProperties: false without admitting turn (and event) in its input schema, or the routing that validates the kwargs against the preset’s input schema refuses them. A preset is a saved flow published to the palette, and it is managed as a node, not as a flow. The palette’s Presets section gives each card an edit control that opens its flow editor and a delete control that raises a guarded confirm, and a + New preset action starts a fresh one in place. Deletion routes through ordinary preset deletion, so it unregisters the tool; the confirm states the blast radius plainly — the node leaves the palette, and any flow that already embeds it keeps a now-dangling reference to it. A preset lives in the palette only: the flows list shows flows, never presets — nor a category folder emptied by them — so a palette-managed node never surfaces among your standalone flows.

Looping over a list

The map node is a first-class “friendly foreach”: it runs one tool over a list. Its face is a list input (a jq expression producing the items), the mapped tool, per-item keyword arguments (jq expressions that may read the current item and its zero-based index through .iterate.item and .iterate.iteration), and the producer nodes whose outputs those expressions may read. It runs either sequential or parallel, with an optional concurrency cap on the parallel mode, and an optional collect expression that shapes the mapped results — left unset, downstream reads the raw per-item output list directly. A map compiles down to the engine’s ordinary loop primitive: on save it becomes one loop node plus one body, and on load that marked cluster folds back into the single map node, so you never touch the mechanism. A loop authored by hand carries no such marker, is never folded into a map, and stays a visible Loop. Because a map is a loop on the wire, its entry is a barrier: the map face carries the same dispatch controls a hand-authored loop does — the wait mode and the give-up pair, and the opt-in state refresh, which fires once at loop entry so every iteration inherits the same fresh view. Broadcast is the map-plus-send recipe for fanning one message out to N recipients: make the mapped tool the send node, set input to the recipients list, and read the current recipient from .iterate.item in the per-item arguments. The per-item output list is the delivery transcript — one slot per recipient, in order — and because send recovers a failed delivery into a failed outcome in-body, a partial failure simply appears as failed slots in that transcript rather than aborting the whole map. Scale a large list with the parallel mode and a concurrency cap, and shape a sent/failed summary with collect.

Inputs and results

Each flow declares its own inputs and results — the values it expects when it starts and the values it hands back when it finishes — so a flow presents a clear contract to whatever calls it and the editor can prompt for the right inputs when you run one. Because a flow is a tool, a parent flow can invoke another flow as a subflow through that same contract. A subflow also joins the dispatching run’s trace rather than minting a separate one, and so does an agent a node drives — see one run, one trace. The join survives parking, including a completing child cascading into its parked parent. Every jq input also carries .run — the executor’s identity for the run the expression evaluates in — as {id, epoch}. .run.id is the run’s session id (the run registry key), so a flow-as-tool subflow reads a distinct .run.id from its parent — the sub-run’s own id. .run.epoch is the state-mint epoch, null on a stateless run and until the run’s stateful mint has run. Outside a run — a validation-time compile — .run is null.
A subflow’s state writes are invisible to a later parent node unless that node opts in. A run’s .states view is its snapshot from run start plus its own writes: a subflow’s writes land on the shared record immediately, but a later parent node folds them in only if it sets refresh state. Without it the parent keeps reading its mint-time snapshot — a silent stale read.A subflow reads only the states it is bound to, fresh at its own start; an unbound .states.<name> reads as null — bind it, or the state_unbound lint flags it. The lint runs per subflow against that subflow’s own bindings — a state the parent binds never masks a read the subflow makes unbound — and its findings carry the subflow’s path into the Studio problems panel like the parent’s.

Portable flow files

A flow exports to a single .babelfish-flow.json file — the stored graph verbatim — and imports back the same way. When the flow carries state bindings, the export gains an optional state_bindings sidecar: each node’s binding travels alongside the graph with its state names, subject expressions, input injections and updates, and a stable schema fingerprint of the state’s declaration at export time. The graph itself is untouched and the sidecar is never persisted onto the saved preset, so a flow with no bindings exports byte-identically to the pre-sidecar format and an older file (no sidecar) imports exactly as before. On import, the editor offers to re-attach each carried binding against the target environment’s states. A state of that name whose schema fingerprint still matches re-attaches straight through; when the schema has changed, each bound path is re-checked and the binding re-attaches only if every path still resolves; a state that does not exist here, or a bound path that no longer resolves, is reported per binding and left unattached rather than silently dropped.

Structured agent output

An agent node can be constrained to return a validated object instead of free text. Its Settings carry a Structured output schema editor — a JSON Schema whose top-level title is required, since the engine uses that title to name the structured response and refuses a run it cannot produce against the schema. With a schema set, the node’s Output contract renders it live and reads Output (structured); cleared, the node returns text. The constraint is the node’s own — it shapes what the agent hands back, and downstream steps read the validated object. A finished run mirrors that declared shape onto the actual result: the value reads against the schema rather than as a raw tree, and the mirror never hides data that does not fit it. See the Flows editor for the rendered view and the guarantees it makes.

Node waits

A fan-in node runs once its input barrier releases, and the barrier has three mutually exclusive modes set on the node:
  • All inputs (the default). The node waits for every declared or edge-derived input to arrive, then runs.
  • Quorum. A minimum input count releases the node once any that many inputs have arrived; it is purely count-based and never looks at which inputs arrived.
  • Named release. A named-producer list releases the node once every named producer is present in the fan-in — a barrier keyed on specific producers rather than a count. Every other input is still consumed if it happened to arrive by release time, but is never waited on.
Named release and quorum are complementary and mutually exclusive: named answers “wait for these specific producers”, quorum answers “wait for any k of them”, and setting both is rejected at validation. Each named producer must be one of the node’s inputs after edge-merge, and its unwaited incoming edges render as dashed, read-only on the canvas so the optional producers are visible at a glance. A named producer counts as present once it produced output somewhere in the run — which is not the same as that output being delivered to your node as a trigger. If the producer sits behind a conditional whose branch routes elsewhere, its output lands in state but nothing re-invokes your node, the barrier is never re-evaluated, and the node simply does not run (the run goes quiescent and completes as success). When a behind-a-condition producer might route away from you, pair the named release with a wait timeout so a later trigger can force a partial run. The give-up pair. A wait carries an optional wait timeout and a raise-on-timeout flag, and the pair is the barrier’s safety valve on any fan-in node — and on an iterate (or its map face), whose loop entry is itself an all-or-nothing barrier. Left to force-run (the default), a trigger arriving after the deadline releases the barrier with whatever inputs are present and the node runs a partial; set raise-on-timeout and that same lapsed deadline instead fails the node loudly, attributed to the waiting node as an application error rather than infrastructure — so a flow-level on-error handler sees it exactly as any node failure and can route on it. The timeout is lazy either way: it can only fire when some trigger arrives after the deadline, so a node whose last trigger already fired keeps waiting forever. Because the wait’s start clock is checkpointed, size the timeout against wall-clock park durations — a long upstream park can make the first trigger after a resume find the deadline already elapsed and force the give-up immediately.

Handling a node’s error

A node — and a conditional branch, and the flow itself — can carry an on-error handler. When the node fails, the handler runs, its result becomes the failed node’s output, and the run continues from there rather than aborting. A handler’s jq reads the failure through .upstream_outputs, the same namespace predecessor outputs use, alongside the ordinary run channels: There are two ways to dispatch a handler, and they are exclusive — declaring both is refused rather than silently preferring one:
  • A wired handler node. Name another runnable in the flow. It is dispatched only through the error-recovery seam.
  • An inline handler tool. Attach a tool directly to On Error, with optional baseline keyword arguments that the handler’s expression overrides map onto. Any registered tool qualifies — including a flow-call preset, which makes the handler an arbitrary nested flow.
A handler may also be a bare expression that shapes a recovery value without dispatching anything. Baseline kwargs without a tool to receive them are refused, and a handler with no tool, node, or expression at all is refused as inert.
A recovery-only handler node — one no ordinary routing reaches — never runs as a live graph step, so an edge out of it can never fire and an inputs reference to it reads an output that is never written. Both leave a downstream node waiting on its barrier forever, so both are rejected as dead wires. Wire the handler into the flow’s ordinary routing if you need its output downstream.
Validation also warns when a wired handler has no incoming routing and the flow declares no entry points: the builder auto-promotes every unrouted node to a start entry, so that handler would run unconditionally at flow start as well as on error. Wire an incoming edge to it, or declare the flow’s entry points explicitly, to keep it handler-only. A flow-level handler is a different job. A handler attached to the flow itself — the Start-attached one — runs only on the terminal error path, and the run still fails. It decorates the failure: it can send a notification or shape the error that surfaces, but it cannot rescue the run. To recover from a failure and keep running, put the handler on the failing node instead. The editor names which of the two you are editing on the form itself, so the rescuing kind and the decorating kind are never confused. A finished run shows what the handler did. A node that failed and was rescued reads as recovered, and the handler that ran — whether the On Error attachment or the wired target — reads as having run through the recovery path rather than as an ordinary step. A node reached both ways keeps its own status and gains the handler mark.

Run condition

Any scheduled node — a runnable, an agent, a flow call, a map, and a router’s classifier — can carry an optional run condition: a jq predicate authored as an inline expression or a stored template with kwargs (the same field triple every jq site uses), answering one question — should this node run at all? Left empty, the node is unconditional and runs exactly as it did before the feature existed. The condition is evaluated at fire time — after the node’s input barrier releases and after its state refresh, before the node does any work of its own, including its own expression — over the same namespace that node’s expression reads. It is bool-strict, like a conditional’s condition: anything but true or false is an authoring defect, and a condition that raises fails the node down its normal error path. It is statically checked like every other jq slot, so an unresolvable upstream read or a schema-impossible comparison is a build error.
  • True runs the node normally.
  • False skips it: the node does not execute, its output is null, and its status reads skipped. The output key is still written, so every downstream input barrier still releases and the flow continues past the node — the gate stops the one node, not the path. To stop a whole path, use a conditional or a router instead.
A skip is not a failure, so a skipped node never reaches its own on-error handler — the skip happens before any execution that could fail — and it settles the node, so a re-drive of the run re-evaluates nothing. A node flagged to return a result still contributes its key when skipped, with the value null, so a return expression on a node you might skip must tolerate a null input. The gate belongs on a scheduled node. A node that runs only as an on-error handler is dispatched directly and never scheduled, so a condition on it would never be evaluated; the editor does not offer the field there — unless one is already stored, in which case it renders with that fact stated, so the dead value is visible and clearable rather than silently kept.
A node whose tool pauses and resumes re-runs its whole node function on resume, so its run condition is evaluated again over the resume-time view — one a peer may have moved during the pause. A condition that answered true on the first drive and false on the resume drive reports the node skipped with a null output even though the tool already ran. This is inherent to interrupt-resume re-execution: a condition over values that can change while the node waits carries the edge, one over the node’s settled fan-in does not.

Routing with an LLM

A router node classifies its input with an LLM and routes on the result — one card instead of an agent node wired into a hand-built conditional. You declare the kinds it may classify into, each with a name and a description, and the node generates the structured-output schema and appends the kinds to your prompt, so the classification and the routing can never drift apart. A kind may carry extra per-kind fields, merged into each extracted item beside its text. Three kinds of edge leave the card, each drawn distinctly:
  • Kind — fires when the classifier produced that kind. An optional jq guard is combined with it, so “this kind, but only when…” stays one edge.
  • Condition — the branch condition is an authored jq expression with full access to the run, .states included. It is a condition route, not a disguised kind, and the canvas says so.
  • Otherwise — the default branch, taken when no other branch matched.
Several branches may share one target, and a declared kind that is not wired is still classified — it stays in the generated schema and in the model’s output, it just routes nowhere. Renaming a kind re-keys its branch, its schema property, and its prompt bullet together, so every wired edge survives the rename untouched. On save the card lowers to ordinary engine parts — one agent call plus one hidden conditional — and folds back into the single card on load, the same way a map folds. A conditional you author by hand carries no such marker and stays a visible decision.

Every match or first match

A router’s routes fire under one of two routing disciplines, set on the card:
  • Every match (the default). Every route whose condition holds fires, in parallel — the multi-fire fan-out. This is the default discipline and carries no wire marker: Every match emits no routing-discipline key on the wire.
  • First match. The routes are evaluated in row order and only the first one that matches fires; the rest are never evaluated. This is the classic single-path routing table — at most one route ever fires.
Because a router lowers to a hidden conditional, its routing is that conditional’s own routing discipline and its routes are the conditional’s branches: a kind route or a when route is a predicate branch, and the single otherwise route is the default branch, taken only when nothing else matched. Under first match, row order is semantic, so it cannot ride the member order of a JSON object — object member order is not meaningful and does not survive every store a flow passes through (Postgres jsonb, for one, re-keys members by key length then bytewise). The order instead rides an explicit array of route keys that no store can permute, written on save from the routes table below. A flow authored before that carrier existed keeps running in whatever order it already had, and the first arrangement made in the table pins it. The array must list every route exactly once — a partial or unknown listing is refused at load rather than guessed at runtime. First-match order carries two guard rails, refused loudly at load and scoped to first match (every-match routing is order-free, so neither rule constrains it):
  • At most one catch-all, and an unconditional one comes last. An always-true route makes every route below it unreachable, and a second catch-all can never fire — so declaring either is refused with the dead rows named. A plain otherwise route is order-free (it fires only when nothing matched, wherever it sits) and may be declared anywhere.
  • Route keys are never plain numbers. A numeric branch key is an array index that a JSON-object client silently re-orders ahead of every other key; the router never mints one, and the engine refuses it whether or not an order carrier rides, so the two spellings of a first-match table cannot disagree.

The routes table

Below the card, the routes table is one view over the router’s routes: one row per wired route, in the order they lower. Each row shows its flavor — kind, when (the table’s name for a Condition route), or otherwise — the kind or when-condition, an optional per-kind guard, and its target. Conditions and guards are edited in place; a route’s target is the edge you drew from its chip on the card, so the table shows it, never picks it — the canvas stays the one place edges are drawn. A kind row’s guard is AND-composed with the pinned non-empty check for that kind, so the route fires only when the kind matched and the guard holds. A when row’s condition is its branch condition, reading the classified output, other node outputs, and .states. Under first match the table also orders the rows — the row number is the evaluation priority. Reorder a row two ways: drag it by its grip, or step it with its move buttons (the keyboard and assistive-technology path). The reorder is a plain array move with no wire format of its own; targets, guards, and conditions ride along untouched. Under the every-match default the order is only a position, so the reorder controls are absent. The table also carries advisory notes — never a block on saving — when a first-match row is already covered by an earlier one, when a row repeats an earlier one to the same target, or when the otherwise row sits above rows still being evaluated and so reads out of order.

Turn-level output fields

A router can describe not just the items it extracts but facts about the whole turn. Beside the kinds, declare optional turn-level output fields — a JSON-Schema properties fragment merged into the generated response format and required alongside the kinds — so a field like staff or sentiment becomes an ordinary top-level output read as .upstream_outputs.<router>.staff, typed and statically checked like any other output rather than modelled as a kind or dug out of an item. The declaration rides the card’s marker exactly as the kinds do (the response format is regenerated on every save) and follows the same absent-key discipline as the routing mode: declare none and the router is byte-identical on the wire. Because a turn field and a kind are siblings in one output namespace, a turn field named after a kind — which would shadow that kind’s array — is refused.

Dispatch: once or loop

A router runs its chips under one of two dispatch modes, set once on the card — never per route. The chips look and connect the same way in both modes; the mode alone decides what a chip’s line means, while the routing discipline above still orders the routes:
  • Once (the default). Every chip is a plain routing line: the flow continues from each matched chip, and the target reads the whole verdict as .upstream_outputs.<router>. This is the router exactly as above — no result chip, and a card on Once emits no dispatch key on the wire.
  • Loop. Every chip’s line starts a body — any nodes and conditions you draw off the chip, in the same flow — and that body runs once per classified item of the chip’s kind. The runs go sequentially or in parallel (a parallel run takes an optional max concurrency cap), or the body repeats while an authored jq condition holds. A looping card grows one result chip, the loop-done exit the collected output leaves through.
A looping body sees its one item as .iterate.item, the run index as .iterate.iteration, the prior runs as .iterate.prior_outputs, and the whole verdict as .outputs.<router>. A while body runs its first round unconditionally, then evaluates the condition before every further round — .iterate.prior_outputs[-1] is the previous round’s result and .upstream_outputs.<router> the verdict — bounded by a max runs cap (the flow’s own cap by default) that is a safety backstop, not a stop condition: exceeding it stops the flow with an error on the ordinary on_error path. A while body is always sequential — the engine refuses a parallel run under a while condition. There is never a branch node between a chip and its body — the line goes straight to the body’s first node. Because a body has a single entry, a chip under Loop carries exactly one line, and the card refuses to switch from Once to Loop while any chip still fans out to several lines — remove the extra lines first. One looping chip beside plain ones needs no Loop card: keep the card on Once and draw an ordinary Iterate after that chip. Fanning one chip out to several targets at once is several Once lines from that chip under every-match routing. The result chip. Under Loop the card grows one result chip, and the collected output of every chip leaves through it, keyed by kind name. A consumer of the result line reads a kind’s aggregate as .upstream_outputs["<router>__result"].<kind> (the bracket form, because the collector’s name carries __). The aggregate is a list in run order — item order for a per-item body, round order for a while body; a kind the classifier did not produce reads null, and a matched kind with no item reads []. Because the key is the kind name the author sees on the card, renaming a kind changes that visible key, and the editor’s upstream hints route the result line through the collector’s shape. Loop and the routing discipline. A looping chip is not a row of the hidden conditional, so the first-match discipline orders the card’s when and otherwise routes only: a chip’s body runs whenever its kind matched, regardless of first match. The single otherwise route still fires only when nothing else on the card did — under Loop it carries an explicit condition over every kind and when route, so it never double-fires beside a matched kind.

The visual jq editor

Every jq expression a flow carries — a node’s expression, a branch or loop condition, a binding’s injection or update jq — is authored in one field two ways: by typing jq into the field’s resting text control, or in a visual editor, opened from that control, that draws the expression as a small, editable node graph and writes it back as jq. The wire always carries the jq text.
This visual editor is jq-studio — the platform’s open-source, built-in jq editor. The Studio SDK re-exports its JqField component and the Studio host provides the design system and evaluation worker, so the flow editor renders it directly, with nothing to install. The same editor serves every jq field across the Studio — a flow’s, a host feature’s, another plugin’s — so the canvas below is identical everywhere. See Visual jq in flows for the flow field’s tabs and per-surface seeding.
The node vocabulary. The canvas paints eight node kinds, each with a semantic hue shared with the flow canvas so a colour means the same thing in both editors:
  • Input (primary) — the data the expression receives, ..
  • Value (muted) — a literal or a path into the input.
  • Operator (muted) — combines two values (+, ==, //, …).
  • Condition (warning) — chooses a result with if / else.
  • Try/Catch (danger) — runs logic and recovers on error.
  • Call Function (primary) — applies a built-in or defined function.
  • Define Function (primary) — declares a reusable function.
  • Comment (muted) — a note; never runs.
Kinds that share a hue (Input, Call Function, and Define Function on primary; Value, Operator, and Comment on muted) wear a terse kind badge and are further told apart by glyph and shape, so two same-coloured cards never read alike. A Legend on the toolbar lists every kind and the wire notations — pipe, operand, and function grant — generated from the same registry the canvas paints from, so it can never drift from what you see. The editor shell. Around the canvas sits a Test panel that runs the generated jq against sample JSON — seeded per surface with a skeleton of the right input shape, editable, and run locally in WASM (Cmd/Ctrl+Enter) — plus, where the engine exposes a matching validator, a Validate action that checks the expression server-side. A live readout shows the jq the graph currently serialises to as you edit, and an error summary chip counts the graph’s problems and jumps the view to each one in turn. The faithfulness guarantee. The editor draws a graph only when it can prove that reading the graph back gives exactly your expression. Before any loaded expression is adopted for editing, its graph is serialised back to jq and a WASM oracle checks that the two behave identically; only a faithful round-trip is drawn. Everything else is non-destructive: an expression the editor cannot draw, or one whose graph would read back differently, keeps your text verbatim and shows it as text with a neutral “not shown here” notice — your expression is never altered, and the editable canvas is reached only through an explicit Start empty that you choose. This keeps two questions apart — is the jq valid and can the editor draw it. Only jq that genuinely fails to compile raises a loud “invalid expression” alert; valid jq the editor cannot represent is neutral, never flagged as broken. Where you open it. In a flow field, the visual editor opens from the Inline tab’s Visual editor button — the field is a two-tab Inline/Template control, and the canvas is never a tab of its own. See Visual jq in flows for the field’s tabs. The same editor opens the same way on every other jq field in the Studio.

Visual jq on every surface

Every jq field carries the same editor, and each names what . is at its site so the Test panel seeds the right sample and the server validator checks the right shape. The per-site input shapes — the node, error, binding, and return envelopes, the loop envelopes a while/foreach/map field sees, and the bare flow-inputs and state-record documents — are listed in Visual jq in flows.

Flow states

A flow’s durable cross-run memory lives in the platform state store: named, schema’d JSON documents, one per subject, that every door reads and writes through one seam. The engine does not own that store — it binds to it. Declaring a state, attaching a template, browsing records, and folding subjects are platform concerns (see the States concept); what the flow engine owns is the binding: which states a node reads and writes, under which subject.
  • Bind a node to a state. Each node carries a binding document — one or more states, each with the templates it uses, a jq subject_expr that picks the subject, an optional scope_expr, input injections evaluated after the node’s input is assembled and before it runs, and updates applied after the node completes. Its shape and the injection and update forms are in Binding a node to a state. In a run, every bound state’s record is available to jq as .states.<name>, seeded once at run start.
  • Subjects. A subject_expr is required on every binding and resolves to either a full subject object {target_kind, target_name, kind, key} or a bare key string — for a bare key the kind is the state’s declared subject kind and the target is the run’s ambient target. It means the same on every door and every node; see A binding’s subject and scope. A subject_expr resolving to null or the wrong keys is a loud mint-door error naming the state and the resolved value.
  • Write through updates. An update’s op batch may carry keyed list ops and per-op compare-and-set guards — the coordination primitives for many writers on one record; the platform applies every op atomically under the record’s row lock.
  • Refresh. A node can opt into a fresher view of its bound records — see refresh.
Flow bindings register a flows-states section in backup and restore; the record data is platform state, backed up with the store like checkpoints. A node’s binding writes land in the platform record’s audit trail with the writing consumer, node, and run, and emit a state-change event into the run’s Langfuse trace.

Reading a state a flow does not bind

The run’s .states view carries only the states bound to the running flow, so a .states.<name> read of a state the flow does not bind resolves to null at runtime. The engine catches this in two stages — a warning while you author, a loud error when it runs:
  • A warning while you author. Flow validation reports a .states.<name> read of a state that is not declared (state_ref), or is declared but not bound to this flow (state_unbound), or a refresh_states naming an unbound state (state_refresh_unbound), as a warning — never a save gate. The fact is extra-graph and per-environment: the read is fixed by binding the state at a separate door, so a flow authored or imported before its states are bound in this environment is not blocked. The message names the read and the fix, for example: “Node ‘n’: return_expr reads .states.x, but ‘x’ is a declared state that is NOT bound to this flow, so the read resolves to null at runtime. Bind ‘x’ to this flow, or read a state it binds. States bound to this flow are […].”
  • A loud error when it runs. A refresh_states naming a state the node does not bind is a loud drift error at node execution: “node ‘n’ refresh_states names state(s) [‘x’] that are not bound on this node; bound states are […].” A binding whose subject_expr cannot resolve is likewise a loud mint-door error naming the state and the resolved value.
Binding is refused at the bind door for a state that is not declared (“state ‘x’ is not declared”), for a missing or blank subject_expr (it is required and must be a non-empty jq expression), and for a subject_expr or scope_expr that is not valid jq.

Cross-flow state writers

Because overlapping writers are allowed rather than refused, where two flows race on the same field an optional compare-and-set guard opts one op into lost-update protection. A guard rides any op in an update’s batch as a {path, expected} pair: expected is the concrete value a custom update’s jq computes into the op, and at write time — under the record’s row lock — the platform compares the record’s current value at the guard path (a missing path reads as null) to it by strict JSON equality. Equal, and the op applies; unequal, and that one op is guard-skipped while the rest of the batch still applies, observable both in the record and in the run’s state-change trace. The idiomatic use is a writer token: pair a field with a companion writer field, and have each flow guard its write of the field on the writer equalling its own token, so under a real write race exactly the token-holder writes. The guard is deliberately narrow — it protects against lost updates only, not multi-step invariants, and it compares equality on exactly one path (a null expected value means “expect the field absent or null”, the first-claim case). It is a state-store primitive, checked on every op the store applies.

Keyed state list ops

A state field that holds a list of objects — vetoes, line items, participants — can be maintained item by item without ever reading the whole list and setting it back. Five path operations do this atomically at write time on the live record, under its row lock:
  • set_by_key — update or append the list item whose key field equals a given value. The op’s value is the item itself: a JSON object that must carry its own key field, so the match key can never drift from the payload. The first item whose key field equals that key is replaced in list order; if none matches, the item is appended (upsert). An absent list is created. The value may also be a list of complete items, applied in payload order — one op, many upserts.
  • merge_by_key — patch fields on items that already exist. The op’s value is a list of partial items, each carrying the key field; each partial is shallow-merged into the first stored item with that key ({**existing, **partial}, top level only — a nested object replaces wholesale rather than deep-merging). Null-valued fields are dropped before merging, so null never deletes a field. A partial matching nothing is skipped: unlike set_by_key this never inserts.
  • unset_by_key — the deleting dual of merge_by_key: remove named fields from items that already exist. The op’s value is a list of entries, each exactly {<key_field>: <key>, "fields": [<name>, …]} — the item’s identity plus the top-level field names to delete. An already-absent field is an idempotent no-op and an entry matching nothing is skipped; the key field itself may never be listed, since an item keeps its identity. Stray keys on an entry, a duplicate field name, and a duplicate entry key are all refused loudly.
  • set_by_key_each — the keyed fan-out of the upsert, for a classifier envelope. The op’s value is an object mapping fan-out keys to lists of complete items; each (key, items) pair applies the set_by_key list semantics into the list one level deeper, at the path plus that key. One op and one atomic apply replace one op per fan-out key. A fan-out key must be a non-empty string and never "-"; a key mapping to an empty list contributes nothing.
  • remove_by_key — remove the list item(s) whose key field equals a given value. The op’s value is the key to remove by — a single scalar, or a list of scalars (the MongoDB $pull + $in idiom) that drops every item matching any listed key across all of them, in one op. Every matching item is dropped; zero matches, or an absent list, is a quiet no-op. A remove list is capped at 200 keys; a nested container or a duplicate key is refused loudly, while an empty list is accepted as a no-op — a jq selection that legitimately matched nothing lands as a traced no-op rather than raising.
Every keyed op names a key_field — a single top-level field of the item objects — and addresses the list itself (for set_by_key_each, the object holding the fanned lists), never a new item, so the append token "-" is refused: the op decides insert-versus-replace by key, not by position. A key is a string or integer only; floats, booleans, null, and containers are refused as fragile identities, and a payload list refuses duplicate keys loudly. A list item that is not an object, or one missing the key field, never matches (and is never an error); a non-list already sitting at the path is a loud refusal. As with any op, a null value contributes no write. set_by_key, merge_by_key, and unset_by_key touch exactly one stored item per supplied key — the first match. remove_by_key keeps its remove-all semantics. This is the race-free “update the item where key equals X” that a whole-list set could not express — a whole-list write reads, edits, and sets the entire list back, so a concurrent writer’s change is lost. Applying by key under the row lock instead makes three multi-writer patterns safe with no extra coordination:
  • Distinct keys compose. Two flows that upsert items under different keys both land — exactly like list appends, which always compose.
  • Appends compose with a keyed update. One flow appending a fresh item while another updates an existing one by key keeps both, whatever the commit order.
  • The same key from two flows needs a guard. Two flows racing to write the same key each replace the whole item, last-writer-wins; where that is a genuine race, pair the op with a compare-and-set guard (the writer-token pattern).
A key change is expressed as remove_by_key(old) followed by set_by_key(new) in the same update — one batch, one transaction. An update authors its op batch as a template update program (with its adapter form) or a custom jq that returns the batch, so the keyed ops ride what that jq yields rather than a per-op picker; a run’s state-change trace renders a keyed write by its op and path — set /items where id = a or removed /items where id = a.
Downgrade note. Drain any keyed-op runs before rolling a server back to a build that predates keyed ops: an older server would replay a stored keyed op as a whole-list set and silently clobber the list. The forward direction is safe — a pre-keyed batch never carries a keyed op, and an unknown op fails loud by design.

Refresh: opt-in state re-read

Every run seeds its bound records into .states.<name> once, at mint — so a node reads whatever was persisted when the run started. A node can opt into a fresher view: set refresh state on it to re-read the persisted record(s) right after its input barrier releases and before its jq input is built, folding the freshly committed docs into .states.<name> for that node’s own expressions and its binding’s input injections and updates. Refresh re-reads every state this node binds, or you narrow it to a named subset. The node’s own control has three states: A flow sets refresh_states_default once and every node that leaves its own control unset inherits it, so a flow whose nodes should all read fresh does not repeat the setting on each card. A node that sets its own value always wins — including the explicit off, which is why the control is three-state: “off” and “not configured” are different answers once a flow default exists. With no flow default and no node setting, a node reads the mint-time snapshot. The default is scoped to the flow that declares it. A subflow dispatched as a tool compiles from its own graph and resolves against its own config, so a parent’s default never crosses the flow-call boundary in either direction. A SubFlowStart card therefore edits the sub-flow’s default, and offers that sub-flow’s bound states as its subset options — never the parent’s.
  • What it heals — and what it does not. Refresh heals causality: a document an already-finished upstream node (or another completed flow) committed after this run’s mint-time seed, or a write whose channel update was rolled back at an interrupt boundary. A subflow a parent node invokes is this case — its updates commit immediately but stay invisible to a later parent node until that node refreshes. It is not a race primitive — the re-read is a single point-in-time snapshot with no lock span, so a writer racing concurrently is still a keyed-op or compare-and-set guard concern, never this one. A record that reads absent/erased skips the landing entirely: a refresh cannot observe a deletion.
  • Once at loop entry. On an iterate (and its map face) the re-read fires once, at loop entry — re-entries through the body back-edge skip the barrier — so every iteration’s body, and any while predicate, inherits the same entry-time refreshed view.
  • Interrupt-resume caveat. A tool node that pauses and resumes re-runs on resume, so its refresh re-reads at resume time and its injections and updates — and the input recorded for the node — are recomputed over the resume-time view, which can diverge from the arguments the tool actually received on the first drive.
  • Stateless bypass. On an unbound run — a raw-graph surface, or a flow imported to an environment where it is unbound — the re-read is a silent no-op, the same shape as the binding’s writes. But naming, in the subset, a state this node does not bind is a loud binding error naming the node’s bound states, whenever bindings do load.
In the Studio the control sits on the node’s State/Settings surface beside the wait knobs, and a refresh lands in the run’s state-change trace as a “refreshed” marker carrying no new writes.

Enable the state store

Flow bindings read and write the platform state store, so that store must be enabled — it defaults to the default database and points elsewhere with TAI_DB_BINDING_STATES (see enabling the store). The flow engine keeps its own small store for the per-node bindings, on its own Postgres component flows-states:
Then run the migrations — the plugin ships its chain under migrations_component: flows-states, discovered by the shared runner:
The boot gate refuses to serve a declared-but-unmigrated database, naming that exact step. With the binding unset the feature is cleanly off: bound flows simply run stateless, and the binding routes answer a gated refusal. Record retention is a platform concern now — set it per state (retention_days) or as a deployment default on the store; see the States concept.

State templates

A state template gives a state a declared structure — a reusable schema fragment, per-path writer regimes, a declarations section, a set of template jq programs, and a trace switch — written in template-relative names that attaching translates to concrete paths once. The template is a platform document (kind: "state-template") the platform state store owns; declaring it, attaching it on a state, its template jq, and the effective schema an attachment composes into are all platform operations. A template is data and the runtime is generic: nothing in a template is a use-case feature — it is a mechanism any state can reuse. Babelfish consumes an attached template two ways: a node’s binding injects an input-purpose template jq’s value into the node input and applies an update-purpose program’s op batch after the node runs; and every jq expression on a bound state may call the input programs through the template-jq prelude.

Binding a node to a state

A node’s binding attaches one or more states, each with the templates it uses, a subject_expr that resolves the record key, and an optional scope_expr. It carries two lists:
  • input injections — each evaluates a purpose: "input" template jq (or a custom jq over {record, input}) and merges the value into the node input under a named field before the node runs;
  • updates — each applies a write after the node completes: a purpose: "update" template jq (its returned op batch rebased under the attachment path), shaped by an adapter that maps the node’s {output, input} to the program’s declared input, or a custom jq over {record, output, input} returning the op batch itself. An optional op_id makes the write idempotent.
A binding always attaches at least one state: PUT /api/babelfish/bindings/{flow}/{node} with zero states ({"states": []}) is refused (422). “No binding” is spelled by the absence of the document, never an empty one — DELETE on the same route removes a node’s binding, and export carries no empty binding document (a node with no binding contributes nothing to the state_bindings sidecar). The engine validates the binding at save: every named template jq must exist on an attached template with the purpose it is used as (input for an injection, update for an update; an unknown name, an ambiguous unqualified name, or the wrong purpose is refused); an adapter — required on a named update — and a custom update’s jq must compile. It does NOT re-check the ops a custom update produces: those are constrained at RUN time by the platform’s write-regime chokepoint (there are no binding-declared paths for a custom update). An adapter’s output is validated against the update jq’s declared input at APPLY time — a missing or undeclared key is refused. The single-writer check runs at save over the REAL write paths. A single-regime path admits at most one writer, and a second is refused naming both. A template update is one writer identified by (template, attachment, program) on its declared writes rebased under the attachment path — so the same template update applied from any node or door on one state is ONE writer — while a custom update is the node’s own writer, (flow, node), counted as a writer of the whole attached subtree (its attachment’s path). A composing path admits only keyed ops or an append set (a path ending '-'); a whole-path write is refused, and the platform store additionally refuses a composing-shape violation at write time, so the rule holds even for a write that never passed the bind door.

The template-jq prelude

Every jq expression a flow carries on a bound state — a node expression, a router verdict, a binding field — reaches the state’s attached templates’ input programs through a generated prelude. For each purpose: "input" template jq the engine emits one def:
so an expression calls it as tjq_<name>({…}) — always exactly one object argument ({} when the program declares no params), for example tjq_due_set({run: .turn.run}) or tjq_x({}). Inside the body . is the attached record subtree, and $declarations/$parameters are the attachment’s declarations and parameters. Sibling input programs are emitted dependency-first, so one may call another by its tjq_<name>({…}) handle. The prelude also emits a generic attachment($state; $template) locator: it yields the attachment envelope, and attachment($state; $template).data the record subtree an input program reads — so attachment($state; $template).data | tjq_<name>({…}) is the explicit form of a call whose . is that subtree. When a program name is unique across the state’s attachments the engine emits both the short tjq_<name> and the qualified tjq_<template>__<name> — the template name slug-encoded -_, so my-tally gives tjq_my_tally__<name>; when it is shared it emits only the qualified form (never a silent pick). An unknown name raises loudly. There is no update/write callable in an expression — updates happen only through a binding’s updates (above).

Trace, declarations, and the effective schema

Three parts of a template are platform-owned and documented with the platform state store: under a traced attachment the platform stamps a _trace object (meta, run, turn, inbound, at) on every object a write touches — the engine supplies meta: {node: <node id>}; a template’s declarations carry static values a program reads as $declarations, validated by their own schema and an optional check predicate; and the platform composes each attachment’s fragment into the state’s effective schema, which every write validates against.

Binding scope

A binding’s optional scope_expr is a boolean jq predicate evaluated first, before the subject is resolved: false skips that state for the run — no injections and no updates — and records a binding_skipped state-change event; a non-boolean raises; unset means engaged. It means the same on every door and every node, and a door’s scope_expr wins over the preset’s — see A binding’s subject and scope. This is how one node, bound to several states, engages only the state its caller names.

Shipped defaults

Babelfish ships no state templates of its own; a state template is a platform document, and a resource that ships one (its schema, regimes, declarations, and template jq) registers it as a platform state-template seed on the state store, reconciled by content hash like any other seed.

Upload — one door, two kinds

POST /api/babelfish/upload is one door for the flow engine’s two self-describing document kinds — the JSON’s kind routes it. Both become presets. State and state-template documents are uploaded from the platform States screen instead, through the platform’s own /api/states doors:
  • flow → a preset over the flow base tool, through the same in-process authoring path the preset HTTP doors use (create for a new name, a new active version for an existing one — earlier versions kept).
  • node → the same, plus a palette entry: the preset carries a palette-node tag so it lists in the canvas palette, with an optional folder placing its card.
An unknown or missing kind is 422; the response is {kind, name} (plus version for a preset). The per-node bindings have direct routes too, keyed (flow_name, node_id): GET /api/babelfish/bindings (by ?flow= or ?state=), PUT/DELETE /api/babelfish/bindings/{flow_name}/{node_id}, DELETE /api/babelfish/bindings/{flow_name} (removes every per-node binding of a flow — the Studio calls it when a flow is deleted — returning {removed}), and POST /api/babelfish/bindings/move. Every one answers the same gated refusal (503, reason: states_unavailable) when the flow states feature is off.

Shipped defaults are files

The shipped defaults live as files beside the code: one JSON document per default (each with its kind), a retired.json withdrawal manifest, and a <name>.md prose note beside each document. A loader reads the folder at import and turns node/flow files into preset seeds, applied in-process through the same seed appliers the upload door’s writes reach — never by the server calling its own HTTP. The document is the source of truth, and a default is told from an operator edit by a content hash. A state-template document a resource ships is registered as a platform state-template seed on the state store.

Checkpoint retention

A run’s pause/resume memory lives in its durable checkpointer, and on the postgres provider that history is kept until a thread is explicitly deleted — so completed and abandoned runs accumulate. An opt-in retention window bounds them, mirroring the states record-retention idiom: configure the window, then drive an explicit prune on a cadence (a schedule or your own cron) — never a silent background loop.
POST /api/babelfish/checkpoints/prune reaps every thread whose newest checkpoint is older than the window and answers {pruned, provider} — plus a reason whenever it did nothing. A live async park is always spared, whatever its age; on this provider the window also doubles as the park-admission horizon, so an async ask whose deadline lies beyond it is refused loudly at suspend rather than parking a run whose checkpoints could be reaped before the answer arrives. Under a bounded window an ask carrying no deadline is refused too — a park that cannot be proven durable is never admitted. A non-positive value, or one past the integer ceiling, is a loud configuration error rather than a silently ignored setting.
The sweep covers the postgres checkpoint provider only. On any other provider it reports a benign no-op naming that reason — redis bounds itself with its native idle TTL (LLM_PROVIDER_CHECKPOINT_TTL_MINUTES), and memory/sqlite are dev-only and never durable enough to park on at all.
The prune sweeps the checkpoint tables, which the postgres provider shares with every other graph pointed at the same connection — conversation and agent threads included. It reaps any aged, unparked thread it finds there, not only a flow’s. Point the flow checkpointer at its own database if that blast radius is not what you want.

Agent session continuity

By default every agent a runnable invokes gets a fresh agent thread per call. A runnable may instead opt into per-node session continuity, keying that node’s agent runs to one retained session so memory carries across successive runs of the node. The key is a plain-string template — placeholders for the flow-run id and the node’s raw id are substituted at run time, and the engine always forces the reserved flow: prefix. Because the key is keyed on the raw node id it is stable across iterations, so inside a sequential loop each iteration resumes the previous iteration’s agent thread; a parallel loop, which would drive the same key concurrently, is rejected at build time. Real carry-over needs a checkpointer-backed agent — a memoryless agent still runs but remembers nothing.

How it plugs in

It installs like any other plugin: it registers its Flows page and the API behind it under its own route mount, and integrates with the Studio the same way every plugin does. The bundle is committed inside the distribution, so installing it needs no JavaScript toolchain on the server. The plugin ships its own HTTP API under its own route mount and claims no backend — see Studio plugins for how the Studio loads a plugin bundle and pins it.

Organizing flows

Because a flow is an ordinary tool, it organizes through the platform’s tool-organization overlay — the same folders, display names, tags, and visibility that apply to any tool. The Flows page renders that overlay directly:
  • Folders and breadcrumbs. The page is a current-directory explorer: a breadcrumb, the folder’s subfolders, then the flows filed there, with unfiled flows at the root.
  • Display names and tags. Each flow shows its display name (its real tool name stays visible) and is filtered by its merged tags — a flow’s plugin-native babelfish tag unioned with your editable user tags. A tag filter and a search box narrow the list. A tool’s display name from that same overlay also surfaces inside the flow editor: a tool node’s canvas label shows it, the node-editing tool pickers show it with the raw name kept visible, and the run popup and history inspector show it with the raw name in parentheses.
  • List or card view. A toggle switches between a compact list and a card grid; the choice persists per browser.
  • Per-flow editing. A flow row edits its display name, tags, and folder in place. Hiding a tool is centralized on the Studio Tools screen, not here — the Flows page manages everything except visibility.
The Flows page derives its entire view state — the open flow, the current folder, open dialogs, and the active filters — from the URL, so a flow is bookmarkable and shareable and the browser’s back button works. A link to a flow that no longer exists renders a loud not-found card rather than a blank editor.

See also

  • Babelfish on tai42.ai — the product, its pricing, and its terms.
  • Visual jq in flows — the flow field’s Inline/Template tabs and what each jq field seeds the editor with.
  • The jq editor — jq-studio, the platform’s open-source, built-in visual jq editor.
  • Studio plugins — how a plugin contributes a Studio page.
  • Presets — the versioned wrap a saved flow becomes.
  • Tool organization — the folders, display names, tags, and visibility a flow rides.