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.
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 listinput (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.
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-leveltitle 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.
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.
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 buttrue 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.
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,
.statesincluded. It is a condition route, not a disguised kind, and the canvas says so. - Otherwise — the default branch, taken when no other branch matched.
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.
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-Schemaproperties 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.
.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.- 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.
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_exprthat picks the subject, an optionalscope_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_expris 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 thekindis 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. Asubject_exprresolving tonullor 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.
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 arefresh_statesnaming 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_statesnaming 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 whosesubject_exprcannot resolve is likewise a loud mint-door error naming the state and the resolved value.
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’svalueis 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. Thevaluemay 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’svalueis 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, sonullnever deletes a field. A partial matching nothing is skipped: unlikeset_by_keythis never inserts.unset_by_key— the deleting dual ofmerge_by_key: remove named fields from items that already exist. The op’svalueis 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’svalueis an object mapping fan-out keys to lists of complete items; each(key, items)pair applies theset_by_keylist 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’svalueis the key to remove by — a single scalar, or a list of scalars (the MongoDB$pull+$inidiom) 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.
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).
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
whilepredicate, 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.
Enable the state store
Flow bindings read and write the platform state store, so that store must be enabled — it defaults to thedefault 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:
migrations_component: flows-states, discovered by the shared runner:
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, asubject_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 optionalop_idmakes the write idempotent.
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:
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 optionalscope_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 theflowbase 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 apalette-nodetag so it lists in the canvas palette, with an optionalfolderplacing its card.
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 itskind), 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 thepostgres 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.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 reservedflow: 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
babelfishtag 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.
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.

