Cross-runtime portability¶
A looplet cartridge is just a directory of declarative files plus (optionally) some Python. The portability analyser answers one question about any cartridge:
Can this agent package run on a non-Python loader (Rust / Go / TypeScript), and if not, exactly which components pin it to a Python host?
from looplet.cartridge import analyse_cartridge
report = analyse_cartridge("examples/dep_doctor_portable.cartridge")
print(report.render())
print(report.profile) # "portable" | "python-host"
print(report.blockers) # the INPROCESS components, if any
The analyser is static: it reads the cartridge directory and its
config.yaml / runtime.yaml rather than importing any Python bodies,
so it can grade cartridges whose code can't even be imported in the
current environment.
The four tiers¶
Every component is classified into one of four tiers (best → worst for
portability). A cartridge is in the portable profile when it has
zero INPROCESS components; otherwise it is python-host and the
INPROCESS components are the exact blockers.
| Tier | Symbol | Meaning | Blocker? |
|---|---|---|---|
PROTOCOL |
● | Pure data or out-of-process protocol — config.yaml, prompts/, mcp_servers: tools, kind: lep hooks. Runs on any conforming loader with no shared code. |
no |
STDLIB |
◐ | Declarative reference to a looplet-shipped archetype (builtin_tools: / builtin_hooks:). No Python body in the cartridge. |
no |
RUNTIME |
◈ | A resources/*.py whose only job is to wrap a host-provided service (compaction, the skill manager) via a looplet factory like default_compact_service. The service is a host responsibility every loader ships its own equivalent of. |
no |
INPROCESS |
○ | A Python body or author-owned shared object that pins the cartridge to a Python host — tools/<n>/execute.py, single-file tools/<n>.py, hooks/<n>/ class hooks, author-owned resources/*.py (@ref shared mutable state). |
yes |
extends: is resolved transitively: a child cartridge inherits its
parent's components (and therefore its parent's blockers), tagged
(inherited) in the report.
The three protocol surfaces¶
A fully-portable cartridge moves every author-owned capability onto one of looplet's three out-of-process protocol surfaces:
| Surface | Carries | Transport | Cardinality |
|---|---|---|---|
| MCP | tools | stdio JSON-RPC | 1 server : N tools |
| LEP (Loop Effect Protocol) | hooks (permission / done policies) | stdio JSON-RPC | 1 server : 1 hook |
| SSP (State Service Protocol) | shared mutable state | AF_UNIX SOCK_STREAM |
1 service : N clients |
The host ships only a declared view to a hook; the hook returns a decision. The host spawns an MCP server and calls its tools. Neither requires the host to execute any author Python.
Portable example cartridges¶
Each non-trivial example ships a *_portable twin that is byte-for-byte
behaviour-faithful to its in-process original, but classifies as
portable. Every twin has an end-to-end cross-process dogfood test in
tests/test_example_*_portable_cartridge.py.
| Portable twin | Original | What moved | Notes |
|---|---|---|---|
hello_portable |
hello |
greet/done → MCP | smallest demo |
mcp_demo_portable |
mcp_demo |
add + done → MCP, CalcGuard → LEP | both tools served by one MCP server |
planner_portable |
planner |
done → MCP; nested planner_child twin |
subagent builtin tool + LEP guard |
skillful_analyst_portable |
skillful_analyst |
done/read_text/write_text → MCP, WriteScopeGuard → LEP | skill system via search_skills/activate_skill builtins + RUNTIME skill_manager |
dep_doctor_portable |
dep_doctor |
7 audit tools → MCP, RegistryGuard → LEP | find_alternatives degrades to empty (no host LLM in the MCP subprocess) |
threat_intel_portable |
threat_intel |
7 tools → MCP, FeedAllowlistGuard → LEP | regex IOC extraction preserved; LLM severity/risk degrade gracefully |
git_detective_portable |
git_detective |
10 tools → MCP, CouplingGuard → LEP | INPROCESS repo_config replaced by $LOOPLET_PROJECT_ROOT resolution; the server only needs git |
The mechanical port recipe¶
Porting an in-process cartridge to a portable twin is a fixed procedure:
- Fold every
tools/<n>/execute.pybody into one_mcp/tools_server.py— a stdlib-only stdio MCP server. Vendor any data tables (package DBs, threat feeds) into the server. Wire it inconfig.yaml:
-
Keep
kind: lephooks verbatim — they are already PROTOCOL-tier. -
Keep
builtin_tools:/builtin_hooks:— already STDLIB-tier. -
Keep
resources/compact_service.py(and similar) — already RUNTIME-tier (host service), not a blocker. -
Replace author-owned INPROCESS resources — e.g.
git_detective'srepo_config(a shared path) becomes a$LOOPLET_PROJECT_ROOTenv lookup inside the server; genuine shared mutable state would move to an SSP service instead. -
Anchor relative paths to the host root — the MCP subprocess resolves relative paths against
os.getcwd()(the host project root), the portable equivalent ofresolve_project_root(runtime).
LLM-backed tools degrade, they don't break
A tool that calls the host LLM (ctx.llm) can't reach it from a
separate MCP process. The faithful port returns the same fallback
the original takes when ctx.llm is None (e.g.
find_alternatives → empty list, assess_risk → severity-only
summary). Deterministic logic (regex extraction, git statistics) is
preserved in full. True host-LLM access from a server would require
MCP sampling/createMessage callbacks — out of scope for v1.x.
Inherently python-host cartridges¶
Two shipped cartridges are deliberately python-host. They are not ported; their whole purpose is to host and execute Python.
coder¶
A general software-engineering agent: ~16 substantial tools
(bash, edit_file, grep, subagent, worktree, …), 5 class
hooks, and 7 resources. Several resources (file_cache,
workspace_config, eval_*) are genuine INPROCESS shared
mutable state, and the tools execute arbitrary code in the host
environment. The mechanical recipe above would apply tool-by-tool
(bodies → an MCP server, class hooks → LEP, shared state → SSP), but the
result would still need a host that can run a shell and edit a live
workspace — the very thing that makes it useful. Porting it buys no
portability; it stays python-host by design.
agent_factory¶
agent_factory does extends: ../coder.cartridge, so the analyser
reports its blockers as its own (validate_workspace) plus all of
coder's (tagged (inherited)). Its one bespoke tool,
validate_workspace, exists to import and validate a Python workspace
— it is python-host by definition. Like coder, it is documented as
inherently python-host rather than ported.