Skip to content

vivesca/porin

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

10 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

porin

Agent-facing CLI layer for Python. JSON envelopes, command tree discovery, NDJSON streaming — works with any CLI framework.

Biology: porins are beta-barrel proteins in outer membranes that form structured channels for selective bidirectional passage of molecules.

Install

pip install porin

Quick start

from porin import ok, err, action, emit_ok, emit_err

# Build envelopes (returns dict)
result = ok("mytool status", {"id": "abc", "state": "running"}, [
    action("mytool logs abc", "Fetch output logs"),
    action("mytool cancel abc", "Cancel if stuck"),
])

# Write to stdout as JSON line
emit_ok("mytool list", {"count": 3, "items": [...]})

# Structured errors with fix suggestions
emit_err("mytool status bad-id", "Not found", "NOT_FOUND",
         fix="Verify the ID with: mytool list")

What it provides

Envelope helpersok(), err(), action() build the JSON envelope. emit_ok(), emit_err() write them to stdout.

{"ok": true, "command": "mytool status abc", "result": {"state": "running"}, "next_actions": [...]}
{"ok": false, "command": "mytool status bad", "error": {"message": "Not found", "code": "NOT_FOUND"}, "fix": "Verify with: mytool list", "next_actions": [...]}

Command tree — declarative registry for agent self-discovery:

from porin import CommandTree

tree = CommandTree("mytool")
tree.add_command("list", description="List items", params=[
    {"name": "--count", "type": "integer", "default": 10},
])
tree.add_command("status", description="Get status", params=[
    {"name": "item_id", "type": "string", "required": True},
])

# Bare invocation returns this as JSON — agent learns your full API in one call
emit_ok("mytool", tree.to_dict())

NDJSON streaming — for long-running operations:

from porin import stream_event, stream_finish

stream_event("progress", {"step": 1, "total": 3})
stream_event("progress", {"step": 2, "total": 3})
stream_finish("mytool run", {"completed": True})
# Last line is always the standard envelope — backwards compatible

Tool annotations — declare behavior for agent safety decisions:

tree.add_command("deploy", description="Deploy service", params=[...],
    annotations={"readonly": False, "destructive": True, "idempotent": False})
tree.add_command("list", description="List items", params=[...],
    annotations={"readonly": True})

# Annotations appear in to_dict() and to_schema()
# Omitted entirely when not provided (no empty dicts)

Input examples — improve agent accuracy by 18pp (Anthropic data):

tree.add_command("status", description="Get status", params=[...],
    examples=[
        {"args": "status abc-123", "description": "Check a specific item"},
        {"args": "status --all", "description": "List all statuses"},
    ])

# Examples appear in to_schema() only (not to_dict() — keeps discovery lightweight)

Pagination — prevent context overflow on large result sets:

from porin import paginated_ok, emit_paginated_ok

emit_paginated_ok("mytool list", items=[...], total=100, next_cursor="pg2")
# {"ok": true, "command": "mytool list", "result": {"items": [...], "total": 100, "has_more": true}, "next_cursor": "pg2", ...}

Envelope version — contract stability for agents:

emit_ok("mytool status", {"id": "abc"}, version="1.2.0")
# {"ok": true, ..., "version": "1.2.0"}
# Omitted when not provided — backwards compatible

Concise envelopes — cut token usage by ~66%:

from porin import ok_concise

ok_concise("mytool status", {"state": "running"})
# {"ok": true, "command": "mytool status", "result": {"state": "running"}}
# No next_actions — minimal footprint for simple queries

Exit codes — semantic contract:

from porin import EXIT_OK, EXIT_ERROR, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_PERMISSION, EXIT_CONFLICT

Design principles

Based on converging patterns from three independent sources:

  1. JSON is the only output format
  2. Every response includes next_actions (HATEOAS for CLIs)
  3. Errors include a fix field with recovery instructions
  4. Bare invocation returns the command tree
  5. Exit codes are a semantic contract
  6. NDJSON for streaming operations

MCP bridge

Turn any porin CLI into an MCP server with one call:

pip install porin[mcp]
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})
tree.add_command("deploy", description="Deploy service", params=[
    {"name": "service_name", "type": "string", "required": True},
], annotations={"destructive": True})

async def dispatch(command: str, args: dict) -> dict:
    if command == "list":
        return {"items": [...], "count": args.get("count", 10)}
    if command == "deploy":
        return {"deployed": args["service_name"]}
    raise ValueError(f"Unknown: {command}")

serve(tree, dispatch)  # starts stdio MCP server

Porin annotations map to MCP hints automatically (readonly -> readOnlyHint, destructive -> destructiveHint, etc.).

For more control, use create_mcp_server() which returns the FastMCP instance:

from porin.mcp_bridge import create_mcp_server

server = create_mcp_server(tree, dispatch, server_name="my-server")
# Add custom resources, prompts, etc. to the server
server.run()

Framework-agnostic

The core library has zero dependencies. It works with any CLI framework:

# cyclopts
from cyclopts import App
from porin import emit_ok, CommandTree

app = App(help_flags=[])

@app.default
def main():
    emit_ok("mytool", tree.to_dict())

# Click, Typer, argparse — same pattern

License

MIT

About

Agent-facing CLI layer — JSON envelopes, auto-generated command trees, and NDJSON streaming for any Python CLI framework

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages