A Custom Node in Python

Add a processing node of your own, written in Python, and get Loom dispatching real pipeline work to it — no JVM build and no rebuild of the Cortex server.

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:

How a Python worker connects to Loom A Python daemon opens a WebSocket control plane to Loom, sends REGISTER with the node kinds it advertises, receives NODE_TASK messages and answers with NODE_TASK_RESULT. Separately it writes the computed payload over the REST data plane as a json-comp and a node-result ledger row. The worker reads media itself from the shared media mount. your Python daemon daemon.py · websockets + stdlib run_node() the only part you write CORTEX_NODE_ID · stable identity CORTEX_NODE_KINDS · what it advertises Loom pipeline run engine routes a task by node kind node descriptor registry asset components + ledger control plane — WebSocket /api/v1/processors/ws REGISTER · HEARTBEAT → NODE_TASK → NODE_TASK_RESULT data plane — REST POST /assets/:uuid/json-comps · /node-results shared media mount the task carries a path — your worker opens the file itself

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.sh from 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 a FAILED result.

  • Three states onlyCOMPLETED, FAILED, SKIPPED. SKIPPED is the honest answer for "this node does not apply to this file", and it does not cascade to dependent nodes; FAILED does, for any dependent node that is blocking.

  • Only declared ports may be emitted. An output the descriptor does not declare fails the task.

  • Media does not arrive in inputs. The task carries media.path and the worker opens the file itself; a media/* 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 MANY output fans out. If your node emits five elements on a MANY port, every downstream node whose matching input takes ONE runs five times for that asset — once per element, each task carrying its own elementSeq. facedetect is the built-in example: its detections port emits one element per detected face, so facedescription describes each face separately.

  • A MANY input 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-sink works this way — its artifacts port 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 (loom-shared/node-model)

The pipeline is rejected on save; the kind is invisible in the editor palette.

Advertised kind

Your worker’s REGISTER

The pipeline saves, but the run is rejected with 503 — no worker accepts the kind.

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 NODE_TASK_RESULT you send back over the WebSocket uses COMPLETED | FAILED | SKIPPED. The state on /node-results uses SUCCESS | FAILED | SKIPPEDasset_node_result carries a CHECK constraint that rejects anything else.

Map COMPLETEDSUCCESS before posting. Forgetting to is quiet in the worst way: the json-comp is written first and succeeds, so the payload is there while the ledger row that says the node ran is not.

origin is constrained the same way — COMPUTED, LOCAL or REMOTE.

The reference examples/cortex-python/daemon.py does the mapping in one place — ledger_state() — so a worker copied from it inherits the fix rather than the bug.

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.sha512 only once a hash node has run for that item, so the graph needs a sha512 node — 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

COMPLETED is mapped to SUCCESS before the ledger POST

The two enums differ and the wire value looks plausible, so this passes review by eye. The asset_node_result CHECK constraint rejects it, the ledger row is lost, and the json-comp — written first — still lands. You get a payload with no record that the node ran.

producerVersion on both REST calls

The ledger is indexed on (node_kind, producer_version) so an operator can sweep everything an old version produced. Omit it and re-running after a node change cannot be targeted.

One result per task, always

An agent that lets an exception escape run_node produces a worker that silently stalls a run — the engine waits for an answer that never arrives.

The node runs off the socket loop

A synchronous node inside the message loop stops heartbeats, and Loom drops the worker mid-task.

inputs read by port id

An agent that has seen an older MetaLoom will reach for upstreamOutputs and index it by node id or node kind. Both are gone: inputs is keyed by the node’s own input ports, and the wrong guess fails silently — the node just never sees its input.

The payload envelope is not flattened

It is tempting to "simplify" inputs to {port: value} and emit the same shape back. Loom rejects an output that is not {contentType, cardinality, elements}, and a MANY port loses its per-element origin, which is what the gather groups on.

elementSeq is echoed back

A fanned-out node gets several tasks that differ only in elementSeq. Drop it and every element’s answer is filed against element 0.

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

CORTEX_NODE_KINDS naming a kind run_node does not handle makes Loom dispatch work the worker can only SKIP.

No invented endpoints

If the code calls something outside the list above, it was guessed. /api/v1/openapi.json on your server is the authority.

CORTEX_NODE_ID has no random default

A generated-per-boot id makes every restart look like a brand new worker.

Token expiry is handled

A JWT from /login dies after an hour. The worker keeps registering and answering, but every persist 401s — healthy-looking, silently dropping data. Use an API key.

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

REGISTER

worker → Loom

Identity, capabilities and the advertised node kinds.

REGISTERED

Loom → worker

Registration accepted; the worker is dispatchable.

HEARTBEAT / HEARTBEAT_ACK

both

Keepalive, every 10 s in the reference daemon.

STATUS_UPDATE

worker → Loom

CPU load and disk usage; all fields optional.

NODE_TASK

Loom → worker

Run one node against one media item.

NODE_TASK_RESULT

worker → Loom

Exactly one per dispatched task, including failures.

ERROR

Loom → worker

Something the server rejected — log it.

SOURCE_TASK / SEGMENT_TASK

Loom → worker

Media enumeration and affinity-grouped work. Not implemented in the example.

A NODE_TASK body carries:

Field Use

taskUuid, runUuid, itemId

Echo them back in the result so the engine can match it. itemId is also the origin.itemId to stamp on every element you emit.

nodeId, nodeKind

Which graph node this is, and which kind to run.

elementSeq

Which element of a fanned-out sequence this task is for — 0 when nothing upstream fanned out. Echo it back unchanged.

media.path, media.sha512

The file to open, and the key that resolves the asset. Media never travels in inputs.

options

The node’s per-node options from the pipeline definition.

inputs

{inputPortId: payload} — keyed by this node’s own input ports, filled from the wired edges. An unwired optional port is absent.

demandedOutputs

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

400 Unknown node type when saving the pipeline

No node descriptor on the Loom side — Step 4.

Pipeline saves, run rejected with 503

No online worker advertises the kind. Check CORTEX_NODE_KINDS and GET /api/v1/processors.

The worker connects but never receives work

It registered with a different kind than the definition uses, or another worker holds the same CORTEX_NODE_ID — Loom rejects a duplicate id.

Media not found on this worker

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 sha512 upstream, no token, or the file is not a known asset in Loom.

The payload is stored but there is no ledger row

The node-results POST sent the wire state COMPLETED; the column only accepts SUCCESS, FAILED or SKIPPED.

Persistence 401s after about an hour

A JWT from /login expired. Use a long-lived API key for a worker.

inputs is missing a port you expected

No edge targets that input port, or the port id in the node does not match the one in the descriptor. inputs is keyed by your port ids, never by an upstream node id or kind.

The pipeline will not save: requires input '<port>' …​ but nothing is connected

A required input port has no incoming edge. Wire it, or declare the port optional in the descriptor.

The pipeline will not save: incompatible content types

The two ends of an edge disagree. Assignability never crosses families, so hash/sha512 cannot feed scalar/string; family/* on either end is what widens it.

The pipeline will not save: does not say which ports it connects

An edge is missing sourcePort or targetPort. Both are mandatory.

The node runs several times per asset

Something upstream emitted a MANY port and your ONE input is bound to it, so the engine fans out — one task per element, each with its own elementSeq. Declare the input MANY to gather instead.

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.

Looking for something else?