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.jsoninstall 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 withpython3 --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:
- 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. - For each capability, the cartridge registers an Op — a Python class that does the actual work when the host dispatches a request.
- 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. Thetextandtextabletags signal “this is human-readable text”, andsentiment-inputis 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-tagis 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
CapUrnBuilderto build the URN, then derive the byte string ONCE via.to_string()and pass that string both toregister_op_typeand (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 withNoHandlerError.
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 thecommandfield on theCap(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--onefilemode 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:
nameMUST equal the parent ({name}/) directory name.versionMUST equal the leaf ({version}/) directory name.channelMUST equal the channel folder (nightlyorrelease).registry_urlMUST be present (key cannot be omitted).nullmeans dev; a URL string means published. For published builds, the URL must behttps(the host rejectshttpoutside dev mode).entryis 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:
- A new
Capinbuild_manifest()with a freshmarker(...)and distinct in/out media URNs. - A new
Opsubclass that implements the work. - A new
runtime.register_op_type(...)call inmain()whose URN string matches the cap URN exactly. Use the build-once-and-keep-the-string pattern from_tag_sentiment_urn()/CAP_TAG_SENTIMENT_URNso 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.