Skip to content

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.

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.

A workflow on the canvas

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.

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.

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" }
]
}

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.

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).

The node inspector

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.

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.

The JSON view of a workflow

Canvas and JSON are two views of one draft.

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.

  • From the canvas⌘⏎.
  • Headlessnpm run run <path-to-workflow.json>, with trailing arguments joined into the run payload a manual_trigger exposes as {{trigger}}.
  • Over HTTPPOST /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.

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.

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.