Operator-facing. How to stand the workflow engine up, prove it works, and read the failures. Design rationale lives in ../design/workflow-engine.md; this file is the runbook.
Everything below runs from
refimpl/racketmaximus/.
A workflow is a validated JSON spec, published per
team, executed one step at a time. Each step becomes a row in the
existing jobs table, so the engine adds no new runtime, no
new daemon and no new datastore — if the server runs, workflows run.
POST /api/workflows/<slug>/run
│
▼
workflow_runs ──advance──▶ workflow_steps ──▶ jobs ──▶ worker pool
(cursor_json) (one per step) (existing scheduler)
Operational consequences, all inherited:
| Property | Comes from | What it means for you |
|---|---|---|
| Survives restart | run state is rows, not memory | systemctl restart mid-run is safe; the run resumes |
| Cancellable | POST /api/runs/<id>/cancel |
queued steps stop; a step already running finishes (SCHED‑7) |
| Rate limited | the scheduler's per-team concurrency cap | a 500-item fan-out cannot self-DDOS; it drains at the cap |
| Budgeted | quota admission at claim time | an over-budget team's steps defer (stay queued), they do not fail |
| Isolated | the org gate at step 0 of can? |
a workflow cannot read across an org boundary (TEN‑2) |
| Audited | audit_log |
workflow.publish and workflow.run
events |
Nothing to install but Nix itself. The toolchain, its version, and
every test dependency are pinned in flake.lock.
nix run github:IoTone/Telemachus/dev # run a server, no checkout needed
nix build github:IoTone/Telemachus/dev # build + run the full unit suite
nix develop # dev shell, from a checkout
nix flake check # unit suite + HTTP smoke, sandboxedThe
/devref is not optional yet. A baregithub:IoTone/Telemachusresolves to the repository's default branch, which ismain— andmainhas neither the flake nor the workflow engine. It fails withpath '«github:IoTone/Telemachus/<sha>»/flake.nix' does not exist. Drop the/devonly once this work is merged tomain.
nix develop gives you Racket 9.2,
PLTCOLLECTS already exported, plus PostgreSQL, SQLite,
OpenSSL, Node and jq. It replaces the entire ritual in
Option B — no brew, no PATH surgery, no exported collection
paths.
The packaged server writes nothing to its own
install prefix: state goes to $TELEMACHUS_DATA_DIR,
defaulting to
${XDG_STATE_HOME:-$HOME/.local/state}/telemachus.
nix profile install github:IoTone/Telemachus/dev
TELEMACHUS_DATA_DIR=/var/lib/telemachus PORT=8835 telemachus-serverFrom a checkout, nix run . and nix build .
use the working tree instead — but Nix only reads
git-tracked files, so git add a new source
file before building or it will be missing from the sandbox.
nix buildrunsraco test test/*-tests.rktand the localization gate inside the sandbox, so a successful build is a passing test run.nix flake checkadds the HTTP smoke, which binds a port and therefore cannot live in the build.
| Racket | 9.2 CS (minimal-racket). Distro
packages are usually 8.x — too old. |
| Python 3 | the demo scripts parse JSON with it |
| curl | the demo scripts |
| A model | only for translate-chat-demo.sh.
Everything else runs without one. |
# Linux (linuxbrew) / macOS (homebrew) — same formula
brew install minimal-racket
export PATH="$(brew --prefix minimal-racket)/bin:$PATH"cd refimpl/racketmaximus
export PLTCOLLECTS="$PWD/pkgs:" # REQUIRED, every shell — see §8
raco make server/main.rkt # precompile; startup is slow otherwiseOnly the first three matter for a workflow deployment. Everything else has a working default.
| Variable | Default | Notes |
|---|---|---|
DATABASE_URL |
derived from TELEMACHUS_DATA_DIR |
postgres://user:pass@host:port/db also supported |
PORT |
8835 |
TELEMACHUS_PORT is a synonym |
TELEMACHUS_MODEL_URL |
unset | OpenAI-compatible chat-completions URL. See the warning below. |
TELEMACHUS_MODEL |
local |
model name sent to that endpoint |
TELEMACHUS_MODEL_KEY |
unset | bearer token, if the endpoint needs one |
TELEMACHUS_DATA_DIR |
checkout: ./data · Nix:
$XDG_STATE_HOME/telemachus |
all writable state — the database, TLS material,
uploads. DATABASE_URL defaults to
sqlite:///$TELEMACHUS_DATA_DIR/telemachus.db. |
TELEMACHUS_PLUGINS |
./plugins |
plugin directory; plugins load at startup |
TELEMACHUS_MULTITENANT |
0 |
orgs above teams; workflows are team-scoped either way |
TELEMACHUS_BIND |
127.0.0.1 |
loopback by default. Prefer a specific private
address (a tailnet or VPC IP) over 0.0.0.0, which
also exposes the instance to the local network |
TELEMACHUS_HOME |
login |
beta serves the beta funnel at
/ — the console is then reached via the page's "Team
sign-in" link, not / |
TELEMACHUS_TLS |
off | with TELEMACHUS_TLS_CERT / _KEY |
The one that will burn you. With
TELEMACHUS_MODEL_URLunset, the server does not error — it answers every model call with a simulated uppercase echo. A workflow with translation steps will complete successfully and return garbage. If your workflows call a model, treat a missingTELEMACHUS_MODEL_URLas a failed deployment.test/translate-chat-demo.shrefuses to start without it for exactly this reason.
Second one that will burn you. The server binds loopback by default. In a container or on a remote host it will come up healthy, log nothing unusual, and be unreachable from outside. Bind it explicitly — and put a TLS terminator in front, or set
TELEMACHUS_TLS.Reach for the narrowest address that works.
TELEMACHUS_BIND=<tailnet-ip>exposes the instance to your tailnet and nothing else;0.0.0.0also publishes it to every other machine on the local network, including the unauthenticatedPOST /api/bootstraproute on a fresh database.
Third one, and it is the one that actually bites during a trial. The server reads
static/index.htmlonce, at startup, and serves it from memory. A long-lived process therefore keeps serving the console it started with — no error, no warning, just a UI missing whatever you shipped since. Any deploy that touches the UI is a restart, and "Is the console you are serving the one you built?" below is how you prove the restart took.
Migrations run automatically at startup. Workflows add
0017-workflows (three tables) and
0018-user-locale (one column). No manual step.
curl -s localhost:8835/health
# {"kdf":"pbkdf2_sha1","multitenant":false,"ok":true,"service":"telemachus","tls":false,"version":"0.1.0"}/health is liveness. For workflow
readiness, probe the contract endpoint — it is unauthenticated
by design so a probe needs no credentials:
curl -s localhost:8835/api/workflows/schema \
| python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["spec"], d["step_kinds"], d["unknown_fields"])'
# 1 ['tool:<name>', 'choice', 'map'] rejectedThe full document also carries predicates,
references, step_fields, limits
and planned_step_kinds — everything an author or a second
implementation needs in order to write a spec this build will
accept.
A non-200, or a spec that is not the version your
workflow documents declare, is the signal to stop the rollout.
Plugins that shipped workflows are listed on startup and at
/api/plugins:
plugin: translate-chat v0.1.0 — 2 tool(s), 1 workflow(s)
/health reports a static version string, so it cannot
answer this. The served asset can — the console is one file, so grep the
thing the process is actually handing out:
B=http://127.0.0.1:8835
diff <(curl -s $B/ | grep -o "const tabs=\[[^]]*\]") \
<(grep -o "const tabs=\[[^]]*\]" static/index.html) \
&& echo "console is current" || echo "STALE — restart the server"A mismatch means the running process predates your checkout. Nothing is broken; it is holding a startup-time copy. Restart it.
The engine adds no daemon and no datastore, so "deploying workflows" is just deploying the server. What follows is the whole sequence, in order.
cd refimpl/racketmaximus
export PATH="$HOME/.linuxbrew/opt/minimal-racket/bin:$PATH" # skip under `nix develop`
export PLTCOLLECTS="$PWD/pkgs:"
export DATABASE_URL="sqlite:///$PWD/data/telemachus.db" # absolute — see §8
export TELEMACHUS_MODEL_URL=http://127.0.0.1:11434/v1/chat/completions
export TELEMACHUS_MODEL=qwen2.5:7b
export TELEMACHUS_BIND=127.0.0.1 # or a tailnet/VPC IP to share it
export PORT=8835
raco make server/main.rkt # precompile — startup is slow otherwise
racket server/main.rktUnder Nix the first three lines are unnecessary and the launch is
nix run github:IoTone/Telemachus/dev; the same environment
variables apply.
There is no reload. Stop the old process, then start the new one —
and stop it by PID. pkill -f 'server/main'
also matches the shell line running it, so it kills your own command
(exit 144); a bare pgrep -f has the same problem and lists
the wrapper shells alongside the server:
pgrep -a -x racket | grep server/main # 920634 racket server/main.rkt
kill 920634 # SIGTERM; in-flight runs resume from the DB
raco make server/main.rkt && racket server/main.rkt-x matches the executable rather than the
command line, which is what keeps the shell that ran the
pgrep out of the answer. The plugin subprocesses
(mock-mcp, the sandboxed OOP plugins) are
racket too, hence the grep; they exit with the
parent.
Killing mid-run is safe: run state is rows, not memory. A step whose
job was running at the moment of death is the one exception
— see §10.
After the restart, prove it took, on the server and in the browser:
curl -s $B/ | grep -c workflowsView # 0 = stale console, ≥1 = currentThen hard-refresh the client (⇧⌘R / ctrl-shift-R). A cached page looks exactly like a stale server, and you will chase the wrong one.
Bind to the narrowest address that reaches your testers:
| Audience | TELEMACHUS_BIND |
Reachable at |
|---|---|---|
| Just this machine | 127.0.0.1 (default) |
http://127.0.0.1:8835/ |
| Your tailnet | the node's tailnet IP, e.g. 100.x.y.z |
http://100.x.y.z:8835/, or the MagicDNS name if
enabled |
| A LAN / anything wider | 0.0.0.0 |
everything on the network — only behind TLS and a real front door |
A tailnet IP is the right default for a trial: no port forwarding, no
certificate, and the instance is invisible to the local network. For
HTTPS at a real hostname, tailscale serve --bg 8835 fronts
it with a Let's Encrypt certificate and lets you keep
TELEMACHUS_BIND=127.0.0.1.
Whatever you choose, remember POST /api/bootstrap is
unauthenticated on a fresh database — it creates the
first operator. Bootstrap immediately after first start, before anyone
else can reach the port.
Run through this once per trial deployment. B is the
base URL — B=http://127.0.0.1:8835, or whatever you bound
to; A is the Authorization: Bearer … header
from §7.
curl -s $B/health # "ok":true
curl -s $B/api/workflows/schema \
| python3 -c 'import sys,json;d=json.load(sys.stdin);print(d["spec"],d["step_kinds"])'
curl -s $B/ | grep -c workflowsView # ≥1 → the console is current
curl -s -m 5 "$TELEMACHUS_MODEL_URL" -H 'Content-Type: application/json' \
-d "{\"model\":\"$TELEMACHUS_MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}]}" \
| head -c 120 # the model actually answersThe workflows feature is per team, in the database, and
defaults to on:
curl -s -X POST $B/api/features/workflows -H "$A" -d '{"enabled":false}'The tab disappears from the console and every workflow endpoint
returns 403 — except /api/workflows/schema,
which describes the build rather than the instance. Existing runs are
unaffected; they are already scheduler jobs. This is the kill switch for
a trial that goes wrong: it needs no restart and no deploy.
Four tiers, cheapest first. Tiers 1–3 need no model and run in CI today.
export PLTCOLLECTS="$PWD/pkgs:"
raco test test/flow-tests.rkt # the engine: 20 cases
raco test test/*-tests.rkt # everything: 137 casesCovers the validator (including that unknown fields and newer spec
versions are rejected), the binding sublanguage, fan-out
ordering and failure, max_steps, retries, cross-org
refusal, and a run resumed by a second database
connection after the first is closed — the durability claim,
tested rather than asserted.
Never run
raco test test/*.rkt. That glob pulls intest/mock-*.rkt, which are mock servers that block forever. Alwaystest/*-tests.rkt.
bash test/server-smoke.sh # or PORT=8890 bash test/server-smoke.sh
# → server-smoke: PASSIncludes the workflow endpoints end to end: publish, list, run, poll,
and assert the notes the steps actually created — plus that a spec with
an unknown field is refused with 400, that a member
without workflows:write is refused with
403, and that a workflow honors per-team tool
activation.
PORT and
PORT+1)bash test/multitenant-demo.sh # 56 assertionsNot workflow-specific, but it is what proves the org gate the engine relies on.
# warm the model first; a cold pull inside the demo looks like a hang
ollama serve &
ollama run qwen2.5:7b </dev/null
export TELEMACHUS_MODEL_URL=http://127.0.0.1:11434/v1/chat/completions
export TELEMACHUS_MODEL=qwen2.5:7b
bash test/translate-chat-demo.shDrives the translate-chat plugin's workflow: one chat
turn, a fan-out to Spanish, Dutch and Icelandic, then a second fan-out
bringing each back to the user's own language — 3 steps, 9 step rows, 7
model calls. Then it switches the translation tool off mid-demo and
asserts the run stops at the failing fan-out and hands the reason back
without running anything downstream.
Not in CI (no model on the runner), and it exits
2 rather than run against the echo fallback if
TELEMACHUS_MODEL_URL is unset.
Use qwen2.5:7b. Avoid qwen3.5 "reasoning"
models — their answer lands in a reasoning field the
OpenAI-compatible path does not read, so steps come back empty.
Drives the Workflows tab the way an operator would: lists what the team can run, runs one, watches it advance, and forces a failure. Proof that the GUI and the API agree, since the GUI is only a client of the API.
export PATH=~/.nvm/versions/node/v24.18.0/bin:$PATH # box default node may be too old
cd test/e2e && npm ci # once
BASE_URL=http://127.0.0.1:8835 node workflow-tour.mjs # → catalog/workflow/*.pngPoint BASE_URL at any running instance; it signs in as
alice/s3cret and bootstraps that operator if
the instance is fresh. WF_SLUG picks a different workflow.
It disables translate_text for the last shot and re-enables
it after.
Use plain Playwright, as the script does — the
@playwright/test runner hangs in a headless
sandbox with no output.
| Code | Meaning |
|---|---|
0 |
pass |
1 |
an assertion failed, or the port was busy (message on stderr) |
2 |
translate-chat-demo.sh only:
TELEMACHUS_MODEL_URL not set |
All four scripts run on a mktemp -d database and clean
up after themselves. They touch no deployed data.
B=localhost:8835
TOK=$(curl -s -X POST $B/api/bootstrap -d '{"username":"ops","password":"CHANGE-ME"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')
A="Authorization: Bearer $TOK"
# 1. the contract this build speaks
curl -s $B/api/workflows/schema
# 2. what is installed (plugin workflows appear on first listing)
curl -s $B/api/workflows -H "$A"
# 3. publish a trivial one
curl -s -X POST $B/api/workflows -H "$A" -d '{
"spec":1,"slug":"ping","input":{"title":"string"},
"steps":[{"id":"note","uses":"tool:create_note",
"with":{"title":"${input.title}","body":"from a workflow"},"end":true}]}'
# 4. run it, then read the run
RID=$(curl -s -X POST $B/api/workflows/ping/run -H "$A" \
-d '{"input":{"title":"deploy check"}}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
curl -s $B/api/runs/$RID -H "$A"A healthy run reaches "status":"done" within a second or
two for tool-only steps.
The same thing without curl — sign in and open Workflows:
plugin:<id> for a workflow a
plugin shipped, api for one that was POSTed) and its step
count. A plugin's workflow appears here the first time the tab is opened
— that listing is what materializes it into the team.input declaration — the same declaration the server
enforces — and POSTs to
/api/workflows/<slug>/run./api/runs/<id> every 1.5 s while the run is live.
Each step shows its status, its retry count, and its output; a
map step's children are the indented rows beneath it, one
per item. Cancel is offered while the run is still
live.queued rather than running on.The tab is hidden and its endpoints return 403 when
the workflows feature is switched off for the team (Admin ›
Features). GET /api/workflows/schema stays open either way
— it describes the build, not the instance.
| Endpoint | Permission | |
|---|---|---|
GET /api/workflows/schema |
none | contract discovery; safe as a probe |
POST /api/workflows |
workflows:write |
publish; re-publishing a slug bumps its version |
GET /api/workflows |
workflows:read |
the team's definitions |
GET /api/workflows/<slug> |
workflows:read |
the spec itself |
POST /api/workflows/<slug>/run |
workflows:run |
returns 202 and a run id |
GET /api/runs · /api/runs/<id> |
workflows:read |
run + every step's status, timing, error |
POST /api/runs/<id>/cancel |
workflows:run |
409 if already finished |
POST /api/profile |
authenticated | {"locale":"es"} — what ${principal.locale}
binds to |
Org admins hold workflows:read and not
workflows:run: running someone's workflow is reading their
data by proxy, which TEN‑2a forbids.
Ordered by how often they will actually happen.
| Symptom | Cause | Fix |
|---|---|---|
| A shipped UI change is missing — no Workflows tab, an old tab set | the server cached static/index.html at startup; the
process predates your checkout |
restart it (§5), then hard-refresh the browser.
curl -s $B/ | grep -c workflowsView tells you which of the
two it was |
| A wall of unrelated assertion failures | another server already on that port; the scripts' readiness probe was satisfied by it | the scripts now refuse to start — heed
port … is already in use and set PORT |
| Steps complete instantly, output is the input in CAPITALS | TELEMACHUS_MODEL_URL unset → simulated echo |
set it; restart |
reference to a variable that is not exported |
stale .zo after a module's exports changed |
raco make the tests too, or delete
compiled/ |
| Module not found / wrong module loaded | PLTCOLLECTS unset —
pkgs/{cli-kit,db-kit,web-kit} collide with any linked
Odysseus copies |
export PLTCOLLECTS="$PWD/pkgs:" |
A run sits at "status":"running" forever |
its steps are queued and the team is over quota —
deferral, not failure |
check GET /api/usage; raise the limit or wait for the
window |
403 feature workflows is disabled, and no tab |
the team has the workflows feature off |
Admin › Features → enable, or
POST /api/features/workflows {"enabled":true} |
/ shows a beta signup page, not the console |
TELEMACHUS_HOME=beta |
follow the page's "Team sign-in" link, or unset the variable and restart |
| Tab is there, Definitions is empty | a plugin's workflows materialize into a team on first listing — an empty list means no plugin shipped one and none was published | check the startup log for N workflow(s); otherwise
POST /api/workflows |
| A stopped server left the port bound | a previous process is still alive | pgrep -a -x racket | grep server/main, then
kill <pid> — not
pkill -f, which matches its own command line |
400 invalid workflow spec: … unknown field 'x' |
strict validation (WF‑10) | remove the field, or the document was written for a newer build |
400 … unsupported spec format version 2 |
the document is newer than this server | upgrade the server; do not hand-edit the version |
unknown tool 'x' — no plugin registered it |
tool names resolve at run time, not publish time | check the plugin loaded: GET /api/plugins |
tool 'x' is disabled for this team |
per-team tool activation | POST /api/tools/<name> {"enabled":true} |
max_steps exceeded |
a next/then cycle that never
terminates |
intended backstop; fix the workflow |
| The test suite hangs with no output | you ran raco test test/*.rkt |
use test/*-tests.rkt |
error: Path 'x' … is not tracked by Git |
Nix only sees git-tracked files | git add the new file — an untracked source is invisible
to the build |
nix build succeeds but the server writes into the
store |
you overrode DATABASE_URL with a relative
sqlite path |
use an absolute path, or leave it unset and let it follow
TELEMACHUS_DATA_DIR |
GET /api/runs/<id> is the whole diagnosis.
error says which step and why; the steps array
carries per-step status, attempt,
error and the job_id that executed it. A
fan-out child is named <map>#<i>, so
to_all#0 is the first item of the to_all
fan-out.
{"status":"error",
"error":"step 'to_all#0' failed: flow: tool 'translate_text' is disabled for this team",
"steps":[{"step_id":"chat","status":"done", …},
{"step_id":"to_all","status":"error", …},
{"step_id":"to_all#0","status":"error","attempt":2, …}]}attempt: 2 means the step's retry.max was
honored before it gave up. Steps after the failure are absent because
they never ran — a failed step stops the run.
The existing job already covers tiers 1–3; the unit step is a
glob, so flow-tests.rkt was picked up with
no CI change:
- name: Unit tests
run: |
export PLTCOLLECTS="$(pwd)/pkgs:"
raco test test/*-tests.rkt # glob — a named list silently skips new suites
- name: Server smoke
run: bash test/server-smoke.sh
- name: Multi-tenancy demo
run: bash test/multitenant-demo.shTier 4 is deliberately absent: a runner with no model would run the
echo fallback and report success. To cover it, add a job with a model
service and set TELEMACHUS_MODEL_URL; without one, leave it
out rather than let it pass falsely.
workflow_defs, workflow_runs,
workflow_steps, jobs. Back up the database and
you have backed up every in-flight run.workflow_defs row and version it started on,
so republishing a workflow never changes a running one. Steps mid-flight
resume from cursor_json after a restart.running when the process
died stays running — the scheduler does not reap
orphans. Rare, but if a run is stuck on a step whose job is
running with no worker, cancel the run and start it
again.