Python middleware for CLI tools that makes them speak natively to AI agents — with adapters for click, typer, and argparse.
murli is named after Krishna's sacred flute. The murli's music enchants every listener — each feeling it was meant for them alone. This library takes the same approach: your commands don't change, but a human at a terminal gets clear readable output, and an agent reading from a pipe gets structured JSON. Each audience gets the experience shaped for them.
Full documentation: murli.allankent.com
Five principles guide everything murli does:
One tool, two audiences. Humans and agents call the same commands. murli routes output automatically — no if agent { ... } branches in your code.
Discoverability is a first-class feature. Agents shouldn't need documentation to use your tool. The tool describes itself.
Errors are instructions, not messages. A structured error tells an agent what went wrong, whether to retry, and what to do instead.
Dangerous operations require explicit intent. Mutations are rejected in non-interactive mode until the agent — or human — confirms they know what they're doing.
Context windows are finite. Log deduplication, clean stderr routing, and streaming results keep agent context consumption predictable.
pip install murli # core only (argparse adapter included)
pip install "murli[click]" # + click adapter
pip install "murli[typer]" # + typer adapter
pip install "murli[all]" # click + typerimport click
import murli
@click.group()
def cli(): pass
murli.enable(cli) # one line — adds --agent, --schema, --force, --dry-run, --output, --profile
# mounts describe, doctor, profile subcommands
@cli.command()
@murli.pass_writer
def deploy(writer):
writer.write_success("Deployed", {"status": "ok"})
if __name__ == "__main__":
cli()import typer
import murli
app = typer.Typer()
murli.enable(app)
@app.command()
def deploy(ctx: typer.Context):
writer = murli.get_writer(ctx)
writer.write_success("Deployed", {"status": "ok"})
if __name__ == "__main__":
app()import argparse
import murli
parser = argparse.ArgumentParser(description="My tool")
murli.enable(parser)
parser.add_argument("--env", required=True)
args, writer = murli.parse(parser) # drop-in for parse_args()
writer.write_success(f"Deployed to {args.env}", {"env": args.env})The same command produces plain text for humans and structured JSON for agents. murli detects whether stdout is a TTY automatically.
$ ./mytool deploy
Deployed
$ ./mytool deploy | cat
{
"status": "ok",
"schema_version": "1.0",
"message": "Deployed",
"result": {}
}Use --agent to force JSON mode without piping:
$ ./mytool deploy --agentUse --output to control serialisation explicitly:
$ ./mytool deploy --output ndjson # minified single-line JSON
$ ./mytool deploy --output text # human text even when pipedWrite your output once — murli routes it.
@cli.command()
@murli.pass_writer
def query(writer, text):
results = search(text)
writer.write_success(
f"Found {len(results)} results", # human text
results, # agent payload
)describe is auto-mounted — one call, full tree.
$ ./mytool describe
{
"name": "mytool",
"schema_version": "1.0",
"capabilities": ["agent", "schema", "dry-run", "force", "profiles"],
"commands": [...]
}Generate an AGENTS.md stub for your repository:
$ ./mytool describe --agents-md > AGENTS.md--schema is auto-registered on every command.
$ ./mytool deploy --schema
{
"name": "deploy",
"summary": "Deploy to production",
"idempotent": false,
"flags": [...]
}doctor checks naming conventions.
$ ./mytool doctor
All naming conventions satisfied.Annotate commands to give agents richer signal:
murli.annotate(deploy_cmd, murli.Metadata(
agent_description="Deploys the application to the target environment.",
when_to_use="Use when the build has passed and artifacts are ready.",
idempotent=False,
mutating=True,
dry_runnable=True,
returns=murli.ReturnSchema(
description="Deployment result",
type="object",
properties={"env": "string", "version": "string"},
),
examples=[
murli.Example(command="mytool deploy --env prod", description="Deploy to production"),
],
flag_annotations={
"env": murli.FlagAnnotation(
enum=["dev", "staging", "prod"],
env="MYTOOL_ENV",
),
"token": murli.FlagAnnotation(sensitive=True, env="MYTOOL_TOKEN"),
},
))FlagAnnotation fields:
| Field | Type | Purpose |
|---|---|---|
env |
str |
Environment variable that sets this flag |
sensitive |
bool |
Flag carries secrets; agents must not log its value |
persistent |
bool |
Flag applies to all subcommands |
enum |
list[str] |
Exhaustive list of valid values |
pattern |
str |
Regex the value must match |
mutually_exclusive_with |
list[str] |
Other flags that cannot be set simultaneously |
profileable |
bool |
Flag can be saved in a profile |
Every error carries structure: what failed, whether to retry, and what to do next.
# Convenience constructors
raise murli.AgentError.user_error("query string cannot be empty", "Provide a search keyword.")
raise murli.AgentError.tool_error("database connection failed: timeout after 30s")
raise murli.AgentError.not_found("index not found at ~/.mytool/index", "Run `mytool index build`.")
raise murli.AgentError.rate_limited("API rate limit hit", retry_after_ms=5000)
# Or build the full error for precise control
raise murli.AgentError(
code=murli.EXIT_NOT_FOUND,
error_type="index_missing",
message="Semantic index not found at ~/.mytool/index",
suggestion="Run `mytool index build` to create the index first.",
recoverable=False,
doc_url="https://example.com/docs/indexing",
)In agent mode, murli writes the error envelope to stderr and exits:
{
"status": "error",
"code": 5,
"error": "index_missing",
"message": "Semantic index not found at ~/.mytool/index",
"suggestion": "Run `mytool index build` to create the index first.",
"recoverable": false,
"schema_version": "1.0"
}Exit code taxonomy (identical across all murli implementations):
| Code | Constant | Meaning |
|---|---|---|
0 |
EXIT_OK |
Success |
1 |
EXIT_USER_ERROR |
Bad input or configuration — fix and retry |
2 |
EXIT_TOOL_ERROR |
Environment or internal failure |
3 |
EXIT_PARTIAL |
Some operations succeeded, some failed |
4 |
EXIT_TIMEOUT |
Operation timed out — retry after a delay |
5 |
EXIT_NOT_FOUND |
Requested resource does not exist |
6 |
EXIT_PERMISSION |
Insufficient permissions |
7 |
EXIT_CONFLICT |
State conflict (resource already exists, etc.) |
8 |
EXIT_RATE_LIMITED |
Rate limit hit — wait retry_after_ms |
9 |
EXIT_CANCELLED |
Operation cancelled |
Mark a command mutating and the guard is automatic. In non-interactive mode (no TTY, no --force), murli rejects it before your handler runs:
@app.command()
@murli.pass_writer
def delete(writer, name: str):
if writer.is_dry_run():
writer.write_plan(f"Would delete {name}", {"would_delete": name})
return
# real deletion
writer.write_success(f"Deleted {name}", {"deleted": name})Annotate the command:
murli.annotate(delete_cmd, murli.Metadata(mutating=True, dry_runnable=True, destructive=True))When called non-interactively without --force:
{
"status": "error",
"code": 1,
"error": "confirmation_required",
"message": "This command mutates state and requires explicit confirmation.",
"suggestion": "Pass --force or --yes to proceed without a TTY.",
"recoverable": true
}The safety block appears automatically in --schema output:
{
"name": "delete",
"safety": {
"read_only": false,
"idempotent": false,
"destructive": true,
"dry_run_supported": true
}
}Named sets of flag values that apply automatically on every invocation.
# Save current flags as a profile
$ mytool profile set production env=prod token=abc123
# Use it by default
$ mytool profile set-default production
# Override for a single invocation
$ mytool --profile staging deployprofile subgroup is auto-mounted by murli.enable(). Available subcommands: list, set, delete, set-default, show.
Profiles are stored in platformdirs.user_config_dir(tool_name) / "profiles.json".
Stream incremental results with write_event:
for result in process_items(items):
writer.write_event({"event": "chunk", "data": result}) # NDJSON line per call; no-op in TTY
writer.write_success("Processing complete", {"total": len(items)})Report progress with write_progress:
writer.write_progress(murli.ProgressEvent(
stage="indexing",
current=500,
total=2000,
percent=25.0,
eta_ms=6000,
message="Indexing files",
))Agent mode: {"event":"progress",...} NDJSON on stdout. TTY mode: human-readable on stderr.
Log with deduplication:
writer.log("Scanning /docs") # stderr
writer.log("Scanning /docs") # deduplicated — collapses in agent mode
writer.log("Scanning /docs") # deduplicatedAgent stderr output:
{"ts":"2026-05-31T12:00:00","level":"info","msg":"Scanning /docs","repeated":2}ANSI escape codes are stripped automatically in agent mode.
Enable murli on a click.Group, typer.Typer, or argparse.ArgumentParser. Injects --agent, --schema, --force/--yes, --dry-run, --output, --profile flags. Mounts describe, doctor, profile subcommands (on Groups).
Attach rich metadata to a command. All fields are optional.
Get the Writer from a click or typer context.
argparse drop-in for parse_args(). Returns both the namespace and a configured Writer.
click decorator — injects writer: Writer as the first argument to the decorated function.
| Method | TTY | Agent |
|---|---|---|
write_success(human_text, json_payload) |
stdout plain text | {"status":"ok",...} to stdout |
write_plan(human_text, plan) |
stdout plain text | {"status":"plan",...} to stdout |
write_error(err: AgentError) |
stderr plain text + exit | {"status":"error",...} to stderr + exit |
write_event(v) |
no-op | NDJSON line to stdout |
write_progress(evt) |
stderr plain text | {"event":"progress",...} to stdout |
log(msg) |
stderr | stderr |
is_tty() -> bool |
||
is_forced() -> bool |
||
is_dry_run() -> bool |
@dataclass
class Metadata:
agent_description: str = ""
when_to_use: str = ""
idempotent: bool = False
mutating: bool = False
dry_runnable: bool = False
destructive: bool = False
arguments: list[ArgumentMetadata] = ...
returns: ReturnSchema | None = None
examples: list[Example] = ...
flag_annotations: dict[str, FlagAnnotation] = ...pip install "murli[dev]"
pytestmurli-py targets the same wire format and principles as murli-go. Deliberate Python-specific differences:
| Feature | murli-go | murli-py |
|---|---|---|
| Profile storage | ~/.<tool>/profiles.json |
platformdirs.user_config_dir (platform-appropriate) |
| Profile command | profile save / profile use |
profile set / profile set-default |
| Mutation guard scope | Per-command (cobra wraps all) | Per leaf command (Groups are routers) |
| Context cancellation | Auto-mapped from context.Canceled |
Not applicable (sync Python) |
Distributed under the MIT License.