The built-in nodes cover hashing, transcription, OCR, faces and scenes — but not your domain. This playbook adds a node of your own, written in Python, and gets Loom dispatching real pipeline work to it.
No JVM build, no Dagger, no rebuild of cortex-server. A worker is anything that speaks Loom’s wire
protocol and answers the tasks it is given.
How a Python Worker Fits In
The Cortex node SPI is JVM-only, so a Python worker does not implement it — it talks to Loom directly over the two planes every worker uses:
The control plane makes the worker part of the fleet; the data plane is where the computed payload is
durably stored. A NODE_TASK_RESULT tells the engine the node finished and carries its outputs for
downstream nodes — it is not how the data gets saved.
Prerequisites
-
A running Loom — Docker or Kubernetes, or just
./start-postgres.sh && ./start-demo.shfrom the repo root for a local one. -
Python 3.11+ and
pip install websockets(the REST calls use the standard library). -
The media library readable by this process at the same path Loom hands out.
-
A token — a JWT from
POST /api/v1/login, or a long-lived API key.
|
Tip
|
The worker below writes its results with hand-rolled REST calls, so that the whole thing stays one readable file with a single dependency. If you would rather not maintain that part, the Python Client covers the same calls — resolving an asset by hash, storing a JSON component, and appending the node-result row — and has no third-party dependencies of its own. The WebSocket control plane is still yours to write either way. |
Step 1 — Start From the Reference Daemon
examples/cortex-python/daemon.py in the source repository is a complete, single-file worker: connect,
register, heartbeat, receive a task, run a node, persist, answer. Copy it and keep the transport as it
is — the node itself, and the port handling around it, is what you write.
cp -r examples/cortex-python my-node && cd my-node
pip install -r requirements.txt
# Verify the node logic with no Loom server involved
python daemon.py --selftest ./some-file.txt
The self-test path is worth using while you iterate: it calls your node directly and prints the result, so you are not debugging protocol plumbing and node logic at the same time.
|
Tip
|
Working with a coding agent? Generating a Node With a Coding Agent below is a ready-to-paste prompt that produces the whole worker — it carries the same protocol facts this page documents, so the agent does not have to guess at them. |
Step 2 — Write the Node
A node has named, typed ports: inputs arrives keyed by your input port ids, and what you return
is keyed by your output port ids. Nothing on either side names another node — the pipeline’s edges
decide which upstream port fills which of your inputs, and Loom resolves that before it dispatches.
Declare the ports once, so the protocol layer knows how to unwrap and wrap them and the descriptor in Step 4 can say the same thing:
# port id → (content type, cardinality)
INPUT_PORTS = {"scenes": ("struct/segments", "ONE")}
OUTPUT_PORTS = {"shot_count": ("scalar/integer", "ONE"),
"shots": ("struct/json", "MANY")}
Everything below run_node is protocol. run_node is your node:
def run_node(node_kind, media_path, options, inputs):
"""Run one node against one media file.
node_kind which kind to run — a worker may advertise several
media_path absolute path on shared storage; you open the file yourself
options per-node options from the pipeline definition
inputs {inputPortId: payload} — keyed by THIS node's port ids
"""
if node_kind != "shot-list":
return NodeResult.skipped(f"Unknown node kind '{node_kind}'")
if not os.path.isfile(media_path):
# Nearly always means the shared media mount is missing on this worker.
return NodeResult.failed(f"Media not found on this worker: {media_path}")
# Read a port, not a node. Whatever was wired into "scenes" lands here under
# that name — rename the upstream node and this keeps working.
scenes = one_value(inputs, "scenes")
shots = analyse(media_path, scenes, options.get("minSeconds", 2))
# Keyed by this node's OUTPUT port ids. A MANY port takes a list; the
# protocol layer turns it into one element per entry.
return NodeResult.completed({"shot_count": len(shots), "shots": shots})
The two readers are three lines each, because a port value is always the same envelope:
def one_value(inputs, port):
"""The value on a ONE input port — None when the port is not wired."""
elements = (inputs.get(port) or {}).get("elements") or []
return elements[0]["value"] if elements else None
def all_values(inputs, port):
"""Every value on a MANY input port, in sequence order."""
return [e["value"] for e in (inputs.get(port) or {}).get("elements") or []]
Four rules the engine depends on:
-
Every task gets exactly one answer. A failure is a value (
NodeResult.failed), never an exception that escapes — Loom blocks on a result for each task it dispatched. Convert a raised exception into aFAILEDresult. -
Three states only —
COMPLETED,FAILED,SKIPPED.SKIPPEDis the honest answer for "this node does not apply to this file", and it does not cascade to dependent nodes;FAILEDdoes, for any dependent node that isblocking. -
Only declared ports may be emitted. An output the descriptor does not declare fails the task.
-
Media does not arrive in
inputs. The task carriesmedia.pathand the worker opens the file itself; amedia/*edge is wiring the engine type-checks, not a payload it copies.
One Element or a Sequence
Every port carries a cardinality, and it is what makes a node run more than once per asset:
-
A
MANYoutput fans out. If your node emits five elements on aMANYport, every downstream node whose matching input takesONEruns five times for that asset — once per element, each task carrying its ownelementSeq.facedetectis the built-in example: itsdetectionsport emits one element per detected face, sofacedescriptiondescribes each face separately. -
A
MANYinput gathers. Wire a fanned-out branch into it and the engine waits until every element of that asset has settled, then dispatches one task carrying the whole sequence.s3-sinkworks this way — itsartifactsport takes many.
Both happen automatically from the two ports' cardinalities. There is no merge node to place and nothing in the definition to configure; the grouping key is the run item the elements came from.
|
Note
|
A node that runs per element and also declares a MANY output is rejected when the pipeline is
saved — nested fan-out is not supported. Gather with a MANY input first.
|
Step 3 — Advertise the Kind
The worker tells Loom what it can run in its REGISTER message; Loom never dispatches a kind that is
not in that list. In the reference daemon it comes from the environment:
export LOOM_HOST=localhost LOOM_PORT=8092
export LOOM_TOKEN="$TOKEN"
export CORTEX_NODE_ID=py-shot-list-1 # unique per worker, stable across restarts
export CORTEX_NODE_KINDS=shot-list # comma-separated; what this worker advertises
python daemon.py
Confirm Loom sees it:
curl -s -H "Authorization: Bearer $TOKEN" $LOOM/api/v1/processors \
| jq '.[] | {nodeId, nodeWhitelist}'
|
Note
|
This is why the Python route sidesteps the registration gap described in node availability — a Python worker declares its kinds on the wire, so nothing has to be registered in the JVM worker build for your kind to be dispatchable. |
Step 4 — Teach Loom the Kind Exists
There is a second registration, and it is the step people miss. Loom validates a pipeline definition against its node descriptor registry when you save it. A definition naming a kind Loom has no descriptor for is rejected:
400 Unknown node type: "shot-list" — not found in descriptor registry
Descriptors are contributed on the Loom side and discovered at startup; the registry is what pipeline validation checks and what the UI’s node palette is drawn from. Check what your server knows:
curl -s -H "Authorization: Bearer $TOKEN" $LOOM/api/v1/pipeline/node-descriptors | jq -r '.[].kind'
curl -s -H "Authorization: Bearer $TOKEN" $LOOM/api/v1/pipeline/node-descriptors/shot-list | jq
A descriptor declares the kind, a display name and icon, its category, its ports and its parameters — which is exactly the metadata the editor needs to render the node, draw its handles and decide which connections are legal:
{
"kind": "shot-list",
"name": "Shot List",
"category": "ANALYSIS",
"inputPorts": [
{ "id": "scenes", "contentType": "struct/segments",
"cardinality": "ONE", "required": true }
],
"outputPorts": [
{ "id": "shot_count", "contentType": "scalar/integer", "cardinality": "ONE" },
{ "id": "shots", "contentType": "struct/json", "cardinality": "MANY" }
]
}
A port id matches ^[a-z0-9][a-z0-9_]{0,62}$ and is unique among that node’s inputs (or its outputs).
required applies to inputs; a required input with nothing wired into it is what makes a definition
fail to save. A content type is always family/subtype — the families are media, text,
detection, hash, scalar, artifact, struct and control — and family/* accepts the whole
family. Assignability never crosses families, so a hash/sha512 port cannot feed a
scalar/string input even though both travel as strings. GET /api/v1/pipeline/node-descriptors is
the authoritative list of the vocabulary your server knows.
Add yours next to the built-in ones in the loom-shared/node-model module
(io.metaloom.loom.nodes.spec), where each built-in kind has a small provider you can copy, and list
it in that module’s META-INF/services file so it is discovered. This is the one part of a custom node
that lives in the Loom build rather than in your worker.
|
Tip
|
Keep INPUT_PORTS / OUTPUT_PORTS in the worker and the descriptor’s inputPorts /
outputPorts identical. They are two declarations of one contract in two repositories, and the
failure when they drift is quiet: an undeclared output fails the task, and an input the descriptor
does not have cannot be wired at all.
|
| Registration | Where it lives | What breaks without it |
|---|---|---|
Node descriptor |
Loom ( |
The pipeline is rejected on save; the kind is invisible in the editor palette. |
Advertised kind |
Your worker’s |
The pipeline saves, but the run is rejected with |
|
Tip
|
While prototyping, you can exercise the worker without either registration by driving it with a kind that already has a descriptor — but do not ship that: the descriptor is what makes the node usable by anyone other than you. |
Step 5 — Persist the Result
The task result carries outputs for downstream nodes; storing the payload is a REST call. The reference
daemon does this in _persist, and the shape is the same one the built-in nodes use:
GET /api/v1/assets/sha512/{sha512} → resolve the asset from the media hash
POST /api/v1/assets/{uuid}/json-comps → the payload
{nodeKind, schemaType, variant, producerVersion, data}
POST /api/v1/assets/{uuid}/node-results → the ledger row: what ran, and where its output lives
{nodeKind, nodeId, producerVersion, state, origin,
durationMs, reason, resultRef}
|
Warning
|
The wire state and the ledger state are different enums. The Map
The reference |
Both writes upsert, so re-running a pipeline replaces rather than accumulates:
json-comps on (asset, nodeKind, schemaType, variant), node-results on
(asset, nodeKind, nodeId). Stamp a producerVersion such as my-node/1.0.0 on both and bump it when
the output shape changes — the ledger is indexed on (node_kind, producer_version), which is how an
operator finds everything an older version produced and re-runs it.
Two facts that decide whether anything is stored at all:
-
Assets are resolved by SHA-512. The task carries
media.sha512only once a hash node has run for that item, so the graph needs asha512node — wire it to the same media the rest of the graph reads. -
The asset must already exist in Loom. Nodes attach results to known assets; they do not create them. Ingest first (upload,
POST /api/v1/assets, or/assets/bulk/create).
Persistence is deliberately best-effort: if the asset is unknown, the reference daemon logs and skips the write but still reports the task result. Losing a payload must not stall a run.
Step 6 — Use It in a Pipeline
{
"nodes": [
{ "id": "source", "type": "filesystem-source", "name": "Video library",
"options": { "path": "/media/video" } },
{ "id": "hash", "type": "sha512", "name": "SHA-512 identity" },
{ "id": "scenes", "type": "scene-detection", "name": "Scene boundaries" },
{ "id": "shots", "type": "shot-list", "name": "Shot list",
"options": { "minSeconds": 2 }, "timeoutMs": 300000 }
],
"edges": [
{ "id": "e1", "source": "source", "sourcePort": "media",
"target": "hash", "targetPort": "media" },
{ "id": "e2", "source": "source", "sourcePort": "media",
"target": "scenes", "targetPort": "media" },
{ "id": "e3", "source": "scenes", "sourcePort": "scenes",
"target": "shots", "targetPort": "scenes" }
]
}
Both ends of every edge are named, and both are mandatory. Edge e3 is the one that feeds your node:
it connects the scenes output port of scene-detection to the
scenes input port of shot-list. The two happen to share a name here; they need not, and neither
is the graph id of a node. Rename the node from scenes to anything else and the edge — and your node
— keep working.
Create and run it exactly as any other pipeline (see
the transcription playbook), then watch your daemon’s log: a
NODE_TASK arrives, run_node runs, and a NODE_TASK_RESULT goes back.
Step 7 — Package It
FROM python:3.12-slim
WORKDIR /worker
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY daemon.py .
CMD ["python", "daemon.py"]
docker run -d --name py-shot-list --network metaloom \
-e LOOM_HOST=loom -e LOOM_PORT=8092 -e LOOM_TOKEN="$TOKEN" \
-e CORTEX_NODE_ID=py-shot-list-1 \
-e CORTEX_NODE_KINDS=shot-list \
-v /srv/media:/media:ro \
my-registry/py-shot-list:latest
It joins the fleet next to the stock workers, which keep serving the built-in kinds. Scale it by
running more replicas — each with its own CORTEX_NODE_ID.
Generating a Node With a Coding Agent
The protocol is small and completely specified, which makes this a good task to hand to a coding agent. The prompt below carries everything an agent needs — the message shapes, the REST calls, the node contract and the failure rules — so it works whether or not the agent can see the MetaLoom source.
Fill in the five bracketed lines at the top and paste the whole thing.
You are writing a MetaLoom Cortex worker in Python. MetaLoom is a media asset
platform: a Loom server owns pipeline graphs and dispatches one node task at a
time to registered workers. I want a worker that serves a node of my own.
## What the node does
- NODE KIND (the `type` used in a pipeline definition): [e.g. shot-list]
- WHAT IT COMPUTES: [one or two sentences]
- APPLIES TO: [e.g. video files only; skip everything else]
- INPUT PORTS it declares: [id, content type, ONE|MANY, required —
e.g. scenes / struct/segments / ONE / required]
- OUTPUT PORTS it declares: [id, content type, ONE|MANY —
e.g. shot_count / scalar/integer / ONE
shots / struct/json / MANY]
- OPTIONS it accepts: [e.g. minSeconds (number, default 2)]
If the MetaLoom repository is available to you, start from
`examples/cortex-python/daemon.py`, keep its transport, and write the node and
its port handling. If it is not, write the daemon from the contract below. Ask
me before inventing any endpoint, message type or field that is not listed here.
## Runtime contract
Configuration comes from the environment, nothing else:
LOOM_HOST (default localhost), LOOM_PORT (default 8092)
LOOM_TOKEN JWT for both the WebSocket and REST
LOOM_USER/LOOM_PASSWORD optional: POST /api/v1/login {username,password}
returns {"token": "..."} if LOOM_TOKEN is unset
CORTEX_NODE_ID REQUIRED. Unique per worker and stable across restarts;
Loom keys registration and attribution on it and rejects
a duplicate.
CORTEX_NODE_KINDS comma-separated kinds this worker advertises. Refuse to
start if it names a kind run_node does not implement —
Loom would dispatch work the worker can only SKIP.
A JWT from /login EXPIRES (default 1h). The WebSocket is authenticated once at
the handshake, so an expired token leaves a worker that looks healthy while every
REST persist returns 401. Prefer a long-lived API key; and on a 401, re-login if
credentials are present and retry the call once.
## Control plane — WebSocket
Connect to ws://HOST:PORT/api/v1/processors/ws?token=<urlencoded JWT>.
Every frame is JSON: {"type": "<TYPE>", "body": { ... }}.
Send on connect:
REGISTER body {nodeId, name, priority, host, capabilities:["CPU","IO"],
nodeWhitelist:[<the kinds>]}
Then:
HEARTBEAT every 10s
STATUS_UPDATE every 20s once registered, body {cpuLoad, diskTotal, diskUsed}
(all fields optional)
Handle inbound:
REGISTERED registration accepted
HEARTBEAT_ACK keepalive confirmed
ERROR body {message} — log it
NODE_TASK body {taskUuid, runUuid, itemId, nodeId, nodeKind,
elementSeq, media:{path, sha512}, options:{...},
inputs:{inputPortId: payload},
demandedOutputs:[outputPortId, ...]}
SOURCE_TASK / SEGMENT_TASK out of scope — log and ignore
Answer every NODE_TASK with exactly one:
NODE_TASK_RESULT body {runUuid, itemId,
result:{taskUuid, nodeId, elementSeq, state,
durationMs, message,
outputs:{outputPortId: payload}}}
state is COMPLETED | FAILED | SKIPPED. This is the WIRE enum — it is NOT the
enum the REST ledger takes. See the data plane below.
Echo elementSeq back exactly as received; it is how the engine files the
answer against the right element.
Reconnect with exponential backoff (2s * attempt, capped at 30s) and re-REGISTER.
## Ports and the payload envelope
Values do not travel as bare JSON. Every entry of `inputs` and `outputs` is:
{"contentType": "text/plain",
"cardinality": "ONE", # or "MANY"
"elements": [{"origin": {"itemId": "<the task's itemId>",
"seq": 0, "total": 1},
"value": <any JSON>}]}
ONE -> exactly one element, seq 0, total 1
MANY -> N elements, seq 0..N-1, total N, in order
`inputs` is keyed by THIS node's own input port ids — never by an upstream node
id or kind. The pipeline's edges decide which upstream port fills which input,
and the engine resolves that before dispatching. Media is NOT in `inputs`: the
task carries media.path and the worker opens the file itself.
A content type is always family/subtype. The families are media, text,
detection, hash, scalar, artifact, struct and control; family/* means the whole
family. Use the ones I listed for the ports above and do not invent new ones.
Cardinality drives execution, so do not treat it as decoration:
- A MANY output fans out: each element becomes a separate downstream task, and
those tasks differ only in elementSeq.
- A MANY input gathers: the engine waits for every element of that item and
hands them to a single task, in seq order.
demandedOutputs lists the output ports something downstream actually consumes.
Treat it as an optimisation hint — skip expensive work nobody asked for — and
emit whatever you computed regardless; an undemanded port is still recorded.
## The node itself
Put all node logic in one function, so the protocol code stays untouched:
run_node(node_kind, media_path, options, inputs) -> NodeResult
media_path absolute path on shared storage; the worker opens the file itself.
Loom sends a path, never bytes.
inputs {inputPortId: payload} — this node's own port ids.
returns NodeResult.completed({outputPortId: value}) | .failed(msg)
| .skipped(reason)
Keep the envelope out of run_node. Declare the ports once, at module level:
INPUT_PORTS = {"<id>": ("<contentType>", "ONE"|"MANY"), ...}
OUTPUT_PORTS = {"<id>": ("<contentType>", "ONE"|"MANY"), ...}
and have the protocol layer unwrap incoming payloads and wrap the plain values
run_node returns, stamping origin.itemId from the task's itemId. A MANY output
port's value is a list; each entry becomes one element.
Rules that are not negotiable:
- A node that raises must still produce a FAILED result. The engine blocks on an
answer for every task it dispatched; one bad file must not take the worker down.
- SKIPPED is the honest answer for "does not apply to this file" — do not fail.
- Run the node OFF the socket loop (thread or run_in_executor) so a long node
never stalls heartbeats or blocks the next inbound message.
- Emit ONLY declared output ports. An undeclared port id fails the task.
- An optional input port that nobody wired is simply absent from `inputs`.
Handle that; do not assume every declared port is present.
- If you dispatch each task with asyncio.create_task, keep a strong reference to
it (a set, discarded in a done-callback). A task nobody holds can be garbage
collected mid-flight, which loses the answer the engine is waiting for.
## Data plane — REST (best effort)
Persist the payload after the node ran. Base URL http://HOST:PORT/api/v1,
Authorization: Bearer <token>:
GET /assets/sha512/{sha512} -> {"uuid": ...} resolve the asset
POST /assets/{uuid}/json-comps {nodeKind, schemaType, variant,
producerVersion, data}
POST /assets/{uuid}/node-results {nodeKind, nodeId, producerVersion, state,
origin:"COMPUTED", durationMs, reason,
resultRef}
THE TWO ENUMS ARE DIFFERENT — this is the single easiest thing to get wrong:
NODE_TASK_RESULT.state (WebSocket) COMPLETED | FAILED | SKIPPED
node-results.state (REST) SUCCESS | FAILED | SKIPPED
Map COMPLETED -> SUCCESS before posting the ledger row. The column carries a
CHECK constraint, so "COMPLETED" is rejected by the database and the ledger row
is silently lost while the json-comp still lands. `origin` is likewise
constrained to COMPUTED | LOCAL | REMOTE.
Set producerVersion (e.g. "<kind>/1.0.0") on BOTH calls and bump it when the
output shape changes: the ledger is indexed on (node_kind, producer_version) so
an operator can find and re-run everything an older version produced.
Write the ledger row for failures too, not only successes — "node X ran on asset
A and failed" is exactly what the ledger is for.
Both writes upsert, so a re-run replaces rather than accumulates:
json-comps key (asset, nodeKind, schemaType, variant) — pick a stable variant
node-results key (asset, nodeKind, nodeId)
media.sha512 is only present once a hash node ran for the item, and the asset
must already exist in Loom. If either is missing, skip the write, log it, and
still report the task result. Never fail a task because persistence failed.
## Deliverables
1. daemon.py — single file, stdlib only except `websockets`. Clear separation:
config / port declarations / run_node / payload wrapping / REST client /
WebSocket channel / entry point.
2. requirements.txt
3. A `--selftest <file>` mode that calls run_node directly and prints the result
as JSON, so the node can be verified with no Loom server running. Exit 0 on
COMPLETED, 1 on FAILED, 3 on SKIPPED, 2 on usage error. Accept an optional
--inputs <file.json> holding a {portId: payload} map so a node with a
required input port can be self-tested too.
4. A Containerfile: python:3.12-slim, install any external binary the node shells
out to, non-root user, PYTHONUNBUFFERED=1, CMD python daemon.py.
5. A short README: env vars, how to run, how to self-test, the input and output
ports (id, content type, cardinality, required) and the options as tables,
the JSON node descriptor to add on the Loom side, and the pipeline JSON
snippet that uses this node kind — with sourcePort/targetPort on every edge.
## Definition of done
- `python daemon.py --selftest <sample>` prints a COMPLETED result and exits 0.
- Every emitted output port id appears in OUTPUT_PORTS, and every payload the
worker sends round-trips through json.dumps/loads unchanged.
- A task whose `inputs` omits an optional port is handled, not crashed on.
- With no LOOM_TOKEN the worker still registers and answers tasks; it only skips
persistence, and says so in the log.
- Killing the Loom server makes the worker reconnect instead of exiting.
- Starting with no CORTEX_NODE_ID exits non-zero with a message, rather than
inventing an id.
- If the node needs an external binary, startup warns when it is missing instead
of failing every task with an obscure error.
- Comments explain WHY where behaviour is non-obvious (the off-loop execution,
the best-effort persistence, the stable node id) — not what each line does.
Reviewing What Comes Back
The failure modes are predictable, so check these first. The top two are the ones a generated worker actually got wrong when this prompt was first used, which is why they are now spelled out in it:
| Check | Why it matters |
|---|---|
|
The two enums differ and the wire value looks plausible, so this passes review by eye. The |
|
The ledger is indexed on |
One result per task, always |
An agent that lets an exception escape |
The node runs off the socket loop |
A synchronous node inside the message loop stops heartbeats, and Loom drops the worker mid-task. |
|
An agent that has seen an older MetaLoom will reach for |
The payload envelope is not flattened |
It is tempting to "simplify" |
|
A fanned-out node gets several tasks that differ only in |
Persistence is best-effort |
A worker that fails the task because the asset was not found in Loom turns a missing hash node into a red run. |
Advertised kinds == implemented kinds |
|
No invented endpoints |
If the code calls something outside the list above, it was guessed. |
|
A generated-per-boot id makes every restart look like a brand new worker. |
Token expiry is handled |
A JWT from |
Then run it for real: start the worker, confirm it appears in GET /api/v1/processors with your kind
in its whitelist, and dispatch a pipeline at it. Afterwards check both writes landed — a payload with
no ledger row is the signature of the enum mistake:
curl -s -H "Authorization: Bearer $TOKEN" $LOOM/api/v1/assets/$ASSET/json-comps | jq
curl -s -H "Authorization: Bearer $TOKEN" $LOOM/api/v1/assets/$ASSET/node-results | jq
|
Note
|
The agent cannot finish the job alone — the node descriptor lives in the Loom build, not in the worker. Until it exists, a pipeline naming your kind is rejected on save, and no amount of correct worker code changes that. |
Protocol Reference
What the reference daemon handles, and what it deliberately leaves out:
| Message | Direction | Meaning |
|---|---|---|
|
worker → Loom |
Identity, capabilities and the advertised node kinds. |
|
Loom → worker |
Registration accepted; the worker is dispatchable. |
|
both |
Keepalive, every 10 s in the reference daemon. |
|
worker → Loom |
CPU load and disk usage; all fields optional. |
|
Loom → worker |
Run one node against one media item. |
|
worker → Loom |
Exactly one per dispatched task, including failures. |
|
Loom → worker |
Something the server rejected — log it. |
|
Loom → worker |
Media enumeration and affinity-grouped work. Not implemented in the example. |
A NODE_TASK body carries:
| Field | Use |
|---|---|
|
Echo them back in the result so the engine can match it. |
|
Which graph node this is, and which kind to run. |
|
Which element of a fanned-out sequence this task is for — |
|
The file to open, and the key that resolves the asset. Media never travels in |
|
The node’s per-node options from the pipeline definition. |
|
|
|
The output port ids something downstream consumes. A hint for skipping expensive work; emitting an undemanded port is still legal. |
And a payload — the same envelope in both directions:
{
"contentType": "text/plain",
"cardinality": "MANY",
"elements": [
{ "origin": { "itemId": "5c1f…", "seq": 0, "total": 2 }, "value": "first" },
{ "origin": { "itemId": "5c1f…", "seq": 1, "total": 2 }, "value": "second" }
]
}
A ONE payload is the same shape with exactly one element at seq: 0, total: 1.
Gotchas
| Symptom | Cause |
|---|---|
|
No node descriptor on the Loom side — Step 4. |
Pipeline saves, run rejected with |
No online worker advertises the kind. Check |
The worker connects but never receives work |
It registered with a different kind than the definition uses, or another worker holds the same
|
|
The shared media mount is missing or mounted at a different path than the one Loom dispatched. |
The node runs but nothing is stored |
No |
The payload is stored but there is no ledger row |
The |
Persistence 401s after about an hour |
A JWT from |
|
No edge targets that input port, or the port id in the node does not match the one in the
descriptor. |
The pipeline will not save: |
A required input port has no incoming edge. Wire it, or declare the port optional in the descriptor. |
The pipeline will not save: |
The two ends of an edge disagree. Assignability never crosses families, so |
The pipeline will not save: |
An edge is missing |
The node runs several times per asset |
Something upstream emitted a |
Heartbeats stop during a long node |
Run the node off the socket loop (the reference daemon uses a worker thread) so the control channel keeps answering. |
Next Steps
-
Put your node to work — Translating Content shows the same text-in, text-out shape as a built-in node.
-
The Java equivalents, and the full example sources — Cortex Examples.
-
Node reference — every built-in kind’s input and output ports — Nodes.