Skip to main content

Workflows

A workflow is a saved sequence of blocks. It can run code in seven languages, call saved HTTP endpoints and MCP tools, ask an AI model, branch, and loop. You can start it by hand or with a trigger. Each workflow is one YAML file, and each run gets its own folder.

The course runs in order from Stage 0 to Stage 10. Each stage is a section in the sidebar, and each lesson takes 5–20 minutes.

  • 0 · Orientation

    What a workflow is, how to switch Workflows on, and a tour of the page.

  • 1 · First workflow

    Build, save, run, and inspect a workflow, then pass a value from one block to the next.

  • 2 · Programming model

    How code blocks receive input and publish output: TERMORQ_INPUT, uses, output schemas, ok, Execute step, Code file, and Git file.

  • 3 · Seven languages

    The same input/output contract in Bash, PowerShell, Python, C#, Rust, JavaScript, and TypeScript.

  • 4 · Connecting blocks

    HTTP endpoints, MCP tools, AI models, and ✦ Autogenerate: reuse saved connections instead of writing clients.

  • 5 · Control flow

    Branch with Decision, iterate with Loop, and route with then, break, continue, and disabled nodes.

  • 6 · YAML authoring

    Write complete workflow files by hand: root keys, block keys per kind, templates, and validation.

  • 7 · Runs & ops

    Run folders, run history, pause and resume, notices and settings, termorq-cli, and troubleshooting.

  • 8 · Triggers

    Start workflows automatically: schedule, startup, after another workflow, file change, HTTP, and Telegram.

  • 9 · Capture & share

    Turn a terminal session into a workflow draft, then export, import, and share workflows with a team.

  • 10 · Capstone

    One HTTP-triggered workflow that uses every runnable block type and all seven languages.

Subsections of Workflows

Stage 0 · Orientation

Before you build anything, learn the terms, check your edition and runtimes, and learn where everything is on the Workflows page.

Subsections of 0 · Orientation

What is a workflow?

Goal

Learn the terms used in the rest of the course. Time: 5 minutes. Needs: nothing.

A workflow is a sequence you’ll want to run again. In Termorq it is one YAML file. The Editor tab shows it as a canvas and the YAML tab shows the same file as text. Changes on one tab appear on the other, because both edit the same file.

The vocabulary

TermMeaning
Block (or node)One step. A node on the canvas is a block in the YAML.
KindThe block’s type: code, code_file, http, mcp, ai, decision, or loop. The editor also lists git (Git file), but it isn’t runnable yet.
PropertyA named value that a block publishes, such as steps.lookup.total.
Output schemaThe outputs: declaration listing the properties a block promises to publish and their types.
usesThe list of properties a code block reads. Termorq writes those properties into a JSON file for the block.
RunOne execution. Each run gets its own folder with run.json, logs, and a files/ directory.
TriggerWhat starts the workflow automatically: a schedule, app startup, another workflow, a file change, an HTTP request, or a Telegram message. Manual Run is always available.

How data flows

flowchart LR
  T["Trigger<br/>trigger.text"] --> A["Code block<br/>uses: trigger.text"]
  A -->|"steps.a.message"| B["HTTP block<br/>{{ steps.a.message }}"]
  B -->|"steps.b.status"| C{"Decision<br/>steps.b.status == 200"}
  C -->|when| D["AI block"]
  C -->|else| E["Code block"]

Data moves forward only through declared properties:

  • A code block lists the paths it needs under uses. At run time it reads them from a JSON file whose path is in TERMORQ_INPUT.
  • An HTTP, MCP, or AI block uses {{ steps.<id>.<property> }} templates in its fields.
  • Every block publishes properties. A later block reads them as steps.<block id>.<property>.

Each code block runs as its own process. A shell variable set in one block doesn’t exist in the next one. To pass a value on, publish it as a property.

Workflows are not runbooks

Termorq also has collection runbooks, Markdown runbooks, and repo runbooks. These are separate features, and nothing in this course changes them.

Check your run

You’re ready to move on if you can answer these: How does block B read a value from block A? (A declares it in outputs, and B lists steps.A.<name> in uses or uses a {{ }} template.) Does export X=1 in one Bash block reach the next block? (No. Each block is its own process.)

Exercise

Draw on paper the data flow for: “every morning, call an API, and if it returns any users, summarise them with AI”. Label each arrow with the property it carries.

Details

Schedule trigger (every: 1d) → HTTP block list_users (publishes status, body, and count) → Decision (steps.list_users.count > 0) → AI block summarise (prompt contains {{ steps.list_users.body }}) → publishes summary.

Reference: Build a workflow

Enable Workflows and install runtimes

Goal

Switch Workflows on and confirm that the runtimes you plan to use are on PATH. Time: 10 minutes. Needs: Termorq Pro (or Trial).

Pro feature

Free builds don’t offer the experimental flag or the Workflows page. The edition pill (FREE, TRIAL, or PRO) is at the right of the header.

Steps

  1. Open Settings → Experimental.
  2. Turn on Workflows. It is off by default.
  3. Open the page with Cmd+Shift+G (macOS) or Ctrl+Shift+G (Windows/Linux). You can also open the jump list with Ctrl+J and choose Workflows, or type @flow.

If you later turn the flag off, Termorq hides the page’s entry points and workflow settings. A workflow that is already open stays open until you leave it.

Check your runtimes

Termorq doesn’t bundle language runtimes. The code runner uses whatever is installed on your computer. Run these commands in a Termorq terminal tab:

​
bash --version | head -1
jq --version            # used by Bash JSON examples
pwsh --version          # PowerShell
python3 --version
dotnet --version        # needs 10.0 or later for C#
rustc --version         # needs Rust 2024 edition support
node --version          # 22.6+ runs TypeScript natively
bash --version          # Git Bash or WSL exposed on PATH
jq --version
pwsh --version
python --version
dotnet --version
rustc --version
node --version
LanguageNeeds
Bashbash on PATH. Workflow Bash is always Bash, even if your terminal tab runs another shell.
PowerShellpwsh (runs with -NoProfile -NonInteractive -ExecutionPolicy Bypass)
PythonPython 3 as python3 or python (runs unbuffered with -u)
C#.NET SDK 10+ (single-file app via dotnet run)
Rustrustc with edition 2024 (--edition 2024 -O), without Cargo
JavaScriptNode.js (runs as an ES module, snippet.mjs)
TypeScriptNode.js 22.6+ native type stripping, or a tsx executable
Warning

If you install a runtime while Termorq is open, restart Termorq so it can find the runtime.

Check your run

The Workflows page opens and lists at least the Included workflows, such as Refresh Acme users and Probe mock. Each runtime you plan to use prints a version.

Exercise

Find out which Node.js version you have. Will TypeScript blocks run natively, or will they need tsx?

Details

node --version 22.6 or newer: native type stripping is used, even if tsx is also installed. Older: install tsx (npm i -g tsx) and restart Termorq.

Reference: Node types and code languages

Tour the Workflows page

Goal

Find every part of the Workflows page you’ll use in later stages. Time: 10 minutes. Needs: Workflows enabled.

The list

Workflow list with trigger filters, multi-select export, and row actions Workflow list with trigger filters, multi-select export, and row actions

  • The All, Yours, Included, and Workspace chips filter by source.
  • The trigger pills filter by trigger type: Manual, Schedule, At startup, After workflow, File change, HTTP request, Telegram.
  • Each row shows the trigger, last edited and run times, run count, disk use, and the latest result. Its actions are Runs, Edit, Copy, Export, and Delete. Delete appears when you hover over or focus the row.
  • Included workflows are read-only. Use Copy to make your own editable version. A copy starts with its triggers paused.

The editor

Canvas with Trigger, Greet, and Shout blocks, and the Code tab of the properties inspector Canvas with Trigger, Greet, and Shout blocks, and the Code tab of the properties inspector

AreaWhat it’s for
CanvasBlocks run left to right in file order. Drag a block’s output connector onto empty canvas to add a block, or use + between blocks. Arrange lays the flow out for you, Fit zooms to show everything, and the minimap helps you move around large workflows.
Properties inspector (right)Has five tabs: Settings (name, Node execution), Input (read-only Available data with type pills), Code, Schema, and Output.
Settings (beside Editor)Workflow name, working folder, block timeout, and description (Markdown).
YAML tabThe same workflow as text. Tab completes keys, legal values, and {{ }} properties.
TriggersChoose the one active automatic trigger.
RunsThis workflow’s run history.

Nodes aligned with Arrange Nodes aligned with Arrange

Keyboard and navigation

ActionKeys
Open or close WorkflowsCmd/Ctrl+Shift+G
Jump listCtrl+J → Workflows
Open a workflow by name@flow Refresh Acme users
Pan the canvasHold Space and drag, or use the middle mouse button
Close a pickerEsc

Workflows in the jump list Workflows in the jump list

Runs keep going after you leave the page. When a run is in progress, the footer shows a chip such as 1 workflow running. Click it to go back to the page.

Check your run

Open the included Refresh Acme users workflow. Find its four blocks on the canvas, then open the YAML tab and find the same four id: lines.

Exercise

Copy Probe mock so that you have an editable version under Yours. What happened to its triggers?

Details

The copy gets a new, unique ID and no run history, and its automatic triggers start paused. You’ll learn how to arm triggers in Stage 8.

Reference: Build a workflow

Stage 1 · Your first workflow

In this stage you build a working workflow from scratch, run it, and pass a value from one block to the next.

Subsections of 1 · First workflow

Hello, workflow (60 seconds)

Goal

Create a workflow with one Bash block that publishes ok: true. Time: 5 minutes. Needs: Bash on PATH.

Steps

  1. Open Workflows (Cmd/Ctrl+Shift+G) and choose New workflow.

  2. Under Settings (next to Editor), name it Hello workflow.

  3. Add a Code block. New Code blocks start as Bash.

  4. In Properties → Code, set the snippet to:

    echo '{"ok":true}'
  5. Open Properties → Schema and add a property named ok with type boolean.

  6. Click Save, then Run.

The same workflow as YAML

Open the YAML tab. You should see something like this. The block ID is generated, so yours may differ.

schema: 1
name: Hello workflow
id: hello-workflow
blocks:
  - id: hello
    name: Hello
    kind: code
    language: bash
    body: |
      echo '{"ok":true}'
    outputs:
      type: object
      properties:
        ok:
          type: boolean
Tip

You can also paste YAML straight into the YAML tab and switch back to Editor. The rest of the course often gives you YAML to paste, because it’s the quickest way to get to a known starting point.

Check your run

Check your run
  • The card shows Running, then Done.
  • Properties → Output shows ok: true, together with exit: 0 and status: ok.
  • Your terminal stayed usable throughout, because workflows don’t block the prompt.

Why the last line matters

Because the block declares an output (ok), the last non-empty line of stdout must be one JSON object, and that object must include ok as a boolean. Stage 2 covers this rule in detail.

Exercise

Change the snippet to print Starting… before the JSON line. Does the block still pass? Then try printing Done after the JSON.

Details

echo Starting… followed by echo '{"ok":true}' passes, because only the last non-empty line is parsed. Printing Done after the JSON fails with The last non-empty line of code output must be a JSON object. Send diagnostic messages to stderr (echo Done >&2) instead.

Reference: Build a workflow → In 60 seconds

Pass a value to the next block

Goal

Build a two-block workflow where block 2 reads a value that block 1 published. Time: 10 minutes. Needs: Python 3.

Steps

  1. Create a new workflow and paste this into the YAML tab:
schema: 1
name: Pass a value
id: pass-a-value
blocks:
  - id: make_name
    name: Make name
    kind: code
    language: python
    body: |
      import json
      print(json.dumps({"name": "Ada"}))
    outputs:
      type: object
      properties:
        name:
          type: string

  - id: greet
    name: Greet
    kind: code
    language: python
    uses: [steps.make_name.name]
    body: |
      import json, os
      with open(os.environ["TERMORQ_INPUT"], encoding="utf-8") as f:
          data = json.load(f)
      name = data["steps"]["make_name"]["name"]
      print(json.dumps({"greeting": f"Hello, {name}!"}))
    outputs:
      type: object
      properties:
        greeting:
          type: string
  1. Switch to Editor. Each card shows steps.<id> near its lower edge.
  2. Select Greet and open Properties → Input. Available data lists steps.make_name.name with a string pill.
  3. Click Save, then Run.

What happened

  • make_name printed {"name": "Ada"} as its last line. Because name is declared in outputs, Termorq published it as steps.make_name.name.

  • greet listed steps.make_name.name under uses. Termorq wrote a temporary JSON file containing only that value, keeping its nesting:

    {"steps":{"make_name":{"name":"Ada"}}}
  • TERMORQ_INPUT holds the path to that file, not the JSON itself.

Warning

Code bodies are not template-expanded. Writing {{ steps.make_name.name }} inside Python source doesn’t insert the value. Always use uses and read the input file.

Check your run

Check your run

Greet → Output shows greeting: "Hello, Ada!". On the Runs tab, select the latest run: the Greet row shows the value passed through uses.

Exercise

Add a third block, shout, that reads steps.greet.greeting and publishes it upper-cased as loud.

Details
  - id: shout
    kind: code
    language: python
    uses: [steps.greet.greeting]
    body: |
      import json, os
      data = json.load(open(os.environ["TERMORQ_INPUT"], encoding="utf-8"))
      print(json.dumps({"loud": data["steps"]["greet"]["greeting"].upper()}))
    outputs:
      type: object
      properties:
        loud:
          type: string

Save, and fix what Save rejects

Goal

Break a workflow on purpose, read the Not saved panel, and fix it. Time: 5 minutes.

Save checks the draft before writing anything. If the check fails, Termorq doesn’t write the file, doesn’t leave the page, and doesn’t start a run. The Not saved panel lists up to 20 reasons, one per line:

Line 19: property 'foo' is not recognised.
Line 23, column 13: malformed YAML.

Steps

  1. Open your Pass a value workflow in YAML.
  2. Add colour: blue under the greet block and click Save. The panel says property 'colour' is not recognised.
  3. Change uses: [steps.make_name.name] to uses: [steps.make_name.nme] and click Save again. Save says 'steps.make_name.nme' is not a property this block can use., because make_name doesn’t declare nme.
  4. Undo both changes and Save.

Rules the checker enforces

RuleExample
Workflow id: a letter, then letters, numbers, or hyphensrefresh-acme-users ✓ · 1st_flow ✗
Block id: letters, numbers, or underscoreslist_users ✓ · list-users ✗
schema, name, id, blocks are required'blocks' is missing.
At most 40 blocks per file, including nested loop bodiesa workflow can have 40 blocks.
Each block allows only the keys valid for its kindproperty 'url' is not recognised. on a code block
Every uses path and {{ }} template must name something reachable: a declared earlier output, a declared inputs.<name>, a declared secrets.<name>, a supported trigger.<field>, or loop.* inside a loop'inputs.city' is not a property this block can use.
Names are unique, ignoring capitalisation and surrounding spacesTwo workflows named Deploy and deploy can’t both exist

Unsaved edits

  • The title shows Unsaved until you save.
  • Run uses the draft on screen. It doesn’t write the file.
  • If you leave with unsaved changes (via Back, Esc, the shortcut, or the jump list), Termorq asks Stay, Discard, or Save. Focus starts on Stay.
Check your run

After you undo your changes, Save succeeds and the Unsaved marker disappears.

Exercise

Rename the block make_name to make-name (with a hyphen). How many reasons does Save report, and why?

Details

At least two. The ID itself is invalid, because block IDs can’t contain hyphens. The greet block’s uses also still points at steps.make_name.name, which no longer exists. Renaming a producer means updating every consumer.

Reference: Build a workflow → YAML

Run, watch, and inspect

Goal

Learn what each run state looks like, and find a failed block quickly. Time: 10 minutes.

Watching a run

On the canvasMeaning
Accent border and sheen, with a dot travelling to the next blockRunning
PulseWaiting
One shake, then redFailed. The editor selects the block, scrolls to it, and opens Output with the error
SkippedThe node is disabled, or a decision didn’t route to it

With prefers-reduced-motion: reduce, the colours and labels stay and the motion is turned off.

A run in progress: finished blocks marked Done, Format Markdown running, and later blocks waiting A run in progress: finished blocks marked Done, Format Markdown running, and later blocks waiting

Output tab of the properties inspector showing the Tally block’s published JSON Output tab of the properties inspector showing the Tally block’s published JSON

Make it fail

  1. In Pass a value, change the greet body’s last line to print("not json").
  2. Click Run. Greet turns red, and Output says the last non-empty line must be a JSON object.
  3. Open the Runs tab and select the run. Every block captured at the start of the run is listed. Blocks that never ran are marked not run.
  4. Use Copy input and Copy output on a block to copy exactly what it received and returned. Secret values stay masked.
  5. Fix the line and run again.

Runs tab with five runs listed and the selected run’s details and block input and output Runs tab with five runs listed and the selected run’s details and block input and output

Pause and Stop

  • Pause run lets the current block finish, then holds the next one.
  • Stop ends the process tree immediately, without asking for confirmation.
Check your run

Runs lists two runs: one failed and one successful. Each has a local date and time.

Exercise

Add import time; time.sleep(10) to the start of Make name, run the workflow, and click Pause run straight away. What does Greet show while the run is paused?

Details

Make name finishes, because Pause waits for the active block. Greet is held and doesn’t start until you Resume. Stage 7 covers Edit rest and Apply on paused runs.

Stage 2 · The programming model

Every Code and Code file block in every language follows the same contract:

flowchart LR
  U["uses:<br/>trigger.text<br/>steps.lookup.total"] --> F["Temp JSON file (0600)<br/>{trigger:{text}, steps:{lookup:{total}}}"]
  F -->|"path in TERMORQ_INPUT"| P["Your program<br/>(cwd = run files/)"]
  P -->|"stderr: diagnostics"| L["Block log"]
  P -->|"last non-empty stdout line:<br/>one JSON object"| O["Published properties<br/>steps.&lt;id&gt;.&lt;prop&gt;"]

Once you understand this stage, you can write blocks in any of the seven supported languages.

Subsections of 2 · Programming model

TERMORQ_INPUT is a path

Goal

Read workflow input from TERMORQ_INPUT the right way. Time: 10 minutes. Needs: Python 3 and Bash with jq.

The rule

TERMORQ_INPUT is an environment variable that contains a file path. Open that file, then parse its contents.

  • It’s not the JSON itself. Don’t run json.loads(os.environ["TERMORQ_INPUT"]).
  • stdin is closed, so reading stdin gets you nothing.
  • Code bodies are not template-expanded, so {{ ... }} inside source code stays as literal text.

The file contains only the paths listed in uses, nested exactly as their dotted paths:

uses:
  - trigger.text
  - steps.lookup.total
{"trigger":{"text":"Hello"},"steps":{"lookup":{"total":2}}}

You read data["trigger"]["text"], not a flattened data["text"].

Try it

Paste this workflow. It uses a mock HTTP block, so it makes no network calls:

schema: 1
name: Read input
id: read-input
blocks:
  - id: lookup
    kind: http
    method: GET
    url: mock://users
    outputs:
      type: object
      properties:
        path:
          type: string
  - id: show
    kind: code
    language: python
    uses: [steps.lookup.status, steps.lookup.path]
    body: |
      import json, os, sys
      path = os.environ["TERMORQ_INPUT"]
      print(f"input file: {path}", file=sys.stderr)
      with open(path, encoding="utf-8") as f:
          data = json.load(f)
      print(json.dumps({"summary": f"{data['steps']['lookup']['path']} -> {data['steps']['lookup']['status']}"}))
    outputs:
      type: object
      properties:
        summary:
          type: string
Check your run

show → Output contains summary: "users -> 200". The stderr line shows a temporary path. The file is created with mode 0600 on Unix and deleted when the block ends.

When there’s no input

  • If uses is empty, a full run provides no input file, so TERMORQ_INPUT is unset.
  • If a listed path isn’t available, for example because the trigger didn’t provide trigger.text, the block fails before your code starts. Listing a path doesn’t give it a default value.
Tip

Request only the values a block needs. This is good data minimisation, because a block can’t leak data it never received. It isn’t a sandbox, though: your code still runs with your user’s permissions.

Exercise

Rewrite show in Bash using jq. It should publish the same summary property.

Details
set -euo pipefail
jq -c '{summary: "\(.steps.lookup.path) -> \(.steps.lookup.status)"}' "$TERMORQ_INPUT"

Set language: bash on the block. jq -c prints a single compact line, which satisfies the last-line rule.

Reference: What TERMORQ_INPUT is for

Every path you can put in uses

Goal

Know every source a block can read from, and which ones Save accepts in each position. Time: 10 minutes.

PathSourceValid when
inputs.<name>Workflow input, such as an HTTP request body field or a value you enter when you run the workflow<name> is declared under the root inputs: key
trigger.textText from an HTTP or Telegram triggerAlways accepted by Save. Present at run time only if the trigger supplies it
trigger.argsThe text after a Telegram commandTelegram trigger with a command
trigger.chat_id, trigger.message_id, trigger.from_usernameTelegram message metadataTelegram trigger
trigger.path, trigger.received_atThe changed fileFile trigger
steps.<id>.<prop>A property published by an earlier block<prop> is declared in that block’s outputs, or is always published (status, exit, duration_ms)
steps.<loop id>.resultsEvery pass of a loopAfter the loop
loop.item, loop.index, loop.countThe current loop pass. loop.index starts at 1Only inside a loop body
secrets.<name>The secret store, resolved at run time<name> is listed under the root secrets: key

Declaring inputs and secrets

schema: 1
name: Greet a city
id: greet-city
secrets:
  - WEATHER_API_KEY
inputs:
  city:
    type: string
blocks:
  - id: greet
    kind: code
    language: python
    uses: [inputs.city, secrets.WEATHER_API_KEY]
    body: |
      import json, os
      data = json.load(open(os.environ["TERMORQ_INPUT"], encoding="utf-8"))
      key_present = bool(data["secrets"]["WEATHER_API_KEY"])
      print(json.dumps({"line": f"Hello {data['inputs']['city']}", "has_key": key_present}))
    outputs:
      type: object
      properties:
        line:
          type: string
        has_key:
          type: boolean
Warning

Never print a secret. Termorq redacts known secret values from logs and masks them in the run record, but the safest secret is one that never reaches stdout. Keep secrets out of input_sample too.

Check your run

Delete secrets: and click Save. Save reports 'secrets.WEATHER_API_KEY' is not a property this block can use. Put it back and the workflow saves.

Exercise

Move uses: [loop.item] onto a top-level block (one that isn’t in a loop). What does Save say?

Details

'loop.item' is not a property this block can use. loop.* paths are only valid inside a loop’s body. See Stage 5.

Publishing output and the output schema

Goal

Publish typed properties that downstream blocks can rely on. Time: 15 minutes.

Schema tab of the properties inspector listing breaking, features, fixes, and score as numbers Schema tab of the properties inspector listing breaking, features, fixes, and score as numbers

Two modes

ModeWhenWhat gets published
StructuredThe block declares custom properties under outputsThe last non-empty stdout line must be one JSON object containing every declared property with the declared type
PlainThere are no custom output declarationstext (raw stdout), exit (exit code), and status: ok

Both modes also record exit, status, and duration_ms. A non-zero exit code fails the block.

The schema

outputs:
  type: object
  properties:
    count:   { type: number }
    names:   { type: array, items: { type: string } }
    ready:   { type: boolean }
    meta:    { type: object }
    token:   { type: string, secret: true }

The schema supports the keys type, properties, items, enum, secret, and required. Termorq checks each declared property for presence and type (string, number, boolean, object, or array). The run fails if a property is missing or has the wrong type, or if the last line isn’t a JSON object.

A property marked secret: true is drawn in the warning colour on the canvas and masked in run records.

Try it: three ways to fail

Start with this block, which passes:

schema: 1
name: Output rules
id: output-rules
blocks:
  - id: emit
    kind: code
    language: python
    body: |
      import json, sys
      print("diagnostics go to stderr", file=sys.stderr)
      print("an early stdout line is fine")
      print(json.dumps({"count": 2, "names": ["Ada", "Lin"]}))
    outputs:
      type: object
      properties:
        count: { type: number }
        names: { type: array, items: { type: string } }

Now make each of these changes in turn and run the workflow again:

  1. Change "count": 2 to "count": "2". The type check fails.
  2. Use json.dumps(..., indent=2). The output is pretty-printed, so the last line is just }. It fails.
  3. Print [1,2] last. An array isn’t an object, so it fails.
Warning

text is reserved for raw stdout. Name your business data something else, such as message, summary, or body_text. Also, outputs isn’t a file name: Termorq doesn’t read a file to find your output.

Artifacts are different

To produce a file, such as a CSV report, write it to $TERMORQ_OUTPUT, which is the run’s files/ directory. Writing a file there doesn’t publish a property. To tell later blocks where the file is, publish its name:

import json, os
out = os.path.join(os.environ["TERMORQ_OUTPUT"], "report.csv")
open(out, "w").write("name\nAda\n")
print(json.dumps({"report": "report.csv"}))
Check your run

After the three failing experiments, restore the passing version. emit → Output shows count: 2 and names: ["Ada","Lin"]. Its Input tab, as seen from a later block, shows number and array type pills.

Exercise

Add an enum so that a level property must be low, medium, or high, and publish "medium".

Details
    outputs:
      type: object
      properties:
        level:
          type: string
          enum: [low, medium, high]

Body: print(json.dumps({"level": "medium"})).

Reference: Publishing code output

Returning ok

Goal

Return ok (on its own, or with other properties) correctly in every language. Time: 5 minutes.

ok is an ordinary boolean property, but it’s an important convention. Shell blocks generated by session capture always end by printing {"ok":true} and declare ok: boolean. The editor’s insertable examples include Return a successful result and Return ok and a property for every language.

If a block declares ok, the last line must include it as a boolean. The string "true" fails.

outputs:
  type: object
  properties:
    ok: { type: boolean }
    summary: { type: string }
​
summary="3 files cleaned"
# jq -c keeps it on one line and escapes the string safely
jq -nc --arg summary "$summary" '{ok: true, summary: $summary}'
$summary = '3 files cleaned'
@{ ok = $true; summary = $summary } | ConvertTo-Json -Compress
import json
print(json.dumps({"ok": True, "summary": "3 files cleaned"}, separators=(",", ":")))
using System.Text.Json.Nodes;
Console.WriteLine(new JsonObject { ["ok"] = true, ["summary"] = "3 files cleaned" }.ToJsonString());
fn main() {
    // No JSON library without Cargo: only safe when summary has no quotes, backslashes, or newlines.
    let summary = "3 files cleaned";
    println!("{{\"ok\":true,\"summary\":\"{}\"}}", summary);
}
console.log(JSON.stringify({ ok: true, summary: "3 files cleaned" }));
const result: { ok: boolean; summary: string } = { ok: true, summary: "3 files cleaned" };
console.log(JSON.stringify(result));
Tip

Command Prompt isn’t one of the workflow languages. To run a cmd.exe command, use a PowerShell block with the editor’s Run a Command Prompt command example. It runs the command through %ComSpec% and exits with the command’s failure code.

Check your run

Each variant publishes ok: true and summary: "3 files cleaned".

Exercise

Make the Bash version publish ok: false when a directory called logs doesn’t exist in the run’s working directory, without failing the block.

Details
if [ -d logs ]; then ok=true; else ok=false; fi
jq -nc --argjson ok "$ok" '{ok: $ok, summary: "checked logs"}'

The script exits with 0, so the block passes. A later Decision can then branch on steps.<id>.ok == true.

Test one block with Execute step

Goal

Iterate on a single code block in seconds, without triggers or a new entry on Runs. Time: 10 minutes.

Code node editor with sample input, parameters, and output panels Code node editor with sample input, parameters, and output panels

Steps

  1. Select a Code block and choose Open code editor.
  2. Type JSON or plain text in Step input.
  3. Choose Execute step. Only this block runs. The output panel shows stdout, errors, and published properties.
  4. Copy output copies everything in that panel.
  5. Save the workflow to keep the sample as input_sample.

How Step input becomes the input file

You typeTERMORQ_INPUT file contains
A JSON object, array, number, boolean, or nullThat JSON
Plain text (including a JSON string value)Raw text. It’s also available as inputs.text and trigger.text
Nothing{}
Text starting with { or [ that isn’t valid JSONRejected
Important

In Execute step, Step input is the file. It isn’t built from uses. To reproduce a full run, type the same nested shape that uses would produce, for example {"trigger":{"text":"Hello"}}, not {"text":"Hello"}.

input_sample in YAML

- id: echo
  kind: code
  language: python
  uses: [trigger.text]
  input_sample: |-
    {"trigger":{"text":"Hello from curl"}}
  body: |
    import json, os
    data = json.load(open(os.environ["TERMORQ_INPUT"], encoding="utf-8"))
    print(json.dumps({"message": data["trigger"]["text"]}))
  outputs:
    type: object
    properties:
      message: { type: string }

input_sample is a test fixture. A trigger or a full run never uses it as a default. A passing Execute step doesn’t prove that the real trigger supplies every path in uses.

Check your run

Execute step shows message: "Hello from curl", and the Runs tab gets no new entry.

Exercise

Type the plain text Hello (without quotes) into Step input. The block above fails. Change the body so it handles both the nested JSON shape and plain text.

Details
import json, os
raw = open(os.environ["TERMORQ_INPUT"], encoding="utf-8").read()
try:
    text = json.loads(raw)["trigger"]["text"]
except (ValueError, KeyError, TypeError):
    text = raw
print(json.dumps({"message": text}))
Note

Execute step is available for inline Code blocks. AI blocks have the equivalent Open AI editor (Stage 4). Test Code file blocks with a full run.

Working directory, environment, and secrets

Goal

Understand where your code runs and which environment variables it can see. Time: 10 minutes.

Working directory

Each code block starts in the run’s files/ directory:

workflows/runs/<workflow id>/<run id>/
├── run.json        # the run record
├── blocks/         # masked per-block logs
└── files/          # cwd for code; artifacts go here

Your project folder (the workflow’s folder setting) isn’t the working directory, and Termorq never changes it. Use $TERMORQ_FOLDER to read from it.

Variables Termorq sets

VariableValue
TERMORQ_INPUTPath to the input file (only set when the block has inputs)
TERMORQ_OUTPUTThe run’s files/ directory
TERMORQ_FOLDERThe workflow’s configured folder
TERMORQ_WORKFLOWThe workflow ID
TERMORQ_RUNThe run ID
TERMORQ_BLOCKThe current block ID

The env map

The root env: map adds variables to every code block. Values are templates, so you can pass a secret to trusted code this way:

schema: 1
name: Env demo
id: env-demo
folder: ~/src/acme-apis
timeout_secs: 120
secrets:
  - ACME_TOKEN
env:
  ACME_REGION: eu-west
  ACME_TOKEN: "{{ secrets.ACME_TOKEN }}"
blocks:
  - id: where
    kind: code
    language: bash
    body: |
      echo "cwd=$PWD" >&2
      echo "project=$TERMORQ_FOLDER run=$TERMORQ_RUN" >&2
      test -n "$ACME_TOKEN" && has=true || has=false
      jq -nc --arg r "$ACME_REGION" --argjson has "$has" '{region:$r, has_token:$has}'
    outputs:
      type: object
      properties:
        region: { type: string }
        has_token: { type: boolean }

Timeouts

timeout_secs (default 600) limits how long each block can run. You can change the default for new workflows under Settings → Workflows.

Check your run

The stderr log shows cwd=…/runs/env-demo/<run id>/files, and the output is region: "eu-west".

Exercise

Set timeout_secs: 2 and add sleep 5 to the block. What happens?

Details

The block is stopped after 2 seconds and the run fails. Use short timeouts for blocks that call services that sometimes hang.

Code file: keep source in your repo

Goal

Move a block’s source code into a file that’s under source control. Time: 10 minutes.

Code file (kind: code_file) works exactly like Code: it has the same input file, the same last-line rule, and the same seven languages. The difference is that the source lives at path, resolved relative to the workflow’s folder.

Steps

  1. Set the workflow’s folder to a project, for example ~/src/acme-apis.

  2. Save this as scripts/echo.py in that folder:

    import json, os, sys
    data = json.load(open(os.environ["TERMORQ_INPUT"], encoding="utf-8"))
    print(json.dumps({"message": data["trigger"]["text"], "argv": sys.argv[1:]}))
  3. Add the block:

- id: from_file
  name: Echo from file
  kind: code_file
  path: scripts/echo.py
  args: --mode fast --verbose
  uses: [trigger.text]
  outputs:
    type: object
    properties:
      message: { type: string }
      argv: { type: array }

Rules

  • Language is inferred from the extension: .sh (and unknown extensions) → bash, .ps1 → powershell, .py → python, .cs → csharp, .rs → rust, .js/.mjs → javascript, .ts → typescript. Set language: explicitly for an unusual extension.
  • args is a single string split on whitespace. Shell quoting is not applied: args: --name "Ada Lovelace" becomes three arguments, --name, "Ada, and Lovelace". Pass structured values through uses and the input file instead.
  • The source is copied into a build directory, and the working directory is the run’s files/. Relative imports and companion files next to the original don’t resolve. Keep scripts self-contained, or use explicit paths built from $TERMORQ_FOLDER.
  • Code file isn’t a project launcher: it doesn’t run cargo, npm install, or .csproj builds.
  • Code file has no Execute step. Test it with a full run (you’ll need a trigger that supplies trigger.text, or use inputs).
Check your run

steps.from_file.argv is ["--mode","fast","--verbose"].

Exercise

Change args to --greeting "hello world". Predict argv before you run it.

Details

["--greeting", "\"hello", "world\""]. The value is split on whitespace and the quotes stay as literal characters. That’s why structured values should go through uses.

Git file (not runnable yet)

Goal

Know what the Git file node does today, and run a file from a repository safely. Time: 5 minutes.

Not runnable yet

The editor offers Git file (kind: git) with fields for a public repository, ref, path, and inferred language. The runner rejects kind: git. A workflow that contains it won’t save or run, even if the editor verified the repository.

Use Code file after checkout: clone or check out the repository locally, review the revision, then point a code_file block at it.

The workaround

# One-time (or in a setup step you trust): check out a reviewed revision
git clone https://github.com/acme/scripts ~/src/acme-scripts
git -C ~/src/acme-scripts checkout v1.4.2
schema: 1
name: Run reviewed script
id: run-reviewed-script
folder: ~/src/acme-scripts
blocks:
  - id: report
    kind: code_file
    path: tools/report.py
    outputs:
      type: object
      properties:
        ok: { type: boolean }

Pinning to a tag or commit you’ve reviewed means the code you run is code you’ve read. That’s also why this route is safer than running whatever happens to be on a remote branch.

Check your run

Save accepts the code_file version. If you put kind: git in YAML, Save reports 'kind' must be code, code_file, http, mcp, ai, decision, loop.

Exercise

Write the YAML for a Code file block that runs scripts/build.ps1 from your checkout with the args -Configuration Release.

Details
- id: build
  kind: code_file
  path: scripts/build.ps1
  args: -Configuration Release

.ps1 means the language is inferred as powershell.

Stage 3 · Seven languages

Code and Code file blocks accept seven language values. Every lesson in this stage builds the same block: it reads trigger.text and publishes it as message. That way you can compare the languages side by side.

Starter workflow

Paste this once, then swap in each lesson’s language and body:

schema: 1
name: Seven languages
id: seven-languages
blocks:
  - id: echo
    name: Echo
    kind: code
    language: python
    uses: [trigger.text]
    input_sample: |-
      {"trigger":{"text":"Hello from curl"}}
    body: |
      # replace with the lesson's source
    outputs:
      type: object
      properties:
        message:
          type: string

Test each one with Open code editor → Execute step.

At a glance

LanguagelanguageRunnerJSON in the standard library?
BashbashbashNo. Use jq
PowerShellpowershellpwsh -NoProfile -NonInteractive -ExecutionPolicy BypassYes (ConvertFrom-Json)
Pythonpythonpython3 -uYes (json)
C#csharpdotnet run snippet.cs (.NET 10+)Yes (System.Text.Json)
Rustrustrustc --edition 2024 -ONo, and there’s no Cargo
JavaScriptjavascriptnode snippet.mjs (ES module)Yes (JSON)
TypeScripttypescriptNode 22.6+ native, or tsxYes (JSON)
Read trigger.text → publish message
set -euo pipefail
jq -ce '{message: .trigger.text}' "$TERMORQ_INPUT"
$data = Get-Content -Raw -LiteralPath $env:TERMORQ_INPUT | ConvertFrom-Json
@{ message = $data.trigger.text } | ConvertTo-Json -Compress
import json, os
data = json.load(open(os.environ["TERMORQ_INPUT"], encoding="utf-8"))
print(json.dumps({"message": data["trigger"]["text"]}))
using System.Text.Json;
using System.Text.Json.Nodes;
using var doc = JsonDocument.Parse(File.ReadAllText(Environment.GetEnvironmentVariable("TERMORQ_INPUT")!));
var text = doc.RootElement.GetProperty("trigger").GetProperty("text").GetString();
Console.WriteLine(new JsonObject { ["message"] = text }.ToJsonString());
use std::{env, fs};
fn main() {
    let input = fs::read(env::var("TERMORQ_INPUT").unwrap()).unwrap();
    println!("{{\"input_bytes\":{}}}", input.len()); // declare input_bytes: number
}
import { readFileSync } from "node:fs";
const data = JSON.parse(readFileSync(process.env.TERMORQ_INPUT, "utf8"));
console.log(JSON.stringify({ message: data.trigger.text }));
import { readFileSync } from "node:fs";
type Input = { trigger: { text: string } };
const data = JSON.parse(readFileSync(process.env.TERMORQ_INPUT!, "utf8")) as Input;
console.log(JSON.stringify({ message: data.trigger.text }));

The short versions above leave out validation. Each lesson shows the production version, which validates its input.

  • Bash

    jq-based JSON handling, strict mode, and returning compact output.

  • PowerShell

    Raw reads, ConvertFrom-Json, and compact ConvertTo-Json output.

  • Python

    Standard-library JSON, stderr diagnostics, and Unicode-safe output.

  • C#

    .NET 10 file-based apps, System.Text.Json, and reflection-free output.

  • Rust

    rustc without Cargo: standard library only, byte-level input, and careful output.

  • JavaScript

    Node.js ES modules, node: built-ins, and JSON.stringify output.

  • TypeScript

    Native Node type stripping, erasable syntax, and runtime validation.

Subsections of 3 · Seven languages

Bash

Goal

Write a Bash block that validates its input and publishes JSON safely. Time: 10 minutes. Needs: bash and jq on PATH.

language: bash
set -euo pipefail
: "${TERMORQ_INPUT:?TERMORQ_INPUT is missing}"
jq -ce '
  if (.trigger.text | type) == "string" then
    {message: .trigger.text}
  else
    error("trigger.text must be a string")
  end
' "$TERMORQ_INPUT"

Things to know

  • Workflow Bash is always Bash, even if your terminal tab runs zsh or fish. On Windows, install Git Bash or expose WSL’s bash on PATH.
  • set -euo pipefail together with jq -e makes parse and validation errors fail the block.
  • Build output with jq -n --arg or --argjson. Never build JSON with echo "{\"message\":\"$x\"}", because quotes or newlines in $x will break it.
  • .sh files, and files with unrecognised extensions, default to Bash in Code file.
  • ✦ Autogenerate writes Bash that reads each value with jq.
Check your run

Execute step with the starter sample prints {"message":"Hello from curl"}. Step input {"trigger":{"text":42}} fails with trigger.text must be a string.

Exercise

Publish words (number): the number of whitespace-separated words in trigger.text.

Details
set -euo pipefail
text=$(jq -r '.trigger.text' "$TERMORQ_INPUT")
words=$(wc -w <<<"$text" | tr -d ' ')
jq -nc --argjson words "$words" '{words: $words}'

Declare words: { type: number }.

PowerShell

Goal

Write a PowerShell block that reads nested JSON and publishes one compact line. Time: 10 minutes. Needs: pwsh.

language: powershell
$ErrorActionPreference = 'Stop'
if ([string]::IsNullOrWhiteSpace($env:TERMORQ_INPUT)) {
    throw 'TERMORQ_INPUT is missing'
}
$data = Get-Content -Raw -LiteralPath $env:TERMORQ_INPUT | ConvertFrom-Json
$text = $data.trigger.text
if ($text -isnot [string]) {
    throw 'trigger.text must be a string'
}
@{ message = $text } | ConvertTo-Json -Compress -Depth 10

Things to know

  • Files run with -NoProfile -NonInteractive -ExecutionPolicy Bypass, so your profile isn’t loaded.
  • -Raw reads the whole file. -LiteralPath stops PowerShell treating [ and * in the path as wildcards.
  • Always use -Compress. Without it, ConvertTo-Json prints several lines and breaks the last-line rule. Add -Depth for nested objects.
  • $ErrorActionPreference = 'Stop' turns non-terminating errors into failures.
  • Captured Command Prompt commands become PowerShell blocks that call cmd.exe through %ComSpec%.
Check your run

Execute step prints {"message":"Hello from curl"}.

Exercise

Publish upper (the text in upper case) and length (number) in the same object.

Details
$data = Get-Content -Raw -LiteralPath $env:TERMORQ_INPUT | ConvertFrom-Json
$t = [string]$data.trigger.text
@{ upper = $t.ToUpperInvariant(); length = $t.Length } | ConvertTo-Json -Compress

Python

Goal

Write the standard Python block, which most later lessons use. Time: 5 minutes. Needs: Python 3.

language: python
import json
import os

with open(os.environ["TERMORQ_INPUT"], encoding="utf-8") as stream:
    data = json.load(stream)
text = data["trigger"]["text"]
if not isinstance(text, str):
    raise ValueError("trigger.text must be a string")
print(json.dumps({"message": text}, ensure_ascii=False))

Things to know

  • The runner looks for python3, then python, and runs it unbuffered (-u). Print statements appear in order.
  • An uncaught exception exits with a non-zero code, so the block fails and the traceback appears in the error output.
  • ensure_ascii=False keeps Unicode readable. The output is valid JSON either way.
  • Use print(..., file=sys.stderr) for diagnostics.
  • Only the standard library is guaranteed. If you pip install something, it’s installed for that interpreter on this computer, not for the workflow.
Check your run

Execute step with {"trigger":{"text":"Grüße 👋"}} publishes message: "Grüße 👋".

Exercise

Publish slug: the text in lower case, with runs of non-alphanumeric characters replaced by -, and leading and trailing - removed.

Details
import json, os, re
text = json.load(open(os.environ["TERMORQ_INPUT"], encoding="utf-8"))["trigger"]["text"]
print(json.dumps({"slug": re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")}))

C#

Goal

Write a C# block as a .NET file-based app. You don’t need a project file. Time: 10 minutes. Needs: .NET SDK 10 or later.

language: csharp
using System;
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;

var path = Environment.GetEnvironmentVariable("TERMORQ_INPUT")
    ?? throw new InvalidOperationException("TERMORQ_INPUT is missing");
using var input = JsonDocument.Parse(File.ReadAllText(path));
var value = input.RootElement.GetProperty("trigger").GetProperty("text");
if (value.ValueKind != JsonValueKind.String)
    throw new InvalidOperationException("trigger.text must be a string");

Console.WriteLine(new JsonObject { ["message"] = value.GetString() }.ToJsonString());

Things to know

  • The runner runs dotnet run snippet.cs using file-based apps. Use top-level statements: you don’t need a class or Main.
  • Build output with JsonObject.ToJsonString(). Reflection-based JsonSerializer.Serialize(anonymousObject) may be disabled in file-based apps.
  • The first run of a snippet compiles it, so expect a few seconds of start-up.
  • An unhandled exception exits with a non-zero code, so the block fails.

Reading plain text

For Execute step with plain text:

using System.Text.Json.Nodes;
var text = File.ReadAllText(Environment.GetEnvironmentVariable("TERMORQ_INPUT")!);
Console.WriteLine(new JsonObject { ["message"] = text }.ToJsonString());
Check your run

Execute step prints {"message":"Hello from curl"}.

Exercise

Publish reversed: the text with its characters in reverse order.

Details
using System.Text.Json;
using System.Text.Json.Nodes;
using var doc = JsonDocument.Parse(File.ReadAllText(Environment.GetEnvironmentVariable("TERMORQ_INPUT")!));
var t = doc.RootElement.GetProperty("trigger").GetProperty("text").GetString() ?? "";
var arr = t.ToCharArray(); Array.Reverse(arr);
Console.WriteLine(new JsonObject { ["reversed"] = new string(arr) }.ToJsonString());

Rust

Goal

Write a Rust block that works without any crates. Time: 10 minutes. Needs: rustc with edition 2024 support.

language: rust
outputs:
  type: object
  properties:
    input_bytes:
      type: number
use std::{env, error::Error, fs};

fn main() -> Result<(), Box<dyn Error>> {
    let path = env::var("TERMORQ_INPUT")?;
    let input = fs::read(path)?;
    println!("{{\"input_bytes\":{}}}", input.len());
    Ok(())
}

Things to know

  • The runner compiles with rustc --edition 2024 -O, then runs the binary. There’s no Cargo, so crates such as serde_json aren’t available.
  • The standard library has no JSON parser. Use Rust for byte, line, or number processing, and use another language for JSON field transformations.
  • Use format! and println! for output. Escape { and } by doubling them. Only interpolate values that can’t contain quotes, backslashes, or newlines, or escape them yourself.
  • Returning Err from main exits with a non-zero code, so the block fails.
Check your run

With the exact compact sample {"trigger":{"text":"Hello from curl"}} (no trailing newline), the output is {"input_bytes":38}. That’s the UTF-8 byte count of the whole file.

Exercise

Publish lines (number): the number of lines in the input file. Test it with multi-line plain text in Step input.

Details
use std::{env, fs};
fn main() {
    let text = fs::read_to_string(env::var("TERMORQ_INPUT").unwrap()).unwrap();
    println!("{{\"lines\":{}}}", text.lines().count());
}

JavaScript

Goal

Write a JavaScript block as an ES module. Time: 5 minutes. Needs: Node.js.

language: javascript
import { readFileSync } from "node:fs";

const path = process.env.TERMORQ_INPUT;
if (!path) throw new Error("TERMORQ_INPUT is missing");
const data = JSON.parse(readFileSync(path, "utf8"));
const text = data?.trigger?.text;
if (typeof text !== "string") {
  throw new Error("trigger.text must be a string");
}
console.log(JSON.stringify({ message: text }));

Things to know

  • The runner writes your code to snippet.mjs, so it’s an ES module: use import, not require. Top-level await works.
  • For Code file, .js and .mjs are inferred as JavaScript.
  • Node built-ins (node:fs, node:path, and fetch) are available. npm packages aren’t installed for you.
  • Never use JSON.stringify(obj, null, 2) for the final line, because it prints several lines.
Check your run

Execute step prints {"message":"Hello from curl"}.

Exercise

Publish chars: an array of the unique characters in the text, sorted.

Details
import { readFileSync } from "node:fs";
const { trigger } = JSON.parse(readFileSync(process.env.TERMORQ_INPUT, "utf8"));
console.log(JSON.stringify({ chars: [...new Set(trigger.text)].sort() }));

Declare chars: { type: array, items: { type: string } }.

TypeScript

Goal

Write a TypeScript block that runs on Node’s native type stripping. Time: 10 minutes. Needs: Node.js 22.6+ (or tsx).

language: typescript
import { readFileSync } from "node:fs";

type Input = { trigger?: { text?: unknown } };
const path = process.env.TERMORQ_INPUT;
if (!path) throw new Error("TERMORQ_INPUT is missing");
const data = JSON.parse(readFileSync(path, "utf8")) as Input | null;
const text = data?.trigger?.text;
if (typeof text !== "string") {
  throw new Error("trigger.text must be a string");
}
console.log(JSON.stringify({ message: text }));

Things to know

  • Termorq prefers native Node (22.6+), which strips types without type-checking them and ignores tsconfig.json. If native support isn’t available, it uses a tsx executable instead.
  • Use only erasable syntax: type annotations, type, interface, and as. Avoid enum, namespace, and parameter properties, because native stripping can’t run them. See Node.js TypeScript support.
  • Types don’t validate JSON at run time. Keep the typeof checks.
Check your run

Execute step prints {"message":"Hello from curl"}. {"trigger":{}} fails with trigger.text must be a string.

Exercise

Accept an inputs.names array (declare inputs: names: {type: array, items: {type: string}} and uses: [inputs.names]), then publish count and first.

Details
import { readFileSync } from "node:fs";
type In = { inputs: { names: string[] } };
const { inputs } = JSON.parse(readFileSync(process.env.TERMORQ_INPUT!, "utf8")) as In;
if (!Array.isArray(inputs.names)) throw new Error("inputs.names must be an array");
console.log(JSON.stringify({ count: inputs.names.length, first: inputs.names[0] ?? "" }));

Test it with the Step input {"inputs":{"names":["Ada","Lin"]}}.

Stage 4 · Connecting blocks

Choose the smallest block that does the job:

NeedBlockWhy
Call an API you’ve already saved in TermorqHTTP endpoint (http)It reuses the saved connection, headers, and credentials, so you don’t write an HTTP client
Call a tool on a saved MCP serverMCP tool (mcp)It reuses the server’s transport and authentication, and the tool’s input contract
Make a judgement call: summarise, classify, or interpretAI (ai)It sends a prompt to a saved OpenAI-compatible model
Transform or validate data, or compute somethingCodeCovered in Stages 2–3

Unlike code bodies, HTTP, MCP, and AI fields are templates: {{ steps.x.y }} is replaced with the value before the call is made. The editor shows these as chips.

  • HTTP with mock://

    Learn the HTTP block offline: status, body, and top-level JSON properties.

  • HTTP with a saved endpoint

    Reuse a saved endpoint and its credentials, and serialise a JSON body safely in code first.

  • MCP tool

    Call a saved MCP server tool with JSON arguments, and read content vs structuredContent.

  • AI block

    Prompt a saved model, get a structured summary, and test it with the AI editor.

  • ✦ Autogenerate

    Generate language-correct code that reads an earlier node, and add missing fields to its schema.

Subsections of 4 · Connecting blocks

HTTP with mock://

Goal

Learn what an HTTP block publishes, without needing a network connection or credentials. Time: 5 minutes.

A url beginning with mock:// returns a canned 200 response. It makes no network request. Direct URLs are accepted only for mock://. To call a real API, you use a saved endpoint, which is covered in the next lesson.

schema: 1
name: HTTP mock
id: http-mock
blocks:
  - id: ping
    kind: http
    method: GET
    url: mock://users
    outputs:
      type: object
      properties:
        path: { type: string }
        mock: { type: boolean }
  - id: read
    kind: code
    language: python
    uses: [steps.ping.status, steps.ping.path, steps.ping.body]
    body: |
      import json, os
      d = json.load(open(os.environ["TERMORQ_INPUT"], encoding="utf-8"))["steps"]["ping"]
      print(json.dumps({"line": f"{d['status']} {d['path']} ({len(d['body'])} bytes)"}))
    outputs:
      type: object
      properties:
        line: { type: string }

What HTTP publishes

PropertyTypeNotes
statusnumberAlways available. A non-success status fails the block (HTTP 404.)
bodystringThe response body, capped at 4,096 bytes in the record
top-level JSON keysvariesWhen the response is a JSON object. Declare the ones you need in outputs. The mock body is {"ok":true,"mock":true,"method":"GET","path":"users"}

Templates work in url too, for example url: "mock://users/{{ loop.index }}".

Check your run

read publishes line: "200 users (…bytes)".

Exercise

Change the method to POST and publish the method property from the mock response.

Details

Add method: { type: string } to ping.outputs.properties, then read steps.ping.method in a later block. It is "POST".

HTTP with a saved endpoint

Goal

Send a POST request with a JSON body built from earlier output. Time: 15 minutes. Needs: a saved HTTP endpoint named Create message that accepts {"message": "..."}.

Steps

  1. Add an HTTP endpoint block and choose your saved endpoint in Properties. Its path parameters, query string, headers, and payload are copied onto the block.
  2. Build the request body in a Code block first. Don’t put text between JSON quotes in a template, because quotes and newlines in user input would break the JSON.
- id: prepare_http
  kind: code
  language: python
  uses: [steps.echo.message]
  body: |
    import json, os
    data = json.load(open(os.environ["TERMORQ_INPUT"], encoding="utf-8"))
    payload = {"message": data["steps"]["echo"]["message"]}
    print(json.dumps({"request_json": json.dumps(payload)}))
  outputs:
    type: object
    properties:
      request_json: { type: string }

- id: send_http
  kind: http
  endpoint: Create message
  method: POST
  body: "{{ steps.prepare_http.request_json }}"
  outputs:
    type: object
    properties:
      body: { type: string }
Warning

Don’t do this: body: '{"message":"{{ steps.echo.message }}"}'. If the message is She said "hi", the result is invalid JSON. Serialise the whole body in code and insert it as one value.

Credentials

The endpoint’s saved credentials stay in the OS keychain and are read during credential preflight, before the run starts. macOS decides whether to show a Keychain prompt. Cancel run is available while preflight is in progress. To use a secret directly, declare it under secrets: and use it in a header template such as {{ secrets.API_TOKEN }}. Termorq masks it in the record.

Check your run

On Runs, the send_http row shows the resolved request and status (for example 201). steps.send_http.body holds the response.

Exercise

Add a Decision after send_http that goes to a log_failure block when steps.send_http.status isn’t 201. (Decisions are covered in Stage 5.)

Details
- id: check_created
  kind: decision
  branches:
    - when: steps.send_http.status == 201
      goto: done
    - else: true
      goto: log_failure

A non-2xx status already fails the HTTP block. This decision handles unexpected successes, such as 200 instead of 201.

MCP tool

Goal

Call a tool on a saved MCP server and use its result. Time: 15 minutes. Needs: a saved MCP server (here Demo tools) with an echo tool that accepts {"message":"..."}.

Steps

  1. Add an MCP tool block. Choose the saved server, connect it, then choose the tool. Its parameters are copied onto the block.
  2. Supply arguments as a template that resolves to one JSON object. As with HTTP bodies, serialise it in code first.
- id: echo_tool
  kind: mcp
  server: Demo tools
  tool: echo
  arguments: "{{ steps.prepare_http.request_json }}"
  outputs:
    type: object
    properties:
      content: { type: array }

- id: read_tool
  kind: code
  language: python
  uses: [steps.echo_tool.content]
  body: |
    import json, os
    content = json.load(open(os.environ["TERMORQ_INPUT"], encoding="utf-8"))["steps"]["echo_tool"]["content"]
    texts = [c.get("text", "") for c in content if c.get("type") == "text"]
    print(json.dumps({"reply": " ".join(texts)}))
  outputs:
    type: object
    properties:
      reply: { type: string }

What MCP publishes

  • status: ok, a text representation of the result, and the top-level properties of the MCP result, such as content or structuredContent.
  • JSON inside a content[].text string is not turned into workflow properties automatically. Parse it in a Code block.
  • If the arguments don’t resolve to a JSON object, the block fails with MCP arguments must resolve to a JSON object.

When a run starts, Termorq connects to the saved server automatically if it isn’t connected yet. If the block fails, open @logs to see whether the credential lookup or the connection failed.

Check your run

steps.echo_tool.content looks like [{"type":"text","text":"{\"message\":\"Hello\"}"}] (the exact shape depends on the tool), and read_tool publishes it as reply.

Exercise

Your tool returns structuredContent: {"count": 3}. How do you read count in the next block?

Details

Declare structuredContent: { type: object } in the MCP block’s outputs, add uses: [steps.echo_tool.structuredContent] to the next block, and read data["steps"]["echo_tool"]["structuredContent"]["count"]. Or let ✦ Autogenerate infer the fields from the last run (see Autogenerate).

AI block

Goal

Ask a model for a one-sentence summary and use it in a later block. Time: 15 minutes. Needs: a saved OpenAI-compatible model connection (here Summary model).

- id: summarise
  kind: ai
  connection: Summary model
  system: 'Return a single JSON object with a string property named "summary".'
  prompt: "Summarise this message in one sentence: {{ steps.echo.message }}"
  max_tokens: 128
  input_sample: |-
    {"trigger":{"text":"Deploy finished in 4m with 2 warnings"}}
  outputs:
    type: object
    properties:
      summary: { type: string }

How it works

  • system and prompt are templates. Values you insert are sent to the model, and secrets are masked in the run record.
  • The block always publishes text (the reply) and model.
  • If you declare another property, such as summary, Termorq asks the model to end its reply with one JSON object and reads that final line. If the reply doesn’t end with that object, the block fails.
  • A new AI block starts with a prompt that already includes earlier properties, and a default summary output.

Test it in isolation

Select the block and choose Open AI editor. Type JSON or plain text in Step input and choose Execute step:

Step inputAvailable in the prompt as
{"trigger":{"text":"Hello"}}{{ trigger.text }}
{"name":"Ada"}{{ inputs.name }}
Hello (plain text){{ trigger.text }} and {{ inputs.text }}

The editor shows the response, errors, published output, and any skills used. Isolated tests don’t start triggers or create a Runs entry. input_sample is saved with the workflow, but a full run never uses it.

Warning

Model output is data, not a decision. Validate it in Code or a Decision before you act on it, for example by checking summary isn’t empty and is under 200 characters.

Check your run

The AI editor shows a reply ending in a line like {"summary":"The deploy completed in four minutes with two warnings."}, and Output lists summary. The exact wording varies from run to run.

Exercise

Change the block to classify the message as info, warning, or error, publishing severity constrained by enum.

Details
  system: 'Classify the message. Reply with one JSON object: {"severity": "info"|"warning"|"error"}.'
  prompt: "{{ steps.echo.message }}"
  outputs:
    type: object
    properties:
      severity:
        type: string
        enum: [info, warning, error]

✦ Autogenerate

Goal

Let Termorq write the input-reading code and the uses list for you. Time: 10 minutes.

Autogenerate panel with an HTTP source node selected Autogenerate panel with an HTTP source node selected

Steps

  1. Open a Code block’s code editor and click where you want the code to go. The panel remembers the cursor position. If you never clicked in the editor, the code is appended at the end.
  2. Choose ✦ Autogenerate on the Snippet row.
  3. Choose the Source node. The node wired into this block is selected first. The list shows how many steps back each node is, and shows input if the workflow declares inputs.
  4. Review the fields:
    • AI, and nodes with a complete schema: lists their declared outputs and says Schema complete — nothing to add.
    • HTTP: status (number) is always included. body and any top-level JSON keys from the last run are marked Will add to this node’s output schema.
    • MCP, and code without a schema: shows an inferred checklist (text, tool-result fields, or keys from the last run). Fields whose type can’t be inferred are shown in amber with a type picker, defaulting to string. Unchecked fields are left out.
  5. Check the Preview, which is written in the block’s language. If code above the cursor already opens TERMORQ_INPUT, the preview reuses that variable. Bash previews use jq.
  6. Choose Insert, or Add schema & Insert if fields will be added to the source node’s schema.

What Insert changes

  • The code goes on its own lines, tinted teal with an Inserted at cursor (or Appended) badge.
  • Each path, such as steps.fetch_users.status, is added to this block’s uses.
  • Any new fields are added to the source node’s output schema, so Available data and Save accept them.
  • Undo in the notice restores the snippet, uses, and the source schema together. The tint and the notice last 10 seconds by default. You can change this to between 1 and 60 seconds under Settings → Workflows → Code editor.
Tip

Run the workflow once before you use Autogenerate on an HTTP or MCP source. The panel infers fields from the last run, so it can offer real keys rather than guesses.

Check your run

In the HTTP mock workflow, add a Python block after ping and autogenerate from ping. The preview reads data["steps"]["ping"]["status"], and after Insert, uses contains steps.ping.status.

Exercise

Autogenerate the same block in Bash. What does the preview use to read values?

Details

jq, for example status=$(jq -r '.steps.ping.status' "$TERMORQ_INPUT"). The exact generated lines may differ slightly between releases.

Reference: Autogenerate code from an earlier node

Stage 5 · Control flow

By default, blocks run top to bottom in file order. This stage shows you four ways to change that order:

  • Decision: choose one route.

  • Loop: repeat a frame of blocks.

  • then: jump to a named block, or to break or continue inside a loop.

  • Node execution off (enabled: false): skip a block without deleting it.

  • Decision: choose one route

    Write when conditions, route with goto, and rejoin branches with then.

  • Loop: for each item

    Iterate an array, read loop.item and loop.index, and collect results.

  • Loop: repeat, while, break, and continue

    Fixed-count and condition-controlled loops, plus then: break and then: continue.

  • Routing, skipping, and disabled nodes

    then targets, Node execution off, disconnected wires, and how Runs shows skipped blocks.

Subsections of 5 · Control flow

Decision: choose one route

Goal

Route to one of two blocks based on an earlier value, then rejoin. Time: 15 minutes.

Decision “Any failures?” with WHEN and ELSE routes that converge on Write summary, and its When, Go to, and Else settings in the inspector Decision “Any failures?” with WHEN and ELSE routes that converge on Write summary, and its When, Go to, and Else settings in the inspector

schema: 1
name: Decision demo
id: decision-demo
inputs:
  text:
    type: string
blocks:
  - id: echo
    kind: code
    language: python
    uses: [inputs.text]
    body: |
      import json, os
      d = json.load(open(os.environ["TERMORQ_INPUT"], encoding="utf-8"))
      print(json.dumps({"message": d["inputs"]["text"].strip()}))
    outputs:
      type: object
      properties:
        message: { type: string }

  - id: choose
    kind: decision
    branches:
      - when: steps.echo.message != ""
        goto: has_text
      - else: true
        goto: empty_text

  - id: has_text
    kind: code
    language: python
    body: |
      import json; print(json.dumps({"route": "non-empty"}))
    outputs:
      type: object
      properties:
        route: { type: string }
    then: joined

  - id: empty_text
    kind: code
    language: python
    body: |
      import json; print(json.dumps({"route": "empty"}))
    outputs:
      type: object
      properties:
        route: { type: string }
    then: joined

  - id: joined
    kind: http
    url: mock://done

Rules

  • Termorq evaluates branches in order, and the first true when wins.
  • else: true is the required fallback.
  • goto names a block ID. Blocks that aren’t chosen are marked Skipped.
  • Set then on each branch block so the routes converge. Without it, has_text would carry on to empty_text, because that’s next in file order.
  • A shared continuation must not require outputs from both branches, because one of them didn’t run.

Condition language

Conditions compare workflow values. They’re not Bash, JavaScript, or C#.

FormExample
Compare: == != < <= > >=steps.lookup.total > 0
Contains (string or array)steps.lookup.names contains "Ada"
Exists (postfix)steps.lookup.names exists
Combine with and, or, ( )steps.lookup.names exists and steps.lookup.total >= 1
Literals"text", 'text', 42, true, false
Any pathsteps.*, inputs.*, trigger.*, loop.*

In the editor, the larger When box suggests properties from earlier blocks. Drag the When and Else handles onto blocks to set their targets.

Check your run

Run with text = hi: has_text runs, empty_text is Skipped, and joined runs. Run again with an empty value and the opposite route runs.

Exercise

Add a third route: if the message contains urgent, go to an escalate block first.

Details

Put it first, because the first true branch wins:

    branches:
      - when: steps.echo.message contains "urgent"
        goto: escalate
      - when: steps.echo.message != ""
        goto: has_text
      - else: true
        goto: empty_text

Give escalate a then: joined as well.

Loop: for each item

Goal

Run a block once per array item and use the collected results. Time: 15 minutes.

Loop “Each issue” with a nested AI block in its Loop body frame, and For each and Max in the inspector Loop “Each issue” with a nested AI block in its Loop body frame, and For each and Max in the inspector

schema: 1
name: Loop names
id: loop-names
blocks:
  - id: names
    kind: code
    language: python
    body: |
      import json; print(json.dumps({"items": ["Ada", "Lin", "Grace"]}))
    outputs:
      type: object
      properties:
        items: { type: array, items: { type: string } }

  - id: each_name
    kind: loop
    for_each: steps.names.items
    max: 20
    body:
      - id: greet
        kind: code
        language: python
        uses: [loop.item, loop.index, loop.count]
        body: |
          import json, os
          d = json.load(open(os.environ["TERMORQ_INPUT"], encoding="utf-8"))["loop"]
          print(json.dumps({"greeting": f"Hello {d['item']} ({d['index']}/{d['count']})"}))
        outputs:
          type: object
          properties:
            greeting: { type: string }

  - id: count_passes
    kind: code
    language: python
    uses: [steps.each_name.results]
    body: |
      import json, os
      r = json.load(open(os.environ["TERMORQ_INPUT"], encoding="utf-8"))["steps"]["each_name"]["results"]
      print(json.dumps({"count": len(r), "first": r[0]["greet"]["greeting"]}))
    outputs:
      type: object
      properties:
        count: { type: number }
        first: { type: string }

Loop facts

FactValue
Loop contextloop.item (the current element), loop.index (starts at 1), loop.count
Outputsteps.<loop id>.results: one object per pass, keyed by child block ID, plus status and exit
maxDefaults to 20, and the ceiling is 100
Passes per run100 in total, across all loops
NestingUp to 2 levels deep
Blocks per file40, including loop bodies

In the editor, a loop is a frame. Add blocks with the + inside the frame, or drag existing blocks into it. While it runs, the frame shows progress such as 3 of 12. Keep the - id: of each child indented under body: in YAML.

Check your run

count_passes publishes count: 3 and first: "Hello Ada (1/3)". On Runs, greet appears three times, once per pass.

Exercise

Replace the inline names block with a workflow input: declare inputs: names: {type: array, items: {type: string}} and loop over inputs.names.

Details
inputs:
  names:
    type: array
    items: { type: string }
blocks:
  - id: each_name
    kind: loop
    for_each: inputs.names
    body: [ ...same greet block... ]

When you press Run, enter the names. An HTTP trigger can supply them as {"names":["Ada","Lin"]}.

Loop: repeat, while, break, and continue

Goal

Use the other two loop modes and leave a loop early. Time: 15 minutes.

repeat: a fixed count

- id: poll
  kind: loop
  repeat: 3
  body:
    - id: ping
      kind: http
      url: "mock://status/{{ loop.index }}"

while: repeat while a condition holds

The condition is checked before each pass. The body must change the value it tests, otherwise the loop only stops at max.

schema: 1
name: Countdown
id: countdown
blocks:
  - id: start
    kind: code
    language: python
    body: |
      import json; print(json.dumps({"more": True}))
    outputs:
      type: object
      properties:
        more: { type: boolean }
  - id: drain
    kind: loop
    while: steps.start.more == true
    max: 5
    body:
      - id: work
        kind: code
        language: python
        uses: [loop.index]
        body: |
          import json, os
          i = json.load(open(os.environ["TERMORQ_INPUT"], encoding="utf-8"))["loop"]["index"]
          print(json.dumps({"done_at": i}))
        outputs:
          type: object
          properties:
            done_at: { type: number }
        then: break

repeat and while loops don’t have loop.item, so leave it out of uses.

break and continue

Inside a loop body, then accepts two special targets:

then:Effect
breakLeave the loop. The run continues after the loop
continueSkip the rest of this pass and start the next one

Outside a loop, the run fails: ‘break’ is only valid inside a loop.

A common pattern is a Decision inside the body whose branches end in then: continue or then: break:

body:
  - id: check
    kind: decision
    branches:
      - when: loop.item == "stop"
        goto: halt
      - else: true
        goto: process
  - id: halt
    kind: http
    url: mock://halt
    then: break
  - id: process
    kind: http
    url: "mock://item/{{ loop.index }}"
    then: continue
Check your run

Countdown runs work once (done_at: 1), then leaves the loop because of then: break. Without break, it would stop at max: 5, because steps.start.more never changes.

Exercise

Set max: 101 on any loop, then save and run it. What happens? Then try a loop of 60 nested inside another loop of 2.

Details

Save accepts it (it’s a number), but the run fails with The loop maximum is 101, and the ceiling is 100. The nested 2 × 60 = 120 passes goes over the 100-passes-per-run limit, so the run fails with This run reached 100 passes. or The loop would run … passes, and this run allows 100.

Routing, skipping, and disabled nodes

Goal

Control exactly which blocks run without deleting any. Time: 10 minutes.

Ways a block doesn’t run

CauseHow it showsYAML
Node execution is offStill visible, marked Skippedenabled: false
A decision chose another routeSkipped—
A block before it failednot run—
You disconnected its only incoming wireIt’s disabled automatically and Skipped, even if file order would reach itenabled: false
A then jumped past itIt doesn’t runthen: <id>

Steps

  1. Open Decision demo from the previous lesson.
  2. Select joined and turn Node execution off in Settings. Run the workflow: joined is shown as Skipped.
  3. Turn it back on. Use the small × on the wire into joined to disconnect it. Because that was its only explicit incoming route, it’s disabled.
  4. Drag from has_text’s output handle back onto joined to restore then: joined.
Note

If you disconnect a decision route, it falls through to the next block in YAML order. If a route already has a target and you choose a new node, the new node is inserted before that target.

Check your run

Runs → (latest) lists every block captured at the start. Disabled and unrouted blocks are visible with their skipped or not-run state.

Exercise

Write a YAML fragment where block a jumps straight to c, so that b never runs, without using a decision.

Details
- id: a
  kind: http
  url: mock://a
  then: c
- id: b
  kind: http
  url: mock://b
- id: c
  kind: http
  url: mock://c

Stage 6 · YAML authoring

The YAML is the workflow. Anything you can do on the canvas, you can write by hand, review in a pull request, and store in a team workspace.

Subsections of 6 · YAML authoring

Anatomy of a workflow file

Goal

Recognise every root key and write a complete file from memory. Time: 15 minutes.

The same workflow as YAML The same workflow as YAML

Annotated example

schema: 1                        # required: 1, or 2 when you use `target`
name: Refresh Acme users         # required: unique, ignoring case and surrounding spaces
id: refresh-acme-users           # required: a letter, then letters, numbers, or hyphens
description: |                   # optional Markdown, shown when the list row is expanded
  Pulls users hourly and posts a summary.
folder: ~/src/acme-apis          # project folder: TERMORQ_FOLDER and the code_file base
timeout_secs: 600                # per-block timeout (default 600)
notices:                         # notice cards; each defaults to true
  started: true
  progress: true
  finished: true
  failed: true
  stopped: true
  skipped: true
inputs:                          # named workflow inputs → inputs.<name>
  region:
    type: string
secrets:                         # names resolved from the secret store → secrets.<name>
  - ACME_CLIENT_SECRET
env:                             # extra env vars for code blocks; values are templates
  ACME_REGION: "{{ inputs.region }}"
triggers:                        # see Stage 8
  enabled: true
  manual: true
  selected: schedule
  schedule:
    every: 1h
blocks:                          # required: up to 40, including loop bodies
  - id: list_users
    kind: http
    method: GET
    url: mock://users

Root keys

KeyRequiredPurpose
schema✓1, or 2 when the file uses target
name✓Display name
id✓Stable ID, used in run folders, CLI commands, and HTTP hook URLs
blocks✓The steps
descriptionMarkdown description (raw HTML and images aren’t rendered)
folderProject folder
timeout_secsPer-block timeout in seconds
noticesstarted, progress, finished, failed, stopped, skipped
inputsDeclared inputs (each with a schema such as type)
secretsA list of secret names
envEnvironment variables for code blocks
triggersTrigger configuration
targetWhere code runs (localHost, useTab, or a WSL mapping). Requires schema: 2
skillsSkill binding for AI blocks
canvaspositions: { <block id>: { x, y } }, written by the editor when you save
consentWritten by the app when you confirm a trigger. Don’t edit it by hand

Any other key fails Save with property ‘…’ is not recognised.

Check your run

Paste the annotated example into a new workflow and click Save. It saves (you can leave the comments in). Then delete id: and click Save again: you get ‘id’ is missing.

Exercise

Add a notices block that shows only failure cards.

Details
notices:
  started: false
  progress: false
  finished: false
  failed: true
  stopped: false
  skipped: false

Reference: Build a workflow → YAML

Block keys by kind

Goal

Know which keys each block kind accepts. Use this page as a cheat sheet. Time: 10 minutes.

Common to every block

KeyPurpose
idLetters, numbers, and underscores. Unique within the file
nameThe label shown on the canvas
kindcode, code_file, http, mcp, ai, decision, or loop
enabledfalse skips the block (Node execution off)
thenThe next block ID, or break/continue inside a loop
outputsThe output schema
usesInput paths (code blocks)
skills, targetAdvanced: skill binding, and the execution target (target needs schema: 2)

Kind-specific keys

​
- id: transform
  kind: code
  language: python        # bash | powershell | python | csharp | rust | javascript | typescript
  body: |
    print('{"ok":true}')
  input_sample: '{}'      # Execute step fixture only
- id: script
  kind: code_file
  path: scripts/report.py # relative to `folder`
  args: --fast --limit 10 # one string, split on whitespace
  language: python        # optional: inferred from the extension
- id: call
  kind: http
  endpoint: Create message   # a saved endpoint (real requests)
  method: POST
  headers: ...               # copied from the saved endpoint
  body: "{{ steps.prep.request_json }}"
# or, offline:
#  url: mock://users
- id: tool
  kind: mcp
  server: Demo tools
  tool: echo
  arguments: "{{ steps.prep.request_json }}"   # must resolve to a JSON object
- id: summarise
  kind: ai
  connection: Summary model
  system: Return one JSON object with "summary".
  prompt: "Summarise: {{ steps.echo.message }}"
  max_tokens: 128
  input_sample: '{"trigger":{"text":"hi"}}'
- id: choose
  kind: decision
  branches:
    - when: steps.lookup.total > 0
      goto: notify
    - else: true
      goto: done
- id: each
  kind: loop
  for_each: steps.list.items   # or: repeat: 3   or: while: <condition>
  max: 20                      # default 20, ceiling 100
  body:
    - id: child
      kind: code
      language: bash
      uses: [loop.item]
      body: jq -c '{seen: .loop.item}' "$TERMORQ_INPUT"
Not runnable yet

kind: git (Git file) appears in the editor, but the runner and the save check don’t accept it. See Git file.

Check your run

Put url: mock://x on a code block and click Save. You get property ‘url’ is not recognised., because each kind accepts only its own keys.

Exercise

Which kinds accept input_sample?

Details

code and ai. These are the two kinds with an isolated Execute step editor.

Templates and chips

Goal

Know exactly where {{ path }} templates are expanded. Time: 5 minutes.

FieldTemplate expanded?
HTTP url, body, headers✓
MCP arguments (and server/tool)✓
AI system, prompt✓
Root env values✓
Code body✗ Never. Use uses and TERMORQ_INPUT
Code file source✗
Decision when, loop for_each/whileThese take a path or condition without braces, for example steps.x.total > 0

In the editor, templates appear as chips. In YAML, Tab completes {{ }} properties that are in reach.

The JSON-safety rule

Never place a template between JSON quotes:

# ✗ breaks when message contains " or a newline
body: '{"message":"{{ steps.echo.message }}"}'

# ✓ serialise the whole object in code, then insert it as one value
body: "{{ steps.prepare.request_json }}"

Secrets in templates

{{ secrets.NAME }} works in templated fields once NAME is listed under secrets:. The value is masked in run records and redacted from logs.

Check your run

Put {{ steps.echo.message }} inside a Python body and run it. The literal braces are printed, and no value is inserted.

Exercise

You need an HTTP header Authorization: Bearer <token>, where the token is the secret ACME_TOKEN. Write the root key and the template.

Details
secrets:
  - ACME_TOKEN
# in the HTTP block (or, preferably, in the saved endpoint's own auth settings):
#   Authorization: "Bearer {{ secrets.ACME_TOKEN }}"

It’s usually better to configure authentication on the saved endpoint, so the workflow never mentions the token.

Validate, complete, and keep files tidy

Goal

Author YAML quickly and catch every error before you run anything. Time: 10 minutes.

In the YAML tab

  • Tab completes keys, legal values (kinds, languages, and trigger presets), and {{ }} properties.
  • Esc closes the list and stays on the page. There’s no Monaco editor, so expect a lightweight text editor.
  • Save shows up to 20 reasons, sorted by line and column.

Outside the app

termorq-cli workflow runs the same checker and prints the same sentences. If a file has an error, it doesn’t run:

termorq-cli workflow list
termorq-cli workflow run refresh-acme-users
# Line 19: property 'foo' is not recognised.

Where files live

SourcePath
Yoursworkflows/user/<id>.yml in the app data folder
Workspace.ai-terminal/workflows/<id>.yml in an open team workspace (the app never commits or pushes it)
IncludedShipped with the app and read-only. Saving one writes your copy, which overrides the included version
Warning

Workspace files are shared with your team. A raw token in one of them is refused by the team-workspace scrubber. Use secrets: names instead.

Check your run

After saving, termorq-cli workflow list shows your workflow with source yours.

Exercise

Write a deliberately broken file containing: an invalid workflow ID, a repeated key, and a block ID with a hyphen. Predict all three messages, then check your predictions with Save.

Details
  • 'id' must be a letter, then letters, numbers, or hyphens.
  • property '<key>' is repeated.
  • A block ID error for the hyphenated ID (block IDs allow letters, numbers, and underscores only).

Stage 7 · Runs and operations

Once a workflow works, you need to understand what it did, and manage it over time. This stage covers how.

Runs tab listing succeeded, failed, and cancelled runs with their start times and summaries Runs tab listing succeeded, failed, and cancelled runs with their start times and summaries

Subsections of 7 · Runs & ops

Inside a run folder

Goal

Find and read everything a run left behind. Time: 10 minutes.

workflows/runs/<workflow id>/<run id>/
├── run.json      # the run record: blocks, inputs passed via uses, outputs, status
├── blocks/       # per-block logs (secrets masked)
└── files/        # cwd for code blocks; artifacts written via TERMORQ_OUTPUT

Settings → Workflows shows where this folder is on your computer.

From the Runs tab

  1. Open a workflow and choose Runs (or choose Runs on its row in the list).
  2. Select a run. Every block captured when the run started is listed, with its values from uses, resolved requests, outputs, and text or error.
  3. Copy input and Copy output copy those values for a block. Secrets stay masked.
  4. Loop body blocks appear once per pass.
  5. Open folder opens the run directory, and Delete run removes it, including files/. Delete asks first, and focus starts on Cancel.

Drag the divider to give the history list or the run details more room.

Retention

Termorq keeps the newest 50 runs per workflow (configurable in Settings → Workflows). Each new run deletes the oldest folder beyond the limit. The disk use shown in the workflow list counts the definition plus all of its run files.

Warning

files/ is deleted along with the run. Copy any artifact you want to keep somewhere else, for example by adding a final Code block that copies it to $TERMORQ_FOLDER/reports/.

Check your run

Run Output rules from Stage 2 that writes report.csv, then click Open folder: the file is in files/, and run.json lists the emit block’s outputs.

Exercise

Add a final block that copies report.csv into $TERMORQ_FOLDER/reports/<run id>.csv.

Details
set -euo pipefail
mkdir -p "$TERMORQ_FOLDER/reports"
cp "$TERMORQ_OUTPUT/report.csv" "$TERMORQ_FOLDER/reports/$TERMORQ_RUN.csv"
jq -nc --arg p "reports/$TERMORQ_RUN.csv" '{ok:true, saved:$p}'

Pause, resume, edit the rest, stop

Goal

Stop a run going wrong, or fix the remaining blocks mid-run. Time: 10 minutes.

ActionEffect
Pause runLets the active block finish, then holds the next one
ResumeContinues from the held block
Edit rest → ApplyChanges the blocks that haven’t started in this run only. It doesn’t write the workflow file
Edit workflow → SaveChanges the file, which affects the next run
StopCancels the active process tree or request immediately, without asking
Cancel runAvailable during credential preflight, before any block starts

Rules

  • A workflow has one active run at a time. A second start is recorded as Skipped · already running. Different workflows can run at the same time.
  • If Termorq quits while a run is in progress, that run is shown as stopped and its partial folder stays.
  • termorq-cli workflow run blocks the CLI process. Quitting the app stops it.
Check your run

Add sleep 20 to the first block of a three-block workflow. Run it, Pause it, use Edit rest to change block 3’s output, Apply, then Resume. Block 3 uses the edited version, and the saved file is unchanged.

Exercise

Start the same workflow twice quickly, once with Run and once with its HTTP trigger. What does Runs show?

Details

The second start is recorded as Skipped · already running.

Notices and Workflow settings

Goal

Tune how runs report their progress, and the defaults for new workflows. Time: 5 minutes.

Notice cards

  • One card per run, shown above the footer, with at most two in-progress cards. These are not OS notifications.
  • The card appears when the run starts, updates as each block starts, and changes when the run fails or finishes.
  • A success card disappears after 6 seconds. A failure card stays until you dismiss it. Open goes to that run.
  • Progress isn’t shown while that run’s page is open.
  • Control which cards appear for each workflow with the root notices: key (see Stage 6).

Settings → Workflows

Settings filtered to workflow, with the listener defaults Settings filtered to workflow, with the listener defaults

SettingDefault
Listener host127.0.0.1
Listener port8787
Network bindOff (this machine only)
Notice cardsOn
Block timeout600 seconds
Runs to keep50
Code editor › Insert highlight clears after10 seconds
Code editor › Undo notice stays open for10 seconds

Search Settings for workflow, listener, or undo notice to find these.

Check your run

Set Runs to keep to 5, run a workflow six times, and check that Runs lists only 5.

Exercise

Which setting would you change so that Autogenerate’s teal highlight stays for 30 seconds?

Details

Settings → Workflows → Code editor → Insert highlight clears after: 30 (the range is 1–60).

termorq-cli workflow

Goal

Run a workflow without opening its page. Time: 5 minutes.

Pro feature

termorq-cli workflow is Pro. A Free binary leaves it out of --help and refuses the command.

termorq-cli workflow list
termorq-cli workflow list --workspace ~/src/acme-apis
termorq-cli workflow run refresh-acme-users
termorq-cli workflow runs refresh-acme-users
CommandPrints
workflow listEach workflow’s name, ID, and source (included, yours, or workspace)
workflow run <id>Starts the run and waits in this process
workflow runs <id>The run directories under workflows/runs/<id>/
  • --workspace takes an absolute path or one starting with ~.
  • A file with a problem prints the same sentence as Save, and the run doesn’t start.
  • Schedules and the HTTP listener belong to the desktop app. The CLI starts only the run you name.
Check your run

termorq-cli workflow run hello-workflow finishes, and workflow runs hello-workflow lists the new directory.

Exercise

Write a one-line shell command that runs hello-workflow and then prints its newest run directory.

Details
termorq-cli workflow run hello-workflow && termorq-cli workflow runs hello-workflow | tail -1

Reference: termorq-cli workflow

Troubleshooting playbook

Goal

Diagnose a failed run in under a minute. Time: reference.

SymptomLikely causeFix
TERMORQ_INPUT is unset in a full runuses is emptyAdd the paths the block needs to uses. input_sample doesn’t supply them
'trigger.text' is not availableA manual run, or a trigger that doesn’t send textUse an HTTP or Telegram trigger, or read inputs.<name> instead
… is not a property this block can use. (on Save)Undeclared output, input, or secret, or loop.* used outside a loopDeclare it, or fix the path
The last non-empty line of code output must be a JSON objectPretty-printed JSON, a trailing log line, or an arrayPrint one compact object last, and send logs to stderr
A declared property is missing or has the wrong type"2" vs 2, "true" vs trueMatch outputs.properties exactly
A JSON parser rejects Step inputPlain text, or the wrong nestingType {"trigger":{"text":"…"}}, or read the input as text
Name a saved endpoint, or use mock://.A direct url to a real hostSave the endpoint, and set endpoint:
HTTP 4xx/5xx.The API rejected the requestCheck the resolved request on Runs
MCP arguments must resolve to a JSON object.Template text that isn’t an objectSerialise the arguments in a Code block
An AI block fails with declared outputsThe model didn’t end with a JSON objectTighten system, and raise max_tokens
A Code file can’t import a sibling fileThe source is copied to a build directoryMake it self-contained, or use $TERMORQ_FOLDER paths
A runtime can’t startIt isn’t installed, or isn’t on PATH for the appInstall it, then restart Termorq
'kind' must be code, code_file, …kind: gitUse Code file after checkout
An MCP block fails before calling the toolCredential lookup or connection failedOpen @logs
Skipped · already runningA run was already activeWait, or Stop the active run
Check your run

You can match each Stage 2–5 failure you caused deliberately to a row in this table.

Exercise

A block prints {"count": 3} followed by done., and its output schema declares count. Which row applies, and what is the smallest fix in Bash?

Details

The last-line row. Change echo done. to echo done. >&2. The added >&2 sends it to stderr.

Stage 8 · Triggers

Pro feature

Triggers and their runs are part of Workflows (Pro). Triggers fire only while Termorq is open, and a missed schedule isn’t replayed later.

Single selected Schedule trigger in the Triggers page Single selected Schedule trigger in the Triggers page

  • Choosing and arming a trigger

    One active trigger, the consent prompt, Pause triggers, and the triggers YAML block.

  • Schedule

    Preset intervals, 5-field cron (evaluated in UTC), and scheduled inputs.

  • At startup and After another workflow

    Run once when Termorq opens, or chain workflows by outcome.

  • File change

    Watch the workflow folder, filter by path, and read trigger.path.

  • HTTP request

    The local listener, x-api-key, status codes, and mapping the body to inputs and trigger.

  • Telegram

    Bot token, chat allowlist, commands, and trigger.args.

Subsections of 8 · Triggers

Choosing and arming a trigger

Goal

Understand how a trigger becomes active and safe. Time: 10 minutes.

One active automatic trigger

A workflow can store settings for every trigger type, but only the selected one starts runs automatically. Manual Run is always available.

triggers:
  enabled: true          # false = all automatic triggers paused
  manual: true
  selected: schedule     # manual | schedule | startup | after | file | http | telegram
  schedule:
    every: 1h
  http: true             # kept for later, but inactive while schedule is selected

The first time you enable an automatic trigger, the page lists every HTTP call, MCP tool, model connection, and secret name that a run can use. Confirm arms the trigger, and Cancel leaves it off. If you later add a new call or secret name, Termorq asks again. A manual Run never asks.

Pausing

Pause triggers stops automatic starts. Run still works. Copies and imports always start with their triggers paused.

TriggerselectedStarts when
ManualmanualYou press Run, use @flow, or run termorq-cli workflow run
SchedulescheduleA preset interval or a 5-field cron expression matches
At startupstartupThe app process opens (once)
After workflowafterAnother workflow finishes with success, failed, or any
FilefileSomething changes inside the workflow folder (debounced by 1.5 seconds)
HTTPhttpPOST /hooks/<id> with a valid x-api-key
TelegramtelegramA message arrives from an allowlisted chat
Check your run

Enable a schedule on a copy of Probe mock. The consent list names its mock:// call and the ACME_CLIENT_SECRET secret. Click Confirm, and the list row shows Schedule.

Exercise

Your workflow has both schedule and http configured. How do you make HTTP the active trigger without losing the schedule settings?

Details

Set selected: http, or choose HTTP request on Triggers. The schedule settings stay saved but inactive.

Reference: Triggers, runs, and notices

Schedule

Goal

Run a workflow every hour, and on weekday mornings. Time: 10 minutes.

Presets

triggers:
  enabled: true
  selected: schedule
  schedule:
    every: 1h        # 1m | 5m | 15m | 30m | 1h | 1d

Cron

  schedule:
    cron: "0 7 * * 1-5"    # minute hour day-of-month month day-of-week
  • Five fields are supported, using *, lists (1,15), ranges (1-5), and steps (*/15). Both 0 and 7 mean Sunday.
  • Runs are at least one minute apart.
Important

Cron expressions are matched against UTC. In London during British Summer Time (UTC+1), 0 7 * * 1-5 fires at 08:00 local time. Adjust the hour for your time zone.

Passing inputs on a schedule

schedule.input provides the run’s inputs. A JSON object becomes inputs.*. Any other value becomes inputs.input.

inputs:
  region:
    type: string
triggers:
  enabled: true
  selected: schedule
  schedule:
    every: 1d
    input: '{"region":"eu-west"}'
Check your run

With every: 1m, two runs appear on Runs about a minute apart. Pause triggers afterwards.

Exercise

Write a cron expression for “every 15 minutes during 09:00–17:59 UTC, Monday to Friday”.

Details

*/15 9-17 * * 1-5

At startup and After another workflow

Goal

Chain two workflows, so that one runs when the other finishes. Time: 10 minutes.

At startup

triggers:
  enabled: true
  selected: startup
  at_start: true

This runs once when the app process opens. It’s useful for warming caches or checking prerequisites.

After another workflow

schema: 1
name: Notify on failure
id: notify-on-failure
triggers:
  enabled: true
  selected: after
  after:
    workflow: refresh-acme-users
    outcome: failed        # success | failed | any
blocks:
  - id: note
    kind: code
    language: bash
    body: |
      echo "refresh-acme-users failed at $(date -u +%FT%TZ)" >&2
      echo '{"ok":true}'
    outputs:
      type: object
      properties:
        ok: { type: boolean }
Tip

Split long pipelines into smaller workflows chained with after. Each part gets its own run history and can be rerun separately.

Check your run

Make refresh-acme-users fail (for example, by disabling its model connection) and run it. Notify on failure starts automatically.

Exercise

Change the chain so that it runs after every finish, whether the first workflow succeeded or failed.

Details

outcome: any

File change

Goal

Run a workflow whenever a particular file in your project changes. Time: 10 minutes.

schema: 1
name: On config change
id: on-config-change
folder: ~/src/acme-apis
triggers:
  enabled: true
  selected: file
  file:
    path: config/app.json     # relative to `folder`; must be inside it
    debounce_ms: 1500
blocks:
  - id: changed
    kind: code
    language: python
    uses: [trigger.path, trigger.received_at]
    body: |
      import json, os
      t = json.load(open(os.environ["TERMORQ_INPUT"], encoding="utf-8"))["trigger"]
      print(json.dumps({"line": f"{t['path']} changed at {t['received_at']}"}))
    outputs:
      type: object
      properties:
        line: { type: string }

Rules

  • Termorq watches inside the workflow folder, and refuses a path outside it.
  • Changes are debounced (by 1.5 seconds by default), so a burst of saves starts one run.
  • path matches the changed file’s path relative to the folder (or a trailing part of it). If you leave path empty, any change in the folder triggers a run.
  • The trigger provides trigger.path and trigger.received_at.
Warning

Don’t write files into the watched folder from the same workflow, or each run will trigger the next one. Write artifacts to $TERMORQ_OUTPUT instead.

Check your run

Run touch ~/src/acme-apis/config/app.json. One run starts, and line names the file.

Exercise

Make the block validate that the changed file is still valid JSON, and publish valid: boolean.

Details
import json, os
t = json.load(open(os.environ["TERMORQ_INPUT"], encoding="utf-8"))["trigger"]
p = os.path.join(os.environ["TERMORQ_FOLDER"], "config", "app.json")
try:
    json.load(open(p, encoding="utf-8")); ok = True
except Exception:
    ok = False
print(json.dumps({"valid": ok}))

HTTP request

Goal

Start a workflow with curl, and read the request body in a block. Time: 15 minutes.

Steps

  1. Use the Input and output example from Stage 2 (the echo block with uses: [trigger.text]).
  2. On Triggers, choose HTTP request, Save, and confirm.
  3. Click Copy key. The key is stored in the OS keychain (account workflow-hook-<workflow id>). It never appears in the YAML, the run record, or the picker.
  4. Call the hook:
export TERMORQ_HOOK_KEY='<paste the key>'
curl -sS -i \
  -H "content-type: application/json" \
  -H "x-api-key: $TERMORQ_HOOK_KEY" \
  -d '{"text":"Hello from curl"}' \
  http://127.0.0.1:8787/hooks/input-output-example
triggers:
  enabled: true
  selected: http
  http: true

How the body maps

The JSON body becomes inputs.<name>. Supported trigger fields, such as text, also become trigger.<name>. So {"text":"Hi"} provides both inputs.text and trigger.text. Declare inputs.text if you want to read it that way.

Responses

StatusMeaning
202 + {"run_id":"…"}Accepted. The run continues in the background
401 (empty body)The key is missing or wrong. A key in the query string is ignored
400Malformed JSON, or the body isn’t a JSON object
413The body is over 256 KiB
415The content type isn’t JSON
429More than 30 requests a minute from one client

The listener

One listener serves every armed workflow. It defaults to 127.0.0.1:8787 (This machine only), and the socket is open only while at least one workflow has HTTP armed. All interfaces exposes the port on your network. There’s no TLS, so the key travels in clear text.

Warning

Never put the hook key in the workflow file or in a repository. Keep it in your shell’s environment or a secret manager.

Check your run

curl prints HTTP/1.1 202 and a run_id. On Runs, the new run’s echo block shows message: "Hello from curl".

Exercise

Send {"names":["Ada","Lin"]} to a workflow that loops over inputs.names. What do you need to declare?

Details
inputs:
  names:
    type: array
    items: { type: string }

Then use for_each: inputs.names on the loop.

Telegram

Goal

Start a workflow by sending /refresh hello from your phone. Time: 15 minutes. Needs: a Telegram bot token from @BotFather, and your chat ID.

Steps

  1. On Triggers, choose Telegram. Enter the bot token and click Save bot settings. Termorq checks the token with getMe and stores it in the keychain (account telegram-token-<id>).
  2. Add your chat ID to the allowlist, and optionally a command such as refresh (with or without /).
  3. Save, confirm, and keep Termorq open.
triggers:
  enabled: true
  selected: telegram
  telegram:
    bot: '@acme_ops_bot'
    chats: ['123456789']
    command: refresh
blocks:
  - id: args
    kind: code
    language: bash
    uses: [trigger.args, trigger.from_username]
    body: |
      jq -c '{text: (.trigger.args // ""), who: (.trigger.from_username // "")}' "$TERMORQ_INPUT"
    outputs:
      type: object
      properties:
        text: { type: string }
        who: { type: string }

What the message provides

PathValue for /refresh hello world
trigger.text/refresh hello world
trigger.argshello world
trigger.chat_id, trigger.message_id, trigger.from_usernameMessage metadata

Rules

  • Termorq long-polls Telegram. The bot doesn’t reply.
  • Messages from chats that aren’t on the allowlist are ignored. /refresh and /refresh@my_bot both match the command.
  • When the bot first connects, it skips messages that were already waiting. The poll stops when you quit Termorq.
  • The Triggers panel shows the poll status: Telegram API errors, allowlist or command mismatches, and the run ID that each matching message started.
Check your run

Send /refresh hello world. Triggers shows a run ID, and the args block publishes text: "hello world".

Exercise

Send a message from a chat that isn’t on the allowlist. What does the panel show, and does a run start?

Details

No run starts, and the poll status reports an allowlist mismatch.

Stage 9 · Capture and share

You don’t always have to start a workflow from a blank page. You can capture what you just did in the terminal, then share the result.

Subsections of 9 · Capture & share

Capture a session as a workflow

Goal

Turn three things you did in the terminal into a runnable workflow draft. Time: 15 minutes.

Pro feature

Session capture is Pro.

Steps

  1. Start capture with @capture or the Capture session command. You can also press Cmd+Option+R on macOS, or Ctrl+Alt+R on Windows/Linux (Ctrl+Shift+R where AltGr uses Ctrl+Alt). The Pro tray menu has Capture session to Workflow too.
  2. Do your work in this tab: run a shell command, call a saved endpoint (@endpoint Find user --id 42), call an MCP tool, and ask the AI something.
  3. A pulsing Capturing pill in the footer counts your actions. Click it, then choose Finish and review.
  4. An unsaved draft opens with its triggers paused. Review it, rename it, and Save.

The Shortcuts dialog filtered to session capture The Shortcuts dialog filtered to session capture

What becomes what

CapturedBecomes
A POSIX, Git Bash, or WSL commandA Bash Code block ending with {"ok":true} and declaring ok: boolean
A PowerShell commandA PowerShell Code block
A Command Prompt commandA PowerShell block that calls cmd.exe
A saved endpoint callAn HTTP block. The request values become named workflow inputs, which you enter when you press Run (42 isn’t saved as a default)
An MCP tool callAn MCP block
An AI promptAn AI block using the tab’s model. The prompt becomes a required input. Earlier chat context isn’t captured

What’s left out (and why)

  • Interactive commands, and commands that change or read persistent shell state (cd, export, reading $VAR), stay in the review list with a prerequisite note. The draft doesn’t pretend that shell state was saved.
  • Secret-looking values are scrubbed before they enter the in-memory buffer. Those steps are listed for review: add a saved secret, or enter the value again in the editor.
  • Capture is per tab and memory-only, and holds up to 200 actions. If nothing was captured, Finish capture closes the session without creating an empty workflow.

Ctrl+Alt+Shift+R (macOS: Cmd+Option+Shift+R) discards the capture. @record is a different feature: it records scrollback as an asciinema recording.

Check your run

The draft has one block per captured, representable action. Shell blocks end with {"ok":true}, and pressing Run asks for any captured request inputs.

Exercise

Capture cd /tmp && ls. Why isn’t it in the runnable draft, and how would you rewrite it as a block?

Details

cd changes persistent shell state, so it’s left out. Rewrite it as one Bash block, ls /tmp >&2; echo '{"ok":true}', or use an absolute path.

Reference: Capture a session as a Workflow

Export, import, and team workspaces

Goal

Share a workflow with a colleague safely. Time: 10 minutes.

Export

  • Export on a row saves a single workflow as readable .yml.
  • To export several, tick the rows, or use Select shown to tick everything matching the current search and filters. Then choose Export selected to save a versioned .aitwf bundle of up to 50 workflows.
  • Optionally turn on Encrypt the export with a password. Encrypted exports use Argon2id and AES-256-GCM. The password can’t be recovered.
  • Exports contain definitions only: no run history and no local credentials.

Import

  1. Choose Import workflows and pick .yml/.yaml files or an .aitwf bundle (Termorq asks for the password if it’s encrypted).
  2. Termorq validates every workflow before it saves any of them.
  3. Each one becomes a new copy with a new ID, saved under Yours, with its automatic triggers paused.
  4. Check the connections and secret names on this computer before you arm a trigger.

Team workspace

When a team workspace is open, its workflows live in .ai-terminal/workflows/<id>.yml, and the list’s Workspace chip shows them. Commit them with git yourself; Termorq never commits or pushes them. Raw tokens are refused, so refer to credentials by secrets: names.

Check your run

Export two workflows as an encrypted bundle, then import it. Two new copies appear under Yours, with new IDs and paused triggers.

Exercise

A colleague’s imported workflow fails at its HTTP block on your computer. Before anything else, what should you check?

Details

Check that you have a saved endpoint with the same name as the block’s endpoint: (and any secret names it uses). Exports don’t include connections or credentials.

Stage 10 · Capstone

Bring everything together. You’ll build Release notes digest, a workflow that:

  • starts from an HTTP trigger with a list of change titles;
  • validates the input in Python;
  • loops over each change, classifying it in Bash and scoring it in PowerShell;
  • counts results in C#, computes a checksum in Rust, and formats Markdown in JavaScript;
  • runs a Code file written in TypeScript;
  • publishes through an HTTP endpoint (mock://) and an MCP tool;
  • branches on whether there are breaking changes, asking an AI model for an upgrade warning only when there are.
flowchart TD
  H(["HTTP trigger<br/>POST /hooks/release-digest"]) --> V["validate · Python"]
  V --> L["each_change (loop)<br/>classify · Bash → score · PowerShell"]
  L --> C["tally · C#"] --> R["checksum · Rust"] --> F["format · JavaScript"]
  F --> T["stamp · Code file (TypeScript)"] --> P["publish · HTTP mock://"] --> M["post · MCP tool"]
  M --> D{"gate · Decision<br/>breaking > 0?"}
  D -->|when| A["ai_warn · AI"]
  D -->|else| Q["quiet · Python"]
  A --> E["done · HTTP mock://"]
  Q --> E
  • Prepare

    Runtimes, the project folder, a saved MCP server, and a saved model connection.

  • Build it

    The complete release-digest workflow YAML, explained block by block.

  • Trigger it and check the run

    Arm the HTTP trigger, POST changes with curl, and verify every block on Runs.

Subsections of 10 · Capstone

Prepare

Goal

Get everything in place for the capstone. Time: 15 minutes.

Checklist

NeedWhyCheck
Bash + jq, pwsh, Python 3, .NET 10 SDK, rustc, Node 22.6+All seven languages run in this workflowStage 0 runtime check
A project folder ~/src/release-digestThe workflow’s folder and Code file basemkdir -p ~/src/release-digest/scripts
scripts/stamp.ts in that folderThe Code file blockSee below
A saved MCP server Demo tools with an echo toolThe MCP blockUse any saved tool that accepts {"message": "..."}, and rename it in the YAML
A saved model connection Summary modelThe AI blockSave rejects an unknown connection with connection ‘…’ is not saved.
Tip

No MCP server or model to hand? You can still finish the capstone. Turn Node execution off for post and/or ai_warn (enabled: false). They show as Skipped, and every other block still runs.

The Code file

Save this as ~/src/release-digest/scripts/stamp.ts (download):

// Termorq Learn capstone: Code file (TypeScript).
// Place at <workflow folder>/scripts/stamp.ts. Uses erasable syntax only (Node 22.6+ native).
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";

type Input = { steps: { format: { markdown: string } } };

const inputPath = process.env.TERMORQ_INPUT;
const outputDir = process.env.TERMORQ_OUTPUT;
if (!inputPath || !outputDir) throw new Error("TERMORQ_INPUT and TERMORQ_OUTPUT are required");

const data = JSON.parse(readFileSync(inputPath, "utf8")) as Input;
const markdown = data.steps.format.markdown;
if (typeof markdown !== "string") throw new Error("steps.format.markdown must be a string");

const run = process.env.TERMORQ_RUN ?? "unknown";
const stamped = `${markdown}\n\n_Run ${run} · ${new Date().toISOString()}_`;
const file = "digest.md";
writeFileSync(join(outputDir, file), stamped, "utf8");

console.log(JSON.stringify({ stamped, file }));
Check your run

ls ~/src/release-digest/scripts shows stamp.ts, and every runtime check prints a version.

Exercise

Before you look at the next lesson, list which block in the diagram will use which kind and which language. You should have seven kinds and seven languages.

Details

Kinds: code, loop, code_file, http, mcp, decision, ai. Languages: Python (validate, quiet), Bash (classify), PowerShell (score), C# (tally), Rust (checksum), JavaScript (format), TypeScript (stamp, as a Code file).

Build it

Goal

Paste, read, and save the complete capstone workflow. Time: 30 minutes.

Steps

  1. Create a new workflow and open YAML.
  2. Paste the file below (download release-digest.yml).
  3. Change server:, tool:, and connection: to match your saved connections, or disable those blocks.
  4. Click Save. If the panel says Not saved, fix each line it reports. Use Stage 6 for help.
  5. Click Arrange, then Fit, to lay out the canvas.
schema: 1
name: Release notes digest
id: release-digest
description: |
  Termorq Learn capstone. Every runnable block type and all seven languages,
  started by an HTTP request with `{"changes": ["feat: ...", "fix: ..."]}`.
folder: ~/src/release-digest
timeout_secs: 300
inputs:
  changes:
    type: array
    items:
      type: string
env:
  DIGEST_TITLE: Release notes digest
triggers:
  enabled: true
  manual: true
  selected: http
  http: true
blocks:
  # 1 · Python: validate the request body
  - id: validate
    name: Validate input
    kind: code
    language: python
    uses: [inputs.changes]
    input_sample: |-
      {"inputs":{"changes":["feat: dark mode","fix: crash on save","feat!: drop v1 API"]}}
    body: |
      import json, os, sys
      with open(os.environ["TERMORQ_INPUT"], encoding="utf-8") as f:
          data = json.load(f)
      changes = data["inputs"]["changes"]
      if not isinstance(changes, list) or not changes:
          raise ValueError("inputs.changes must be a non-empty list")
      if not all(isinstance(c, str) and c.strip() for c in changes):
          raise ValueError("every change must be a non-empty string")
      items = [c.strip() for c in changes][:50]
      print(f"validated {len(items)} changes", file=sys.stderr)
      print(json.dumps({"items": items, "total": len(items)}))
    outputs:
      type: object
      properties:
        items:
          type: array
          items:
            type: string
        total:
          type: number

  # 2 · Loop: classify (Bash) and score (PowerShell) every change
  - id: each_change
    name: Each change
    kind: loop
    for_each: steps.validate.items
    max: 50
    body:
      - id: classify
        name: Classify
        kind: code
        language: bash
        uses: [loop.item]
        body: |
          set -euo pipefail
          jq -c '
            .loop.item as $t
            | {title: $t,
               kind: (if ($t | test("^(breaking|[a-z]+!:)"; "i")) then "breaking"
                      elif ($t | test("^feat"; "i")) then "feature"
                      elif ($t | test("^fix"; "i")) then "fix"
                      else "other" end)}' "$TERMORQ_INPUT"
        outputs:
          type: object
          properties:
            title:
              type: string
            kind:
              type: string
              enum: [breaking, feature, fix, other]
      - id: score
        name: Score
        kind: code
        language: powershell
        uses: [steps.classify.kind, loop.index]
        body: |
          $ErrorActionPreference = 'Stop'
          $d = Get-Content -Raw -LiteralPath $env:TERMORQ_INPUT | ConvertFrom-Json
          $weights = @{ breaking = 5; feature = 3; fix = 2; other = 1 }
          $kind = [string]$d.steps.classify.kind
          @{ weight = $weights[$kind]; position = [int]$d.loop.index } | ConvertTo-Json -Compress
        outputs:
          type: object
          properties:
            weight:
              type: number
            position:
              type: number

  # 3 · C#: tally the loop results
  - id: tally
    name: Tally
    kind: code
    language: csharp
    uses: [steps.each_change.results]
    body: |
      using System;
      using System.IO;
      using System.Text.Json;
      using System.Text.Json.Nodes;

      var path = Environment.GetEnvironmentVariable("TERMORQ_INPUT")
          ?? throw new InvalidOperationException("TERMORQ_INPUT is missing");
      using var doc = JsonDocument.Parse(File.ReadAllText(path));
      var results = doc.RootElement.GetProperty("steps").GetProperty("each_change").GetProperty("results");
      int breaking = 0, features = 0, fixes = 0;
      double score = 0;
      foreach (var pass in results.EnumerateArray())
      {
          var kind = pass.GetProperty("classify").GetProperty("kind").GetString();
          if (kind == "breaking") breaking++;
          else if (kind == "feature") features++;
          else if (kind == "fix") fixes++;
          score += pass.GetProperty("score").GetProperty("weight").GetDouble();
      }
      Console.WriteLine(new JsonObject
      {
          ["breaking"] = breaking,
          ["features"] = features,
          ["fixes"] = fixes,
          ["score"] = score,
      }.ToJsonString());
    outputs:
      type: object
      properties:
        breaking:
          type: number
        features:
          type: number
        fixes:
          type: number
        score:
          type: number

  # 4 · Rust: checksum of the validated items (std only, no Cargo)
  - id: checksum
    name: Checksum
    kind: code
    language: rust
    uses: [steps.validate.items]
    body: |
      use std::{env, error::Error, fs};

      fn main() -> Result<(), Box<dyn Error>> {
          let bytes = fs::read(env::var("TERMORQ_INPUT")?)?;
          // FNV-1a, 64-bit
          let mut hash: u64 = 0xcbf29ce484222325;
          for b in &bytes {
              hash ^= *b as u64;
              hash = hash.wrapping_mul(0x100000001b3);
          }
          println!("{{\"checksum\":\"{:016x}\",\"bytes\":{}}}", hash, bytes.len());
          Ok(())
      }
    outputs:
      type: object
      properties:
        checksum:
          type: string
        bytes:
          type: number

  # 5 · JavaScript: format Markdown and a JSON-safe request body
  - id: format
    name: Format Markdown
    kind: code
    language: javascript
    uses:
      - steps.each_change.results
      - steps.tally.breaking
      - steps.tally.score
      - steps.checksum.checksum
    body: |
      import { readFileSync } from "node:fs";

      const { steps } = JSON.parse(readFileSync(process.env.TERMORQ_INPUT, "utf8"));
      const icons = { breaking: "💥", feature: "✨", fix: "🐛", other: "•" };
      const lines = steps.each_change.results.map(
        (pass) => `- ${icons[pass.classify.kind]} ${pass.classify.title} (weight ${pass.score.weight})`
      );
      const markdown = [
        `## ${process.env.DIGEST_TITLE}`,
        "",
        ...lines,
        "",
        `Breaking: ${steps.tally.breaking} · Score: ${steps.tally.score} · Checksum: ${steps.checksum.checksum}`,
      ].join("\n");
      console.log(JSON.stringify({ markdown, request_json: JSON.stringify({ message: markdown }) }));
    outputs:
      type: object
      properties:
        markdown:
          type: string
        request_json:
          type: string

  # 6 · Code file (TypeScript): stamp the digest and save it as an artifact
  - id: stamp
    name: Stamp digest
    kind: code_file
    path: scripts/stamp.ts
    uses: [steps.format.markdown]
    outputs:
      type: object
      properties:
        stamped:
          type: string
        file:
          type: string

  # 7 · HTTP (offline mock): "publish" the digest
  - id: publish
    name: Publish
    kind: http
    method: POST
    url: mock://digest
    body: "{{ steps.format.request_json }}"
    outputs:
      type: object
      properties:
        path:
          type: string
        mock:
          type: boolean

  # 8 · MCP: post the digest through a saved tool
  - id: post
    name: Post via MCP
    kind: mcp
    server: Demo tools
    tool: echo
    arguments: "{{ steps.format.request_json }}"
    outputs:
      type: object
      properties:
        content:
          type: array

  # 9 · Decision: warn only when something is breaking
  - id: gate
    name: Breaking?
    kind: decision
    branches:
      - when: steps.tally.breaking > 0
        goto: ai_warn
      - else: true
        goto: quiet

  # 10 · AI: write an upgrade warning
  - id: ai_warn
    name: Upgrade warning
    kind: ai
    connection: Summary model
    system: 'Return a single JSON object with a string property named "summary".'
    prompt: |
      These release notes contain breaking changes. Write a two-sentence upgrade warning.

      {{ steps.stamp.stamped }}
    max_tokens: 200
    outputs:
      type: object
      properties:
        summary:
          type: string
    then: done

  # 11 · Python: the quiet route
  - id: quiet
    name: All clear
    kind: code
    language: python
    uses: [steps.validate.total]
    body: |
      import json, os
      with open(os.environ["TERMORQ_INPUT"], encoding="utf-8") as f:
          total = json.load(f)["steps"]["validate"]["total"]
      print(json.dumps({"summary": f"No breaking changes in {total} items."}))
    outputs:
      type: object
      properties:
        summary:
          type: string
    then: done

  # 12 · Join
  - id: done
    name: Done
    kind: http
    method: GET
    url: mock://done

Block by block

#BlockKind · languageConcept practised
1validatecode · Pythoninputs.* in uses, validation, stderr diagnostics, input_sample
2each_changeloopfor_each, max, nested body
2aclassifycode · Bashloop.item, safe jq JSON, enum output
2bscorecode · PowerShellReading a sibling’s output in the same pass, plus loop.index
3tallycode · C#Iterating steps.<loop>.results keyed by child ID
4checksumcode · RustStd-only byte processing, no Cargo
5formatcode · JavaScriptES module, env (DIGEST_TITLE), producing a serialised request_json
6stampcode_file · TypeScriptLanguage inferred from .ts, artifact in TERMORQ_OUTPUT, TERMORQ_RUN
7publishhttp (mock://)Templated body from a JSON-safe string
8postmcparguments resolving to a JSON object
9gatedecisionFirst-true branch, else fallback
10ai_warnaiTemplated prompt, a declared summary, then: done
11quietcode · PythonThe alternative route, rejoining with then: done
12donehttp (mock://)The join point, which doesn’t require outputs from either branch
Important

Notice that done reads nothing from ai_warn or quiet. Only one of those two runs, so a join block that needed both would fail.

Test before the full run

  • validate: open the code editor and choose Execute step. The saved input_sample supplies three changes.
  • ai_warn: its prompt reads steps.stamp.stamped, which only exists during a full run. To try the wording in Open AI editor, temporarily replace that chip with {{ trigger.text }}, enter some sample notes as plain text in Step input, and choose Execute step. Put the chip back before you save.
Check your run

Save succeeds, the canvas shows 12 top-level blocks, with classify and score inside the loop frame (14 blocks in total, within the 40-block limit), and validate → Execute step publishes total: 3.

Exercise

Add a docs category to classify (titles starting with docs) with a weight of 1 in score. Which three places do you need to change?

Details
  1. In classify’s jq, add elif ($t | test("^docs"; "i")) then "docs".
  2. Add docs to classify’s output enum.
  3. Add docs = 1 to $weights in score. (Optionally also add a docs icon in format. Without one, the line shows undefined.)

Trigger it and check the run

Goal

Start the capstone from outside Termorq and confirm that every block did its job. Time: 15 minutes.

Steps

  1. On Triggers, confirm that HTTP request is selected, Save, and Confirm the consent list. It names the mock:// calls, the MCP tool, and the model.
  2. Click Copy key.
  3. Send a release with a breaking change:
export TERMORQ_HOOK_KEY='<paste the key>'
curl -sS -i \
  -H "content-type: application/json" \
  -H "x-api-key: $TERMORQ_HOOK_KEY" \
  -d '{"changes":["feat: dark mode","fix: crash on save","feat!: drop v1 API","docs: typo"]}' \
  http://127.0.0.1:8787/hooks/release-digest
  1. Then send one without a breaking change:
curl -sS -i -H "content-type: application/json" -H "x-api-key: $TERMORQ_HOOK_KEY" \
  -d '{"changes":["feat: export to CSV","fix: typo in header"]}' \
  http://127.0.0.1:8787/hooks/release-digest

Check your run

Check your run

For the first request, Runs → (latest) shows:

  • validate: total: 4
  • classify and score, four times each (once per pass), with kinds feature, fix, breaking, and other
  • tally: breaking: 1, features: 1, fixes: 1, score: 11
  • checksum: a 16-character hex checksum
  • format → markdown beginning ## Release notes digest
  • stamp: file: digest.md. Open folder → files/digest.md exists
  • publish: status: 200, path: digest
  • post: the MCP content
  • gate → ai_warn ran and quiet is Skipped
  • done: status: 200

For the second request, breaking: 0, so quiet publishes No breaking changes in 2 items. and ai_warn is Skipped.

Validated output from a local dry run of the code blocks (PowerShell scoring simulated, model and MCP omitted):

## Release notes digest

- ✨ feat: dark mode (weight 3)
- 🐛 fix: crash on save (weight 2)
- 💥 feat!: drop v1 API (weight 5)
- • docs: typo (weight 1)

Breaking: 1 · Score: 11 · Checksum: <16 hex chars>

_Run <run id> · <timestamp>_

Failure drills

Try each of these, then match what you see to the troubleshooting playbook:

SendExpect
{"changes":[]}validate fails: inputs.changes must be a non-empty list
{"changes":"oops"}validate fails
No x-api-key header401, and no run starts
-H "content-type: text/plain"415
Exercise

Graduation task: add a Schedule configuration (cron: "0 8 * * 1", with input: '{"changes":["chore: weekly check"]}') without making it the active trigger. Then explain how you would switch to it.

Details
triggers:
  enabled: true
  manual: true
  selected: http
  http: true
  schedule:
    cron: "0 8 * * 1"
    input: '{"changes":["chore: weekly check"]}'

To switch, set selected: schedule, or choose Schedule on Triggers. Remember that cron runs in UTC.

🎉 You’ve completed the Workflows track. Keep the reference docs handy, and use this capstone as a template for your own pipelines.