Workflows
A workflow in LatchAI is a file: graph JSON in <home>/workflows/<id>.json. The canvas
renders that document, the executor runs it, and the SDK compiles to it. There is no
second representation and nothing is trapped in a database — you can grep your
automations, diff them, and commit them alongside the code they operate on.
The document
Section titled “The document”A workflow is nodes and edges. Each node has an id, a type, a canvas position (part of
the document, so layouts survive round-trips), and a type-specific config whose values
support {{nodeId}} templating against upstream node output. Edges leaving a condition
node carry when: "true" | "false". The whole thing is validated with zod on save, so an
invalid graph never reaches the executor.

The canvas renders the same JSON the executor runs.
Here is a complete two-node workflow — a manual trigger feeding an agent step that runs under a named agent definition:
{ "id": "release-notes", "name": "Release Notes", "description": "Turn the raw change list into notes in the house style.", "group": "Docs", "nodes": [ { "id": "trigger", "type": "manual_trigger", "position": { "x": 60, "y": 160 }, "config": { "params": [ { "key": "version", "label": "Version", "required": true, "placeholder": "1.4.0" } ] } }, { "id": "draft", "type": "agent", "label": "Draft the notes", "position": { "x": 320, "y": 160 }, "config": { "agent": "release-writer", "prompt": "Read input/changes.txt and write release notes for version {{trigger}}." } } ], "edges": [{ "from": "trigger", "to": "draft" }]}Top-level fields are id, name, description?, group? (a folder in the sidebar),
disabled?, nodes (at least one) and edges. Validation rejects duplicate node ids and
any edge whose from or to names a node that isn’t in the document — so a save either
produces a graph the executor can walk, or an error in the tab.
Node types
Section titled “Node types”The current set:
| Type | What it does |
|---|---|
manual_trigger |
Entry point for a hand-started run; may declare run parameters |
cron_trigger |
Entry point armed on a schedule |
file_trigger |
Entry point armed on a watched directory |
webhook_trigger |
Entry point armed on a local HTTP endpoint |
template |
String templating over upstream output |
http_request |
An outbound HTTP call |
agent |
A tool-using model turn (see Agents) |
read_file / write_file |
Permission-gated file access |
review_gate |
Pauses the run for human approval |
condition |
Branches the graph on a predicate |
subworkflow |
Runs another workflow as a step |
The trigger types are covered in Triggers; review_gate in
Checkpoints & review gates.
Config, node by node
Section titled “Config, node by node”Every node’s output is a string, and that string is what {{thatNodeId}} resolves to
downstream.
| Type | Config | Output |
|---|---|---|
manual_trigger |
params? — rows of key, label?, required?, placeholder? |
The run payload, or triggered |
cron_trigger |
schedule — a cron expression |
The payload, or cron: <schedule> |
file_trigger |
path — a workspace-relative directory |
The changed file’s path |
webhook_trigger |
secret? — a shared secret callers must present |
The request body |
template |
text — interpolated against upstream output |
The interpolated text |
http_request |
url, method? (default GET), headers?, body? |
The response body |
agent |
prompt, plus agent?, tools?, maxTurns?, provider? |
The model’s final prose answer |
read_file |
path |
The file’s contents |
write_file |
path, content |
wrote <path> (<n> bytes) |
review_gate |
— | approved, or approved: <note> |
condition |
input, op, value |
"true" or "false" |
subworkflow |
workflow (an id), input?, repeat? |
The child run’s final output |
A few of these have behaviour worth knowing before you rely on them.
http_request resolves {{secret:NAME}} references in the URL, headers and body at
the moment of the call — after node interpolation, so a secret value never lands in a node
output, the event log, or a model’s context. Requests time out at 15 seconds; a non-2xx
response fails the node naming the host only (never the full URL, which may carry a
token); any secret value the server echoed back is redacted out of the response; and the
body is truncated at 4000 characters with a note saying how much was dropped.
agent takes one of two paths. With config.tools or config.agent set it runs the
full tool loop; with neither, it is a single
streamed completion with no tools at all. config.provider picks a
model for this step — a connected provider’s name, or
local:<name> for a model on this Mac.
condition evaluates a small fixed operator set — truthy (the default), equals,
not_equals, contains, not_contains, matches (a regular expression; an invalid
pattern evaluates to false rather than erroring), is_empty, is_not_empty, gt,
lt — with no eval anywhere near it.
subworkflow loads another workflow by id and runs it as a step, streaming the child’s
own events under the child’s own run id. repeat clamps to 1–50 and appends
[iteration n/N] to the input on each pass. A workflow cannot reference itself, nesting is
capped at depth 5 (which is what catches mutual recursion A→B→A), and a child run that
fails fails the parent node — a dead build never silently flows into QA.
Branching looks like this on the wire — the condition node plus the two when edges
leaving it:
{ "nodes": [ { "id": "gate", "type": "condition", "position": { "x": 580, "y": 160 }, "config": { "input": "{{draft}}", "op": "contains", "value": "BREAKING" } } ], "edges": [ { "from": "gate", "to": "announce", "when": "true" }, { "from": "gate", "to": "publish", "when": "false" } ]}How a run walks the graph
Section titled “How a run walks the graph”Nodes execute in Kahn topological order; a graph with a cycle is rejected before anything
runs. A node is active when it has no incoming edges at all, or when at least one of its
incoming edges was taken. After a node completes, all of its outgoing edges are marked
taken — except on a condition node, where only the edge whose when matches that node’s
"true"/"false" output is taken. Anything left unreached emits node.skipped and never
executes.
Execution is sequential. Parallel branches are an executor upgrade, not a format change: a diamond in the graph runs one side and then the other, in topological order.
{{nodeId}} interpolation reads completed node outputs. A reference to a node that hasn’t
run (or doesn’t exist) is left in the text verbatim, which is usually the fastest way to
spot a typo in a prompt.
Authoring on the canvas
Section titled “Authoring on the canvas”The canvas is fully editable: a node palette in the toolbar, drag to move (positions
persist), drag between handles to connect, Backspace to delete, and a type-aware
inspector for the selected node — or the workflow’s own name, description and group when
nothing is selected. Drafts track dirty state per tab, ⌘S saves, and ⌘⏎ runs (saving
first if dirty, since the engine runs the file on disk).

Selecting a node opens a form built from that node type’s config.
The inspector is built from a per-type field list, so it stays honest about what each node
actually reads: a webhook_trigger shows you its own URL, a manual_trigger gets a row
editor for run parameters, a condition gets the operator dropdown, and an agent node
that references an agent definition gets a read-only card naming the persona, telling you
which other workflows share it, and linking into Settings to edit it.
Authoring as JSON
Section titled “Authoring as JSON”A workflow tab toggles between Canvas and JSON. Both views project the same draft, so an
edit in the JSON appears on the canvas and vice versa; invalid JSON surfaces the parse
error and simply doesn’t commit. With Vim mode on in the editor, :w runs the same save
handler as ⌘S.

Canvas and JSON are two views of one draft.
Authoring in code
Section titled “Authoring in code”The SDK’s defineWorkflow() builder emits the same format, so a code-authored workflow is
an ordinary citizen on the canvas. The direction is one-way by design — code compiles to
graph JSON; there is no round-trip sync to keep honest.
import { defineWorkflow } from "@latchai/sdk";
const wf = defineWorkflow("release-notes", "Release Notes (code-first)", (w) => { w.manualTrigger(); const changes = w.readFile("changes", "input/changes.txt"); const notes = w.agent( "draft", `Turn this raw change list into friendly release notes with a headline:\n\n${changes}` ); w.writeFile("publish", "out/release-notes.md", `# Release Notes\n\n${notes}\n`);});Each builder call returns a step handle whose toString() is {{stepId}} — which is why
dropping ${changes} into a template literal emits exactly the right interpolation token
in the JSON. Steps chain to the previous one by default; pass a handle as the trailing
after argument to fork. Write the returned object to <home>/workflows/<id>.json and it
opens on the canvas like anything else, laid out left to right.
Running
Section titled “Running”- From the canvas —
⌘⏎. - Headless —
npm run run <path-to-workflow.json>, with trailing arguments joined into the run payload amanual_triggerexposes as{{trigger}}. - Over HTTP —
POST /api/runs/<workflow-id>, optionally with a{"payload": "…"}body. - By trigger — see Triggers.
A manual_trigger can declare config.params; the UI asks for those before the run and
joins the answers, in declared order, into that one payload string.
Manual runs go through the same one-run-per-workflow guard that triggers use, so a click can’t overlap a cron fire — the second start is simply refused as busy.
Runs are an event stream
Section titled “Runs are an event stream”The executor is event-sourced. Execution emits an ordered stream — run.started,
node.started / node.delta / node.completed / node.failed / node.skipped,
file.written / file.read / file.edited, tool.call / tool.result, shell.exec,
web.fetch, plan.update, token.usage, permission.denied, gate.waiting /
gate.resolved, checkpoint.created, and finally run.completed or run.failed — and
every surface that shows you a run is a consumer of it. The live canvas animation, the
Run Monitor, the append-only JSONL log in <home>/runs/,
and the brain all read the same events.
One consequence worth internalising: a failed run still resolves. The failure is a
run.failed event in the stream, not an exception thrown at whoever started it — which is
exactly why a subworkflow node checks the child’s status rather than just reading its
output.
Disabling a workflow
Section titled “Disabling a workflow”A workflow may set disabled: true. Its cron, file, and webhook triggers are not armed —
so it cannot fire overnight — while manual runs still work. The sidebar accordion has
per-workflow disable and delete controls.