Fifty lines of Python. The Transmute menu picks it up. You wrote a citizen.

What a cartridge actually is

Strip away the protocol, the dispatch rule, and the philosophical claims, and a cartridge is this: a binary that reads stdin and writes stdout.

The binary speaks Bifaci on those two streams — a small framed protocol that handshakes, declares what the cartridge can do, then waits for requests and emits responses. The framing is CBOR. The protocol is well-specified, small, and the same on every host.

That’s the whole contract. Everything else — sandboxing, lifecycle management, peer invocation, progress reporting — is the host’s job. You don’t have to know how XPC works. You don’t have to know how the planner picks routes. You write a function that takes typed input and emits typed output, and you let the runtime carry it.

The minimum viable cartridge

Here is the spine of a real cartridge. It classifies a string of text as positive, neutral, or negative against a tiny word list. Real cartridges replace the classifier with a model; the rest stays the same.

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

def classify(text: str) -> str:
    pos = {"good", "great", "love", "happy"}
    neg = {"bad", "terrible", "hate", "awful"}
    tokens = {t.strip(".,!?").lower() for t in text.split()}
    p, n = len(tokens & pos), len(tokens & neg)
    return "positive" if p > n else "negative" if n > p else "neutral"

class TagSentimentOp(Op):
    async def perform(self, dry: DryContext, wet: WetContext):
        req = wet.get_required(WET_KEY_REQUEST)
        text = collect_text(req.take_frames())
        req.emitter().emit_cbor(classify(text))

    def metadata(self):
        return OpMetadata.builder("TagSentimentOp").build()

URN = (CapUrnBuilder()
       .marker("tag-sentiment")
       .in_spec("media:text;sentiment-input;textable")
       .out_spec("media:sentiment-tag;textable")
       .build())

def main():
    runtime = CartridgeRuntime.with_manifest(
        CapManifest(
            name="sentiment-tagger",
            version="0.1.0",
            channel="nightly",
            registry_url=None,
            description="Tag text as positive / neutral / negative.",
            cap_groups=[default_group([
                Cap(urn=CapUrn.from_string(CAP_IDENTITY), title="Identity", command="identity"),
                Cap(urn=URN, title="Tag sentiment", command="tag-sentiment"),
            ])],
        )
    )
    runtime.register_op_type(URN.to_string(), TagSentimentOp)
    runtime.run()

if __name__ == "__main__":
    main()

Fifty lines, and we cut some boilerplate to keep it readable. The full version is in the Getting Started tutorial, with the frame-collection helper and the install record spelled out.

What the host does for you

You write perform(dry, wet) and emit results. The runtime carries everything else.

Spawning. The host process-spawns your cartridge when a request needs it, and tears it down when memory pressure climbs.

Sandboxing. Your cartridge runs in an XPC service with no default network access and no access to user files outside what’s handed in for the current request.

Frame routing. Bifaci frames flow in. Frames flow out. The runtime multiplexes streams, indexes chunks, validates checksums, and translates frame types into Python objects you actually want to work with.

Peer invocation. If your cartridge needs to call another cartridge, the runtime gives you a typed handle. You don’t deal with subprocesses or sockets directly.

Progress streaming. You emit progress events; the host translates them into UI updates for the running task. The user sees what your cartridge is doing in real time.

Health. Heartbeats keep stuck cartridges from hiding behind a spinner. A missed heartbeat tears the cartridge down and surfaces the failure on the task.

Where it lives

Cartridges live in /Library/Application Support/MachineFabric/Cartridges/{slug}/{channel}/{name}/{version}/.

The path is filesystem-level and intentional. You can ls it. You can rm -rf a cartridge that’s gone wrong. You don’t need a special tool to inspect what’s installed. The cartridge.json install record next to the binary is the same JSON your cartridge declared in its manifest — readable, diffable, scriptable.

For development, you place the cartridge directly in the install root and the host picks it up. For distribution, you sign it with a Developer ID, notarise it with Apple, package it as a .pkg, and propose it on cartridge-shelf. Every accepted cartridge ends up in the canonical manifest at cartridges.machinefabric.com, shipping with every install.

Build one this weekend

The shortest path from “I have a Python script that does X” to “X is a verb on the Transmute menu” is the Getting Started tutorial. It walks through the build above, end to end, including the install layout and the smoke tests.

If you’ve ever written a script that takes a file and produces a result, you have a cartridge. The protocol exists so you don’t have to invent the contract yourself.