What a workflow is
A workflow block has no source code of its own. Instead, its "source" is a JSON graph: a list of nodes (some of which call other blocks) and edges that wire outputs of one node to inputs of the next. The platform's orchestrator topologically sorts the graph and runs each node in turn.
workflow run starts
│
▼
orchestrator parses the graph
│
▼
toposort: input → block-A → block-B → output
│
▼
for each block node, in order:
- resolve inputs (literal | upstream node output)
- launch a child Run for that block
- wait until succeeded
- take its outputs
│
▼
collect output nodes → write to workflow Run.outputs
│
▼
workflow run = succeededEvery block node in the graph spawns its own normal Run row. So a workflow run is really a parent run with N child runs, all linked. You can drill into any child run from the workflow run viewer to see live logs, structured outputs, etc.
When to pick workflow
- You're composing existing blocks into a pipeline: extract → enrich → summarise.
- The pipeline has clear stages and you want to visualise them (the canvas renders the graph).
- Different stages should have different limits / spend caps / model choices — workflows let you set those per node.
- You want to publish the pipeline as its own block: callers run the workflow, not the individual stages.
When NOT to pick workflow
- The pipeline lives entirely inside one program — just write a normal job and run the stages in Python. The orchestrator overhead per node is ~1-2 s; for hundreds of small stages that adds up.
- The control flow is genuinely dynamic (loops, branches that depend on intermediate results). Workflows today are static DAGs — for branching agents, write a single job that loops internally.
- You need real-time streaming between stages. Workflows materialise each node's outputs before starting the next one — there's no way to stream tokens stage-to- stage.
What you'll build
A two-stage workflow called e2e-workflow: take a topic string, define it with the e2e-job block from the job guide, then feed that definition back into e2e-job to define the definition. Output is the meta-definition.
input.topic
│
▼
e2e-job(word=topic) ── outputs.definition
│
▼
e2e-job(word=definition) ── outputs.definition
│
▼
output.meta_definitionTotal: 4 nodes, 3 edges. The reference run finished in ~7 seconds with meta_definition: "Serendipity is finding something wonderful when you weren't looking for it."
1. Create the workflow block
Workflows aren't deployed via gonkablocks deploy — there's no Dockerfile to build. Instead, you create the block from a workflow template via POST /api/blocks:
curl -X POST https://blocks.gonka.gg/api/blocks \
-H "Authorization: Bearer $GONKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"slug": "e2e-workflow",
"name": "e2e-workflow",
"description": "Define topic, then meta-define the definition.",
"category": "misc",
"template": "workflow"
}'
# {"blockId":"cmooh9vwj004w4a1ds0hlpqng","versionId":"cmooh9vws004y4a1dx0qfsd8z"}That gives you an empty workflow block with a stub manifest:
name: e2e-workflow
version: 0.1.0
type: workflow
description: …
inputs: { topic: { type: string, required: true } }
outputs: { result: { type: string } }
runtime: { build: none, entrypoint: workflow }
…The manifest will be re-derived from the graph in the next step — keys you don't use end up dropped. You can also do this entirely from the UI: go to /new, pick "Workflow (visual canvas)", and drag the same nodes onto the canvas. Both routes write the same graph JSON underneath.
2. Graph shape
A graph has nodes and edges. Three node kinds:
input— declares a workflow-level input. Has akey(the input's name) and aninputType(string / integer / number / boolean).block— calls another block. ReferencesblockId(cuid, not slug; resolve the slug first) and an optionalversionId. OptionalliteralInputsfor static values not coming from edges.spendCapCentsbounds how much this single node can spend.output— declares a workflow-level output. Has akey.
Edges define data flow: {from: nodeId, fromOutput: "definition", to: nodeId, toInput: "word"}. Output keys come from the upstream block's declared outputs; value is the canonical key for input nodes.
3. Save the graph
First resolve the block id of the dependency you're calling:
JOB_ID=$(curl -s -H "Authorization: Bearer $GONKA_API_KEY" \ "https://blocks.gonka.gg/api/blocks/resolve?author=admin&slug=e2e-job" \ | jq -r .blockId) # cmoogmeqw00014a1dtbadu4nm
Build the graph (here from a tiny Node helper, but any language works — it's plain JSON):
node -e '
const job = process.env.JOB_ID;
const ver = process.env.WF_VERSION;
const graph = {
nodes: [
{id: "in1", kind: "input", key: "topic", inputType: "string", position: {x: 0, y: 0}},
{id: "n1", kind: "block", blockId: job, literalInputs: {}, spendCapCents: 20, label: "define topic", position: {x: 300, y: 0}},
{id: "n2", kind: "block", blockId: job, literalInputs: {}, spendCapCents: 20, label: "meta-define", position: {x: 600, y: 0}},
{id: "out1", kind: "output", key: "meta_definition", position: {x: 900, y: 0}},
],
edges: [
{id: "e1", from: "in1", fromOutput: "value", to: "n1", toInput: "word"},
{id: "e2", from: "n1", fromOutput: "definition", to: "n2", toInput: "word"},
{id: "e3", from: "n2", fromOutput: "definition", to: "out1", toInput: "value"},
],
};
require("fs").writeFileSync("/tmp/wf.json", JSON.stringify({versionId: ver, graph}));
' JOB_ID=$JOB_ID WF_VERSION=cmooh9vws004y4a1dx0qfsd8zSave it:
curl -X PUT https://blocks.gonka.gg/api/blocks/$WF_ID/workflow \
-H "Authorization: Bearer $GONKA_API_KEY" \
-H "Content-Type: application/json" \
--data @/tmp/wf.json
# {"ok":true,"manifest":"{\"name\":\"e2e-workflow\",\"version\":\"0.1.0\", ...}"}Saving rebuilds the manifest:
- The platform inspects every
inputnode and produces a typedinputs:section. - Every
outputnode becomes an entry inoutputs:. - Every
blocknode becomes aworkflow.steps[]entry using the human-readable@author/slugform, so the manifest is readable, not full of cuids.
On the canvas, this happens every time you press Save. From the API, every PUT does the same.
4. Publish
Saving the graph marks the version buildStatus: ready (workflows have no real build step). Publishing makes the block public and points currentVersionId at the new version.
curl -X POST https://blocks.gonka.gg/api/blocks/$WF_ID/publish \
-H "Authorization: Bearer $GONKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
# {"ok":true}From the canvas, click Publish. Same effect.
5. Run it
Same gonkablocks run as for any other block — the type is invisible to the caller.
$ gonkablocks run admin/e2e-workflow topic=serendipity
starting run…
✓ run started: https://blocks.gonka.gg/runs/cmoohbiwm00504a1ddk9zzvqv
• running
• succeeded
outputs: {"meta_definition":"Serendipity is finding something wonderful when you weren't looking for it."}Behind the scenes the orchestrator just spawned two childe2e-job runs in sequence and stitched their outputs together. You can see them in your runs list:
$ gonkablocks runs succeeded e2e-job cmoohbky500634a1d5h1ipvuc <-- node n2 succeeded e2e-job cmoohbiz6005c4a1dbifeck15 <-- node n1 succeeded e2e-workflow cmoohbiwm00504a1ddk9zzvqv <-- the parent
The workflow run viewer shows the same graph from the canvas, with each node coloured by its child-run status — very handy for debugging.
Data flow & types
Edges carry typed values. The orchestrator coerces between compatible types but doesn't hide errors:
- string ↔ string — passes through verbatim.
- integer / number / boolean — passed as their typed value into the next block's env. So an input declared
type: integerarrives as the string"42"inINPUT_*(env vars are always strings; coerce inside the block). - json / array — JSON-serialised on the way out, JSON-decoded on the way in (parse with
json.loads). - file — the file path is what flows through. Be careful: paths are valid only inside the producing container; for cross-block file passing, write to platform object storage first and pass the URL.
literalInputs on a block node are static values, applied only for inputs not already wired by an edge. Edges always win.
Spend caps & failure modes
Every block node has its own spendCapCents. The workflow run as a whole inherits the sum implicitly — if any node hits its cap, that node fails, which fails the workflow.
By default:
- A failed child run = the workflow run fails. The error surfaces on the parent with the child run id, so you can drill in.
- The orchestrator never retries a failed child automatically. Wrap retry logic inside the block if it makes sense for your case (most LLM blocks already do).
- Subsequent nodes don't start. There's no continue-on-error today.
If a node has multiple downstream consumers, its outputs are materialised once and reused — no re-running.
Iterate
Workflows version like any other block:
- Save the graph → updates the existing version (auto-bumps to
buildStatus: ready). - To create a new version: bump the version field via the canvas's "New version" button (or POST a new version via the API), then save the graph against the new version id, then publish.
- Older versions stay runnable — handy if you broke something and want to roll back without redeploying.
Pitfalls I actually hit
- Forgetting to substitute values into the JSON payload. When constructing the graph from a shell, env vars must actually be available to the interpreter you're using. I lost a few minutes to
node -e '…' JOB_ID=$JOB_ID— that's a positional arg to the script, not an env var. Either prefix env vars before the command (JOB_ID=… node -e '…') or build the JSON from a real script file. Symptom: the API returns{"error":"invalid input"}becauseblockIdended upundefined(then stripped by JSON.stringify). - Wrong output keys. Edges reference
fromOutput: "definition"— that name must match a key in the upstream block's declaredoutputs:. Mismatches are caught at workflow run time as a validation error, not at save time. Open the upstream block's manifest to copy the exact keys. - Cycles silently fail. The graph schema allows you to write a cycle; toposort detects it at run time and fails the workflow. Eyeball the graph in the canvas before publishing, especially after refactors.
- Dependency block must be public (or yours). You can reference any block you own, plus any public block. Forking someone else's private block is required if you want to use it in your own workflow.
- Per-node spend caps lie. Sort of — they're per child run. If a single node spawns a workflow that itself spawns sub-runs, each leaf run gets the cap independently. Set caps with the leaf cost in mind.
Next steps
- Author your workflow visually instead of via API: open
/newand pick the workflow template. The canvas writes the same graph JSON. - Make this workflow externally callable like any other block — see the external-access guide.
- Schedule it to run nightly: wrap it in a thin worker that POSTs to
/api/runswith the workflow's slug and your inputs.