This guide walks you through building a brand-new MachineFabric cartridge in Python from a blank directory to a binary the host can install. We will build a small, self-contained example called sentiment-tagger: a cartridge that reads a piece of text from stdin and emits one of positive, neutral, or negative based on a simple word-list rule. No model, no network, no external services — the whole thing fits in ~50 lines of Python and demonstrates every required piece of the cartridge contract.

By the end you will have:

  • A new media URN (media:sentiment-tag;textable) that names the output type.
  • A new cap URN that ties an input media URN to the output media URN via an operation tag.
  • A Python cartridge binary that registers and serves the cap.
  • A cartridge.json install record next to the binary.
  • A directory you can drop into the host’s installed-cartridges tree for the host to pick up. This example was verified on macOS with Python 3.12.

Prerequisites

  • Python 3.12 or newer. The cartridge SDK and one of its transitive dependencies (python-pest) require Python ≥3.12. Check with python3 --version.
  • pip, ideally inside a virtualenv so this guide doesn’t pollute your global site-packages. The commands below assume a fresh venv.
  • A text editor. That’s it. No IDE, no compiler, no build system.

To follow this guide, you need a host installation of MachineFabric (or another bifaci-speaking host) to actually run the cartridge once you’ve built it — but the build is hostless and you can stop after step 5 if you only want to package without installing. The cartridge will run in CLI mode for testing without a host, and the manifest can be inspected without a host too.


Step 1 — Create a fresh project directory

Pick any directory name; this guide uses sentiment-tagger.

mkdir sentiment-tagger
cd sentiment-tagger
python3 -m venv .venv
source .venv/bin/activate

You should see (.venv) prepended to your shell prompt. Confirm Python points inside the venv:

which python   # → .../sentiment-tagger/.venv/bin/python
python --version   # must say 3.12 or newer

If python --version reports an older version, your python3 was 3.10 / 3.11. Re-create the venv with an explicit binary, e.g. python3.12 -m venv .venv (Homebrew installs Python under that name).


Step 2 — Install the SDK from PyPI

Two packages are needed: capdag (the cartridge runtime, manifest builder, and URN library) and the ops framework (the Op base class your handlers inherit from). Both are MIT-licensed.

capdag declares the ops framework as a transitive dependency, so a single install pulls everything:

pip install --upgrade pip
pip install capdag

The runtime exposes the Op base class via from ops import Op. The PyPI distribution name of the underlying package is opsx-py (the short ops name on PyPI is taken by an unrelated project), but the import path is ops — capdag’s pip install pulls the right wheel automatically.

Verify the import paths your editor will see:

python -c "from capdag.bifaci.cartridge_runtime import CartridgeRuntime; print('capdag ok')"
python -c "from ops import Op; print('ops ok')"

Both should print ok. If either errors with ModuleNotFoundError, the venv isn’t active — re-run source .venv/bin/activate.


Step 3 — Understand what you’re going to write

A MachineFabric cartridge is a single executable that speaks the bifaci wire protocol on its stdin/stdout. The cartridge does three things:

  1. On startup, it sends a manifest to the host describing every capability it can perform. Each capability is identified by a cap URN of the shape cap:in="media:X";<tags>;out="media:Y". The cap URN names the input media type, one or more freeform operation tags, and the output media type.
  2. For each capability, the cartridge registers an Op — a Python class that does the actual work when the host dispatches a request.
  3. The cartridge calls runtime.run() and the runtime takes over stdin/stdout, multiplexing requests, heartbeats, and responses automatically.

For our sentiment-tagger we need ONE custom cap. The standard CAP_IDENTITY is mandatory in every cartridge but the runtime auto-registers it for us — we don’t need to do anything for that one.

URN design

Two media URNs participate:

  • Input: media:text;sentiment-input;textable — a UTF-8 text blob that the user wants tagged. The text and textable tags signal “this is human-readable text”, and sentiment-input is our domain-specific tag distinguishing “text we will tag” from “arbitrary text that happens to be readable”.
  • Output: media:sentiment-tag;textable — a single-word string (positive / neutral / negative). sentiment-tag is our domain-specific tag.

The cap URN ties them together (note: tags get re-sorted alphabetically when the URN is canonicalized, so the printed form won’t match the order you typed):

cap:in="media:sentiment-input;text;textable";out="media:sentiment-tag;textable";tag-sentiment

The tag-sentiment segment is a freeform operation tag — it’s how the host disambiguates this cap from any other cap that maps the same input type to the same output type. Operation tags are intentionally freeform; you pick the name.

URNs are opaque. Never construct cap or media URNs by string concatenation in your handler logic, and never hard-code the canonical byte form. Use CapUrnBuilder to build the URN, then derive the byte string ONCE via .to_string() and pass that string both to register_op_type and (implicitly via the manifest serialization) to the host. The runtime canonicalizes tags alphabetically; if the byte string you register with doesn’t match the canonicalized form, dispatch fails with NoHandlerError.


Step 4 — Write the cartridge

Create cartridge.py in the project root with the following content. The file is intentionally one piece so you can read it top-to-bottom.

#!/usr/bin/env python3
"""
sentiment-tagger — a minimal MachineFabric cartridge.

Reads UTF-8 text on stdin and emits a single tag word: `positive`,
`neutral`, or `negative`. Demonstrates the smallest useful shape of
a cartridge: one custom cap, one Op, one manifest, one main().
"""

import queue

import cbor2

from capdag.bifaci.cartridge_runtime import (
    CartridgeRuntime,
    Request,
    WET_KEY_REQUEST,
)
from capdag.bifaci.frame import FrameType
from capdag.bifaci.manifest import CapManifest, default_group
from capdag.cap.definition import (
    Cap,
    CapArg,
    CapOutput,
    PositionSource,
    StdinSource,
)
from capdag.standard.caps import CAP_IDENTITY
from capdag.urn.cap_urn import CapUrn, CapUrnBuilder
from ops import DryContext, Op, OpMetadata, WetContext


# ---------------------------------------------------------------------------
# Domain logic — pure Python, no MachineFabric awareness.
# ---------------------------------------------------------------------------

POSITIVE_WORDS = {
    "good", "great", "love", "happy", "excellent",
    "wonderful", "amazing", "fantastic", "delightful",
}
NEGATIVE_WORDS = {
    "bad", "terrible", "hate", "sad", "awful",
    "disappointing", "horrible", "miserable", "broken",
}


def classify(text: str) -> str:
    """Return one of `positive`, `neutral`, `negative` for the input.

    Case-insensitive whole-word match against two small word lists.
    Replace this with a real model when you graduate from `getting
    started`.
    """
    tokens = {t.strip(".,!?;:").lower() for t in text.split()}
    pos = len(tokens & POSITIVE_WORDS)
    neg = len(tokens & NEGATIVE_WORDS)
    if pos > neg:
        return "positive"
    if neg > pos:
        return "negative"
    return "neutral"


# ---------------------------------------------------------------------------
# Frame helpers
# ---------------------------------------------------------------------------

def _collect_text(frames: queue.Queue) -> str:
    """Drain CHUNK frames from the request queue and decode as UTF-8.

    Each CHUNK payload is one CBOR-encoded value; we accept either a
    CBOR text string or a CBOR byte string and concatenate. Stops on
    the first END frame."""
    pieces: list[str] = []
    while True:
        frame = frames.get()
        if frame.frame_type == FrameType.CHUNK:
            if frame.payload:
                value = cbor2.loads(frame.payload)
                if isinstance(value, str):
                    pieces.append(value)
                elif isinstance(value, bytes):
                    pieces.append(value.decode("utf-8"))
        elif frame.frame_type == FrameType.END:
            break
    return "".join(pieces)


# ---------------------------------------------------------------------------
# Op — implements the cap.
# ---------------------------------------------------------------------------

class TagSentimentOp(Op):
    """Implements `cap:tag-sentiment` end-to-end.

    Lifecycle: the runtime constructs a fresh `TagSentimentOp` per
    request, calls `perform`, and discards the instance. Don't carry
    per-request state in fields; use locals.
    """

    async def perform(self, dry: DryContext, wet: WetContext) -> None:
        req: Request = wet.get_required(WET_KEY_REQUEST)
        text = _collect_text(req.take_frames())
        tag = classify(text)
        # `emit_cbor` writes one CHUNK frame containing a CBOR text
        # string; the host receives `tag` as a Python `str` on the
        # other side. The runtime emits END for us when `perform`
        # returns.
        req.emitter().emit_cbor(tag)

    def metadata(self) -> OpMetadata:
        return OpMetadata.builder("TagSentimentOp") \
            .description("Classify text as positive / neutral / negative") \
            .build()


# ---------------------------------------------------------------------------
# URN + manifest — describes what this cartridge can do.
# ---------------------------------------------------------------------------

def _tag_sentiment_urn() -> CapUrn:
    """Build the cap URN once. We keep this in a helper so the
    manifest and the `register_op_type` call use the SAME URN object,
    which is the only way to guarantee their canonical byte forms
    match."""
    return (
        CapUrnBuilder()
        # `marker` adds a freeform operation tag without a
        # `key=value` form. Pick any unique slug; the host uses the
        # full cap URN (in + tags + out) for dispatch.
        .marker("tag-sentiment")
        .in_spec("media:text;sentiment-input;textable")
        .out_spec("media:sentiment-tag;textable")
        .build()
    )


# Computed from the parsed CapUrn so the byte form matches whatever
# the runtime canonicalizes to (tags get re-sorted alphabetically,
# media URNs may be normalized too). Pass THIS string — never a
# hand-written one — to `register_op_type`.
CAP_TAG_SENTIMENT_URN: str = _tag_sentiment_urn().to_string()


def build_manifest() -> CapManifest:
    tag_sentiment = Cap(
        urn=_tag_sentiment_urn(),
        title="Tag sentiment",
        command="tag-sentiment",
    )
    tag_sentiment.cap_description = (
        "Classify a piece of text as positive, neutral, or negative."
    )
    tag_sentiment.args = [
        CapArg(
            media_urn="media:text;sentiment-input;textable",
            required=True,
            sources=[
                StdinSource("media:text;sentiment-input;textable"),
                PositionSource(0),
            ],
            arg_description="UTF-8 text to classify.",
        )
    ]
    tag_sentiment.output = CapOutput(
        media_urn="media:sentiment-tag;textable",
        output_description=(
            "One of the literal strings 'positive', 'neutral', "
            "or 'negative'."
        ),
    )

    # Every cartridge MUST declare CAP_IDENTITY. The runtime
    # auto-registers an identity handler for us; we just need to
    # advertise the cap in the manifest.
    identity = Cap(
        urn=CapUrn.from_string(CAP_IDENTITY),
        title="Identity",
        command="identity",
    )

    return CapManifest(
        name="sentiment-tagger",
        version="0.1.0",
        # `nightly` and `release` are the two valid channels.
        # `nightly` is the right pick during development.
        channel="nightly",
        # Dev cartridges (built and installed locally) use `None`
        # for registry_url. When you publish to a registry, set
        # this to the verbatim registry URL (e.g.
        # "https://cartridges.example.com/manifest").
        registry_url=None,
        description=(
            "Classify a piece of text as positive, neutral, or negative."
        ),
        cap_groups=[default_group([identity, tag_sentiment])],
    )


# ---------------------------------------------------------------------------
# main — wires the manifest to the runtime and starts the loop.
# ---------------------------------------------------------------------------

def main() -> None:
    runtime = CartridgeRuntime.with_manifest(build_manifest())
    runtime.register_op_type(CAP_TAG_SENTIMENT_URN, TagSentimentOp)
    runtime.run()


if __name__ == "__main__":
    main()

Make it executable:

chmod +x cartridge.py

Step 5 — Smoke-test the cartridge from the command line

Cartridges have a built-in CLI mode. When you invoke them with no arguments they enter the bifaci CBOR protocol on stdin/stdout (which is what the host uses); when you invoke them with arguments they behave like a normal Unix tool. The two relevant CLI subcommands:

  • manifest — print the manifest as JSON.
  • <command-name> — invoke a cap. The command name is the command field on the Cap (here, tag-sentiment).

Inspect the manifest:

./cartridge.py manifest | python -m json.tool

You should see a JSON object containing your tag-sentiment cap and the auto-registered cap: (identity).

Run the sentiment cap on some input:

echo "I love this" | ./cartridge.py tag-sentiment
# → positive

echo "this is awful" | ./cartridge.py tag-sentiment
# → negative

echo "the sky is blue" | ./cartridge.py tag-sentiment
# → neutral

If the output isn’t what you expected, the bug is in classify() — the runtime is doing its job. Iterate until the output matches.


Step 6 — Make the cartridge launchable as a single binary

The host needs to launch your cartridge as an executable file with a stable absolute path. The simplest reliable approach uses the shebang line you already have:

#!/usr/bin/env python3

The cartridge.py script IS the cartridge binary. The host will run ./cartridge.py directly.

There’s one catch: the host’s environment might not have capdag and its dependencies installed where python3 can find them. Two ways to address this:

Option A — install capdag into the system Python that the host uses:

/usr/bin/env python3 -m pip install --user capdag

The --user flag installs into the user site-packages, which /usr/bin/env python3 searches by default. This works when the host runs the cartridge as your user.

Option B — wrap the script with a tiny launcher that activates the project’s venv:

Create sentiment-tagger.sh next to cartridge.py:

#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$DIR/.venv/bin/python" "$DIR/cartridge.py" "$@"

Then chmod +x sentiment-tagger.sh and use sentiment-tagger.sh as the cartridge’s entry point in step 7.

For Option A the entry name is cartridge.py; for Option B it’s sentiment-tagger.sh. Pick whichever matches your environment and use that name in the install record below.

Note on PyInstaller / py2app / zipapp. These tools work for pure-Python projects but don’t handle the C-extension dependencies in capdag’s transitive tree (cbor2, python-pest) well. If you need a truly single-file binary with no separate Python install, use PyInstaller in --onefile mode and follow its docs for bundling C extensions; the basic shebang approach above is much simpler and adequate for development.


Step 7 — Write the install record

The host expects every installed cartridge to live in a directory shaped like:

{cartridges-root}/{slug}/{channel}/{name}/{version}/
    cartridge.json
    {entry-binary}

{slug} is dev for unpublished cartridges (what we’re building); for published cartridges it’s a deterministic SHA-256 hash of the registry URL. {channel} is nightly or release; {name} and {version} come from the manifest.

For our cartridge:

{cartridges-root}/dev/nightly/sentiment-tagger/0.1.0/
    cartridge.json
    cartridge.py        (or sentiment-tagger.sh, per step 6)

cartridge.json is a JSON record the host reads to validate the install context BEFORE spawning the cartridge. The required-but- nullable shape is:

{
  "name": "sentiment-tagger",
  "version": "0.1.0",
  "channel": "nightly",
  "registry_url": null,
  "entry": "cartridge.py",
  "installed_at": "2026-05-06T10:30:00Z"
}

Important rules the host enforces:

  • name MUST equal the parent ({name}/) directory name.
  • version MUST equal the leaf ({version}/) directory name.
  • channel MUST equal the channel folder (nightly or release).
  • registry_url MUST be present (key cannot be omitted). null means dev; a URL string means published. For published builds, the URL must be https (the host rejects http outside dev mode).
  • entry is the relative path to the executable inside the version directory. Use the file name from step 6.

If any of these are inconsistent, the host records the install as a bad_installation failure, surfaces it in the cartridges view, and grace-period-deletes the directory.

Build the install layout in a staging directory (replace the path with wherever you want to keep dev cartridges):

INSTALL_ROOT="$HOME/cartridge-staging"
SLUG="dev"
CHANNEL="nightly"
NAME="sentiment-tagger"
VERSION="0.1.0"
ENTRY="cartridge.py"

mkdir -p "$INSTALL_ROOT/$SLUG/$CHANNEL/$NAME/$VERSION"
cp "$ENTRY" "$INSTALL_ROOT/$SLUG/$CHANNEL/$NAME/$VERSION/"

cat > "$INSTALL_ROOT/$SLUG/$CHANNEL/$NAME/$VERSION/cartridge.json" <<EOF
{
  "name": "$NAME",
  "version": "$VERSION",
  "channel": "$CHANNEL",
  "registry_url": null,
  "entry": "$ENTRY",
  "installed_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF

You can verify the install record validates by re-running the manifest dump and confirming the values match:

./cartridge.py manifest | python -c "
import json, sys
m = json.load(sys.stdin)
print('name    =', m['name'])
print('version =', m['version'])
print('channel =', m['channel'])
print('registry_url =', m['registry_url'])
"

The values printed must match your cartridge.json byte-for-byte (modulo formatting). If they don’t, the host will reject the install.


Step 8 — Hand the directory to the host

Where the host expects installed cartridges depends on the host implementation. For the macOS host the path is

/Library/Application Support/MachineFabric/Cartridges/

— that’s a privileged location; you’ll need sudo to drop a directory there. For other hosts, consult their installation docs; the host simply needs an entry under {cartridges-root}/{slug}/{channel}/ that matches the four-component layout.

For the Mac host:

sudo mkdir -p "/Library/Application Support/MachineFabric/Cartridges"
sudo cp -R "$INSTALL_ROOT/dev" "/Library/Application Support/MachineFabric/Cartridges/"

The host’s filesystem watcher should pick the new directory up within a second and run discovery on the cartridge. The cartridges view in the UI should show sentiment-tagger with a “Dev” badge once the discovery scan completes.

If you want to force a rescan rather than wait for the watcher, use the host UI’s Refresh button (or whatever equivalent your host provides).


Step 9 — Iterate

Two common changes you’ll make next:

Change the rule and re-publish

Edit classify(), re-copy the file, and the host’s filesystem watcher will fire on the change. The host’s discovery rescan re-evaluates every install-context check, picking up the new binary automatically.

If you want a clean break (different version number, different rule behavior), bump version in BOTH build_manifest() and the install-layout VERSION shell variable, then re-copy into a new 0.1.1/ directory next to the existing 0.1.0/. The host will treat the two as independent installs and pick the latest by version sort.

Add a second cap

Each new cap needs:

  1. A new Cap in build_manifest() with a fresh marker(...) and distinct in/out media URNs.
  2. A new Op subclass that implements the work.
  3. A new runtime.register_op_type(...) call in main() whose URN string matches the cap URN exactly. Use the build-once-and-keep-the-string pattern from _tag_sentiment_urn() / CAP_TAG_SENTIMENT_URN so the byte form matches the canonicalized URN automatically.

Cartridges with N caps are no different from cartridges with one cap; the runtime dispatches based on the request’s cap URN against the table you registered.


Troubleshooting

The host shows my cartridge as manifest_invalid. Your cartridge.json is missing a required field (most often registry_url) or has an unparseable JSON body. Run python -m json.tool < cartridge.json to confirm it parses cleanly, then check that all six required keys are present.

The host shows my cartridge as bad_installation. One of the on-disk-vs-manifest consistency checks failed. Compare the name, version, channel, and registry_url in cartridge.json to the directory components and to what your manifest dump prints. Common causes: a typo in the directory name, a version change in the manifest that wasn’t reflected in the directory layout, an http registry URL on a non-dev install.

The host shows my cartridge as handshake_failed. The cartridge process spawned but the bifaci handshake didn’t complete. Almost always this is your cartridge crashing at startup before it sends the HELLO frame. Run ./cartridge.py manifest directly and look at stderr; the Python traceback will tell you what’s wrong.

The host shows my cartridge as entry_point_missing. The path in cartridge.json:entry doesn’t resolve to an executable file inside the version directory. Check that you chmod +x the binary AND that the file is named exactly what entry says.

My handler doesn’t get called even though discovery succeeded; CLI mode prints NoHandlerError. The cap URN you passed to register_op_type doesn’t byte-match the URN the runtime is dispatching against. The runtime canonicalizes tags into alphabetical order; if you hand-wrote the URN string you almost certainly produced a non-canonical form. The fix is the _tag_sentiment_urn() helper pattern shown in step 4: build the URN once via CapUrnBuilder, then derive the string via .to_string() and use that string in register_op_type.