Skip to main content

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.