skip to content
Terry Li

Last week I wrote about seven patterns for agent-facing CLIs. This week I turned them into a library.

porin is a zero-dependency Python library that handles the output side of agent-facing CLIs: JSON envelopes, command tree discovery, NDJSON streaming, and semantic exit codes. It works with any CLI framework — Click, Typer, cyclopts, argparse.

The name comes from cell biology. Porins are barrel-shaped proteins in bacterial outer membranes that form structured channels for selective passage. The library is the structured channel between your CLI and the agent invoking it.

What the patterns actually need

The seven patterns sound simple when described. Implementing them revealed three things the patterns don’t say:

Discovery needs two tiers. Bare invocation returns the command tree — but how much detail? Full schemas with return types, error codes, and examples waste tokens when the agent just needs to know what commands exist. porin splits this into to_dict() (names + params only) and to_schema() (everything). The lightweight tier is what you emit on bare invocation. The full schema is what mytool schema returns when the agent needs detail.

Annotations are safety-critical. Anthropic’s MCP spec defines four behavioral hints: readOnlyHint, destructiveHint, idempotentHint, openWorldHint. Agents use these to decide whether to auto-execute or ask for confirmation. Without them, every tool invocation requires the same level of caution. With them, readonly operations can run unattended while destructive ones get human review. porin lets you declare these per command and they flow through to both the command tree and the MCP bridge.

Pagination is not optional. Dumping 500 results into an agent’s context window is actively harmful. Anthropic restricts Claude Code tool responses to 25,000 tokens by default. porin’s paginated_ok() returns items with has_more and next_cursor — the agent decides when to stop fetching.

The MCP bridge

The most interesting addition is porin[mcp]. One function call turns a porin CLI into a stdio MCP server:

from porin import CommandTree
from porin.mcp_bridge import serve
tree = CommandTree("mytool")
tree.add_command("list", description="List items",
params=[{"name": "--count", "type": "integer", "default": 10}],
annotations={"readonly": True})
async def dispatch(command, args):
if command == "list":
return {"items": get_items(args.get("count", 10))}
serve(tree, dispatch)

The bridge maps porin annotations to MCP hints automatically. The command tree becomes the tool schema. The dispatch function is the handler. No MCP boilerplate.

This matters because the CLI-vs-MCP decision shouldn’t be either/or. Your CLI is the durable artifact. The MCP server is an adapter. porin makes the adapter free.

What I learned

Zero dependencies is a feature, not a constraint. The core library has no deps. The MCP bridge is an optional extra (pip install porin[mcp]). This means the core works everywhere — including constrained environments where you can’t install mcp, fastmcp, or their transitive dependency trees.

Agent-facing is a layer, not a framework. porin doesn’t replace Click or Typer. It’s the output layer that sits underneath. Your CLI framework handles argument parsing, subcommands, and help text. porin handles what the agent sees in stdout.

The gap between patterns and code is smaller than expected. The entire core is 130 lines. Most of the work was figuring out which abstractions earn their keep (CommandTree, paginated_ok) versus which are better left as conventions documented in the README (dry-run, idempotency keys, verbosity control).

porin on PyPI | source on GitHub