Skip to content

Latest commit

 

History

History
404 lines (329 loc) · 25.9 KB

visualflow.md

File metadata and controls

404 lines (329 loc) · 25.9 KB

VisualFlow JSON

VisualFlow is the portable workflow document produced by the AbstractFlow editor and persisted by AbstractGateway.

Execution semantics are owned by AbstractRuntime. Flow's job is to author and serialize the graph.

Shape

{
  "id": "workflow-id",
  "name": "Workflow Name",
  "nodes": [
    {
      "id": "node-1",
      "type": "llm_call",
      "position": { "x": 100, "y": 80 },
      "data": {
        "nodeType": "llm_call",
        "pinDefaults": {}
      }
    }
  ],
  "edges": [
    {
      "id": "edge-1",
      "source": "node-1",
      "sourceHandle": "exec-out",
      "target": "node-2",
      "targetHandle": "exec-in"
    }
  ],
  "entryNode": "node-1"
}

Interfaces

A workflow can declare the contracts it implements in its top-level interfaces list, for example "interfaces": ["abstractcode.agent.v1"]. Callers use this list to find workflows they can start, then send inputs to the On Flow Start outputs and read results from the On Flow End inputs by pin id.

Each interface requires these boundary pins. Optional pins are added with them when the interface is declared, but Run preflight never asks for them; an empty one means the gateway default.

Interface On Flow Start outputs On Flow End inputs
abstractcode.agent.v1 provider (provider_text), model (model), prompt (string) response (string), success (boolean), meta (object)
abstractassistant.agent.v1 provider (provider_text), model (model), prompt (string); optional: image_provider / image_edit_provider / image_upscale_provider (provider_image), video_provider / image_to_video_provider (provider_video), music_provider (provider_music), the matching *_model (model), sound_output (object), primary_image_artifact (artifact_image) response (string), success (boolean), meta (object)
abstractcode.goal.v1 goal (string), max_cycles (number), provider (provider_text), model (model), tools (array) result (string), success (boolean), cycles_used (number), stopped_reason (string)
abstractcode.coding.v1 request (string) report (string), passed (boolean)
abstractresearch.coscientist.v1 research_goal (string) research_overview (string), ranked_hypotheses (array)
abstractreview.adversarial.v1 artifact (string) findings (array), verdict (string)
abstractextract.structured.v1 source_text (string), fields_spec (json_schema) data (object), valid (boolean)
abstractbatch.mapreduce.v1 items (array) results (array), synthesis (string)

abstractresearch.deep.v1 and abstractmeta.intelligence.v1 are family markers and require no pins.

When you declare an interface in the editor (Flow Library > Interfaces), AbstractFlow adds every missing required pin to the flow's On Flow Start and On Flow End nodes, with the type shown above. Pins you already have keep their id, label, type and order; nothing is removed. The change is one undo step, and Save stores the interfaces together with the new pins. Opening a saved workflow that declares an interface also adds any pin it is missing; the workflow then opens with unsaved changes and the notice "Interface pins were added to On Flow Start/End; save to store them", and after you save nothing is added again. An On Flow Start or On Flow End node you add to such a workflow arrives with the pins already in place. The added pins are declarations: wire each one on the canvas so the host receives real values. Removing an interface keeps its pins, so you can delete the ones you no longer need. The Run preflight warns when a required pin is missing from an On Flow Start or On Flow End node, when a required On Flow End pin has no connection, pin default or pin expression (hosts would read null), and when a pin has the required id but another type than the interface expects. The legacy spellings provider (for provider_text) and model_text (for model) are accepted.

Automation Defaults

A runnable workflow can carry the defaults of the automations created from it in a top-level automation_defaults object (edited in the Automation dialog, see Web Editor > Automations):

"automation_defaults": {
  "schema_version": 1,
  "title": "Memory watch",
  "trigger": { "source_id": "schedule", "source_version": 1, "config": { "every": "2m" } },
  "context": { "mode": "independent" },
  "input_data": { "prompt": "Report this computer's memory usage." }
}
  • trigger.source_id / source_version name a trigger source served by the gateway (GET /api/gateway/trigger-sources); config must match that source's config_schema. For schedule v1: start_at, until and anchor are RFC 3339 timestamps, every is a whole number followed by s, m, h or d, count is an integer of at least 1. manual v1 takes {}.
  • context.mode is independent (default) or growing; a growing context may set context.growing.max_tokens (a positive whole number: how much of the earlier runs each run reads); without it the gateway's one default applies (GET /api/gateway/automations/defaults → growing_max_tokens). title is optional; input_data (default {}) holds the On Flow Start inputs.
  • trigger.connection_id (optional string) names one of your trigger connections, for a source whose descriptor says needs_connection: true with the trigger_connections provider (a feed, a webhook). An email trigger (connection_provider: email_accounts) reads one of your mailboxes instead, named in its own settings (account); mailboxes are added under Accounts → your row → Email in the Gateway console. Such a source requires it; any other source refuses it. Connections are created on the gateway console's Triggers page (/console#triggers); the gateway refuses a connection that is not the automation owner's when the automation is created.
  • No other field is accepted: binding ids, credentials and automation ids are created by the gateway when an automation is created, never stored in the workflow.

The trigger event

A triggered run also fills named On Flow Start outputs from input_data (declared by the editor when a trigger is chosen; the runtime's contract: AbstractRuntime > Occurrence inputs and outputs): mail messages ([{id, from, to, subject, date, body_text, attachments}]) and mailbox; feed items ([{id, title, link, published, summary, author}]) and feed ({title, url}); webhook calls ([{id, received_at, headers, body}]); schedule scheduled_at and occurrence. On Flow End records the outcome and reaches nobody: response (the run's result), success (false marks the run failed) and note (a short line of what happened, kept with the run). Flows saved before Notify me existed may carry a notify pin ({title, body}), which still raises an alert after the run; new flows never get it.

Notify me is stored as a publish_event node without a channel pin (the me channel, alert on). Its Where choice is saved in the node's effectConfig.channels, a list of tray, watch, phone and email (for example ["watch", "phone"]); without it the alert goes to every channel. See Web editor > What happens after On Flow End.

A run started by a trigger (an automation occurrence) receives the event as input_data.event, a CloudEvents 1.0 envelope: id, type, source, subject, time, datacontenttype, data, plus bindingid, connectionid, correlationid, dedupekey, sequence and abstracttrust; schedules add data.series (rule, occurrence, scheduled_at, next_at, coalesced). A workflow is triggerable when its On Flow Start declares the event output (type object):

{"id": "start", "type": "on_flow_start", "data": {"outputs": [
  {"id": "exec-out", "label": "", "type": "execution"},
  {"id": "event", "label": "event", "type": "object"}
]}}

The runtime fills it from input_data.event like any start input (empty when the workflow is run by hand). To read fields, wire it into a break_object whose breakConfig.selectedPaths names them (type, subject, time, data, data.count, data.events, data.series, or a deeper path). In the editor, Add trigger event on On Flow Start does both. A source with needs_connection cannot be bound to a workflow without the event output.

data depends on the source. Sources that read from a connection (feeds, webhooks) deliver a batch: everything that arrived since the last run, oldest first, as CloudEvents items of their own. One example per source (attributes shortened):

Feed item (feed.item@1): each item's data holds title, link, published, updated, summary, author, id and feed {title, url}.

{"type": "abstract.feed.item.v1", "subject": "batch:41-42", "connectionid": "conn_1a2b", "abstracttrust": "untrusted",
 "data": {"count": 2, "event_ids": ["…", "…"], "first_seq": 41, "last_seq": 42, "content_trust": "untrusted",
          "connection_id": "conn_1a2b",
          "events": [{"type": "abstract.feed.item.v1", "subject": "https://example.org/releases/0.13",
                      "data": {"title": "Release 0.13", "link": "https://example.org/releases/0.13",
                               "published": "2026-10-25T09:00:00Z", "summary": "…", "feed": {"title": "Releases", "url": "…"}}},
                     {"…": "…"}]}}

Webhook call (webhook.received@1): each call's data is {webhook_id, content_type, body} (the parsed JSON) or {…, body_text} for a text body; its subject is the value at the connection's subject_path, else the webhook id.

{"type": "abstract.webhook.received.v1", "subject": "msg_2Lh…", "connectionid": "conn_9z8y",
 "data": {"count": 1, "events": [{"subject": "lpalbou/abstractflow",
          "data": {"webhook_id": "msg_2Lh…", "content_type": "application/json", "body": {"ref": "refs/heads/main"}}}]}}

Email received (email.received@1): data.messages lists the messages' headers (from, from_address, to, subject, date, message_id…); the whole messages, bodies included, are the run's emails input (declare an emails output on On Flow Start to read them).

{"type": "abstract.email.received.v1", "subject": "batch:7-7",
 "data": {"count": 1, "content_trust": "untrusted",
          "messages": [{"from": "Ada <ada@example.org>", "subject": "Invoice 42", "date": "2026-10-25T08:12:00Z", "message_id": "<…>"}]}}

Schedule (schedule@2): the tick and its series.

{"type": "abstract.schedule.v2", "subject": "tick:4",
 "data": {"tick": 4, "scheduled_at": "2026-10-25T08:00:00+02:00",
          "series": {"rule": {"kind": "daily", "at": "08:00", "time_zone": "Europe/Paris"}, "occurrence": 5,
                     "scheduled_at": "2026-10-25T08:00:00+02:00", "next_at": "2026-10-26T08:00:00+01:00", "coalesced": null}}}

Text written by other people (abstracttrust: "untrusted": feeds, webhooks, email) is content to read, never instructions.

The gateway validates the field when you save the workflow (the same structural rules as the editor, then the trigger source's own check) and answers HTTP 422 with the failing field otherwise. Publishing copies the object into the bundle manifest as metadata.automation_defaults[<flow id>], and the gateway's workflow catalog and bundle listings expose it keyed by entrypoint flow id.

When an app creates an automation from the published workflow (POST /api/gateway/automations) and leaves out the trigger or the title, the gateway takes the missing values from these defaults: the trigger, the context mode, the title, and input_data, merged under the request's own input_data so the request's keys win. The published defaults are validated again at that point. Tool approval is not part of automation_defaults: the gateway applies its default policy, in which creating the automation is consent for the tools the workflow uses, unless the creation request sets policy.tool_approval to ask.

See Architecture > Automation Defaults for the path from the document to an automation.

The Waits nodes, Wait Until (on_schedule) and Wait for Event (on_event, or wait_event in its step form), and Delay (wait_until) pause a run that has already started. They are not how automations are scheduled. Their node types and saved fields are unchanged from when they were named On Schedule, On Event and Wait Event; see Web editor > Waits inside a flow.

Authoring Rules

  • Nodes contain editor data and runtime-facing pin defaults.
  • A pin default is a JSON value stored with its type (5, "5", true, []), and the runtime uses it exactly as stored. The editor stores what is typed on a card as the input's type, or for an input with a type variable (typeVar on the node template, such as Compare's a and b) as the type wired to its peers; see Web editor > Values typed on a card.
  • Execution edges connect exec-out to exec-in.
  • Data edges connect typed pins.
  • Provider/model pins should use Gateway-discovered provider ids and models.
  • Secrets must not be embedded in VisualFlow JSON.
  • Reusable endpoint credentials belong in Gateway provider endpoint profiles.

Pin Expressions

A data input pin may carry a small sandboxed Python expression instead of a wire or a static default. Expressions are stored on the node as data.pinExpressions — a field of its own, deliberately never encoded inside pinDefaults:

{
  "id": "loop",
  "type": "while",
  "data": {
    "nodeType": "while",
    "pinExpressions": { "condition": "vars.fix_cycles < 3" }
  }
}

AbstractRuntime evaluates the expression at input resolution on the consuming node, after wires and pin defaults have resolved, and the result replaces the pin's value. The expression environment is small:

  • vars — the run variables, read-only (vars.count, vars["count"], vars.get("count", 0), "count" in vars). Writing state stays the Set Variable node's job.
  • value — what the pin would have resolved to without the expression: the wire value if connected, else the pin default, else None.
  • The Code-node sandbox builtins (len, sorted, sum, ...) plus parse_json / to_json / shq / text_of. The last two are for the two things flows kept hand-rolling: shq(value) escapes a value for a single-quoted shell context ("cd '" + shq(path) + "'") — use it for every value you interpolate into a command, one unescaped quote is an injection — and text_of(envelope) pulls the text out of a tool result whatever envelope shape it arrives in. Both are available in Code node bodies too (one source, both sandboxes), which is what lets a shell-command composer be a Code node with a visible body rather than a flow-library function.

Expressions compile under the same RestrictedPython policy as Code node bodies, in expression mode: statements and imports cannot appear, while lambdas, comprehensions, and conditional expressions remain available. Failures are loud and attributed — an unparseable expression fails the flow build naming node and pin, and an expression that raises at run time fails the consuming step with an error naming <node>.<pin> plus an expression preview. Evaluation happens at every resolution, so a While condition expression re-reads vars.* fresh each iteration.

The authoring ladder

An expression is one rung of a ladder, and the ladder has one rule: nodes and wires first, always. Visual authoring exists so a process can be understood by looking at it. Anything expressed off the canvas — in a code body, an expression, a function, or one object blob crossing a boundary — is hidden, and the reader has to open something to learn what the flow depends on.

Code and pure functions are here to harness DETERMINISTIC or tedious work — contact a database, run an ETL, convert format 1 to format 2 — so the node vocabulary does not have to explode combinatorially. They are not a replacement for the abstractions that already ship, which must always be favoured.

Walk from the top; stop at the first rung that works.

rung reach for for
1 an existing node anything the catalog ships. Check docs/workflow-node-catalog.md before writing a line of Python.
2 a subflow, one input pin per field a whole reusable process. subflow_interface names the child's pins; wire one edge each. Never a hand-built input object.
3 get_var / get_vars / set_var / set_vars / add_to_list reading and writing run state. Set Variables takes one row per variable (name, type, value, or a wire into its row; an array row also says what it holds): one node initialises several variables. Add to List Variable grows a list variable in one step; Get Variables reads several at once (and folds to a list of names). A String Template reads run variables by name (chips in its text), so a prompt built from run state needs no getter. A review → refine loop is capped with Loop Limit (loop_limit), whose count is the round.
4 a pin expression a derivation: vars.fix_cycles < 3, not vars.accepted and vars.revisions < 3, to_json(value). Never a plain read.
5 a code node deterministic glue: external systems, ETL, format/shape transforms, checksums, validation, shell composition. Exec pins when it acts; pure otherwise. Expert territory.
6 a flow function the same derivation at 2+ call sites, ≤ 25 lines.
7 a runtime builtin something useful in every flow (envelope parsing, shell quoting).
8 propose a reusable node nothing above fits — say so, out loud, instead of working around it in a body.

Within rung 4, what an expression is NOT for:

what you are doing use
reading a run variable, incl. a dotted path and a default Get Variable node, wired in
reading one field off this pin's own wire declare that output pin upstream
reading several fields off one wire Break Object (Break Email for an Email: every field, typed)
interpolating values into a string String Template
combining two reads with and / or / not / a comparison those nodes, or an expression
multiple statements, branching, several outputs Code node
the same derivation at two or more call sites a flow function (≤ 25 lines)

And what a code node is never for: orchestration (sequence/parallel/if/ switch/loop/for/while own that), state reads and writes, field extraction, or prompt and system text. Every sentence a model reads must be editable by a user or an agent without opening Python — it lives on the consumer's pin default or in a String Template. A body may SELECT between texts that sit on pins; it may not contain them.

It is never for a plain read. The runtime treats both lanes identically (every pure node is volatile and re-pulled at each resolution, so a wired getter re-reads inside a While condition exactly as an expression does), so the choice is purely about what the canvas shows — and a node shows it.

get_var resolves dotted paths and honours a default, so get_var{name:"state.wait_gating", default:true} is an exact replacement for vars.state.get("wait_gating", True) — including the missing-key case. One getter can fan out to many consumers; five agents reading state.provider want getter nodes and edges, not five expressions.

How far to fan one getter out is a LAYOUT question, and the honest answer is "per neighbourhood", not "once per flow". Auto-layout puts a pure helper in the column of its EARLIEST consumer, so a getter shared by consumers ten columns apart drags a wire the width of the canvas. Cluster consumers that sit within a column or two of each other, give a distant cluster its own getter, and measure: on the multiagent-coding bundle, one pair per agent cost 10 chips, one pair for all five produced a 7,872px wire, and three neighbourhood pairs (6 chips) came out shortest on every wire metric at once.

The editor DRAWS a single-consumer read on the pin row it feeds instead of as a separate card: a get_var with a configured name, no incoming edges and exactly one outgoing wire renders as a teal read pill on its consumer, the card and wire hidden until you click the pill (which reveals and selects the node). This is a render fold and nothing else — the document keeps the getter node and its edge, so nothing to author or migrate changes, and the audit reports both counts as [nodes=…, rendered_nodes=…]. Shared, dangling and computed reads (2+ consumers, no consumer, or a wired name/default) stay drawn, because there the node is the subject rather than the argument. The toolbar toggle beside the execution view turns the fold off and puts every chip back on the canvas.

scripts/audit_flow_graph.py --policy checks a flow against the whole ladder, and the editor's preflight panel raises the same findings as warnings while you author (never as Run blockers — style must not stop a run):

check fires on
P1 TRIVIAL READ an expression that is only a run-var read (either spelling: vars.state.get("k") or (vars.get("s") or {}).get("k"))
P2 FIELD EXTRACT an expression that is only a field read off the pin's own wire
P3 THIN WRAPPER an expression that is only a call to a function failing P4/P5
P4 SINGLE USE a flow function with fewer than 2 call sites
P5 OVERSIZED FN a flow function longer than 25 lines
P6 HIDDEN CONTRACT a subflow node whose only data input is one input/vars object while the child declares several start pins — the finding names the smuggled fields when the child resolves beside it
P7 CODE-FOR-ORCHESTRATION a code node whose body is mostly prose — the "prompt composer in Python" smell; the text belongs on a pin default or in a String Template

--policy is advisory; --policy-strict makes it affect the exit code. --selftest runs the predicates against their own fixtures.

Version skew is safe by construction: a runtime that predates pin expressions never reads the field, so the pin falls back to its wire or default value — a stale value, never a spinning loop. The no-secrets rule above applies to expressions too; the editor and the authoring commands refuse credential-looking expression text.

In the editor, hover a pin row and click the ƒx control to add or edit an expression; computed pins show an amber chip. In the authoring document the field is pin_expressions, merged per key (an empty string removes one).

File And Document Nodes

File/document side effects are execution nodes and must be on the execution path before on_flow_end if their outputs are part of the requested result.

  • read_file reads UTF-8 text or JSON from a workspace path.
  • write_file writes UTF-8 text or JSON to a workspace path.
  • read_pdf extracts text and metadata from a .pdf path using Runtime's permissive PDF reader.
  • write_pdf renders text or Markdown-style report content to real PDF bytes using Runtime's permissive PDF writer.
  • write_docx renders text or Markdown-style report content to a real .docx document using Runtime's standard-library DOCX writer.

In Gateway-hosted runs, these are workspace-scoped server paths, not browser local files. Artifact inputs use the separate Artifact / Local File / Server File source model. Use write_file for Markdown, JSON, and text files. Use write_pdf for PDF files and write_docx for Word-compatible documents; do not represent PDF/DOCX generation by writing Markdown to a .pdf or .docx path.

Structured Output And Switch Cases

Inline response schemas for LLM Call and Agent nodes are stored as pin defaults. Connected schema edges override these defaults at runtime.

When a schema is active, LLM Call and Agent nodes keep response as a text output for compatibility and expose data as the structured object output. Wire data directly into Break Object or other object-aware nodes; use response when you explicitly want the textual assistant content.

{
  "id": "classify",
  "type": "llm_call",
  "position": { "x": 240, "y": 80 },
  "data": {
    "nodeType": "llm_call",
    "pinDefaults": {
      "prompt": "Classify the request.",
      "resp_schema": {
        "type": "object",
        "properties": {
          "choice": {
            "type": "string",
            "enum": ["approve", "reject", "escalate"]
          }
        },
        "required": ["choice"]
      }
    }
  }
}

For enum-driven control flow, wire the structured data output into Break Object, expose the enum field, and connect that field to Switch. Flow stores explicit Switch cases. Gateway publishes these fields unchanged, and Runtime routes by the saved case handles.

{
  "id": "route_choice",
  "type": "switch",
  "position": { "x": 640, "y": 80 },
  "data": {
    "nodeType": "switch",
    "switchConfig": {
      "cases": [
        { "id": "approve", "value": "approve" },
        { "id": "reject", "value": "reject" },
        { "id": "escalate", "value": "escalate" }
      ]
    },
    "outputs": [
      { "id": "case:approve", "label": "approve", "type": "execution" },
      { "id": "case:reject", "label": "reject", "type": "execution" },
      { "id": "case:escalate", "label": "escalate", "type": "execution" },
      { "id": "default", "label": "default", "type": "execution" }
    ]
  }
}

Sharing Workflows

For portable workflows, prefer exposing environment-specific values as start inputs or Gateway defaults:

  • provider id
  • model id
  • optional base URL override when the workflow intentionally targets an OpenAI-compatible route
  • non-secret parameters such as temperature, max tokens, dimensions, steps, and seed

API keys and user credentials should be configured by the receiving Gateway, not exported with the workflow.

Examples

Sample JSON files live in examples/flows/.