perch as an LLM control plane¶
You can use perch as a protected execution layer for an LLM, so it only runs the commands you declared โ without standing up backend infrastructure.
That's the strongest framing of what perch is good for: it replaces a custom backend whose only job is letting an agent perform a fixed set of actions safely.
This page walks through the pattern and shows why a .perch file + perch-mcp + a few CLI flags can do what a 2,000-line FastAPI service used to.
The usual problem¶
You want an LLM agent to do something โ restart a service, refund a customer, fetch logs, run a migration, send an email. The conventional shape:
- Spin up a backend (FastAPI / Express / Go).
- Define endpoints โ one per agent-callable action.
- Add authentication.
- Hand-roll input validation per endpoint.
- Add audit logging middleware.
- Add rate limiting.
- Write function-calling glue for whichever LLM framework you use (Claude tool use, OpenAI function calling, LangChain tools).
- Define a JSON schema per function for the LLM to consume.
- Keep all of it in sync as the API evolves.
- Maintain it.
That's a lot of plumbing for "let the agent call these eight functions safely." Most of it is not the agent's actions โ it's the scaffolding to expose actions to an agent at all.
The perch alternative¶
Write a .perch file. Run perch-mcp with whatever restrictions you want. That's it.
name "ops"
about "Operations the agent can perform"
version "0.1.0"
command restart_pod
description "Restart a Kubernetes pod"
arg ns
type string
description "Namespace (must match ^[a-z0-9-]+$)"
end
arg pod
type string
description "Pod name (must match ^[a-z0-9.-]+$)"
end
do
if not regex_match "${ns}" "^[a-z0-9-]+$"
fail "invalid namespace"
end
if not regex_match "${pod}" "^[a-z0-9.-]+$"
fail "invalid pod name"
end
kubectl -n ${ns} delete pod ${pod}
end
end
command get_logs
description "Fetch recent pod logs"
arg ns type string end
arg pod type string end
arg lines type int default 100 end
do
logs = kubectl -n ${ns} logs ${pod} --tail=${lines}
print "${logs}"
end
end
command scale_deployment
description "Set the replica count on a deployment"
arg ns type string end
arg name type string end
arg replicas type int end
do
if replicas > 50
fail "replicas > 50 needs a human"
end
kubectl -n ${ns} scale deploy/${name} --replicas=${replicas}
end
end
Then:
That's the backend.
The agent connects via MCP, calls perch_list to discover the verbs, calls perch_run with named args. The schema you wrote is the API. Anything outside it is rejected with a typed error โ no JSON-parsing bugs to hand-write past, no auth bypass to engineer around.
What you get without writing it¶
| Concern | Custom backend | perch |
|---|---|---|
| Endpoint definition | route + handler code | command NAME โฆ do โฆ end |
| Arg schema for the LLM | code-gen or hand-written JSON | auto-exposed via perch_list |
| Input validation | hand-rolled per route | typed args (type string/int/float/bool), regex_match guards |
| Auth boundary | bespoke middleware | "is the agent allowed to talk to this perch-mcp instance" |
| Per-action restrictions | code | declared command set; non-declared verbs simply don't exist |
| Filesystem / shell restrictions | not provided by HTTP framework | --no-shell, --no-write, --no-subprocess |
| Network egress restrictions | firewall, separate concern | --no-network |
| Env-var visibility | the host's env, all of it | --env A,B,C (everything else errors) |
| Audit logging | custom middleware + format | NDJSON stream from --server; op-level error trail |
| Error consistency | per-handler | uniform: op "X" is disabled by --no-Y / arg fails regex /โฆ/ / command not declared |
| LLM-facing tool schema | maintain by hand | derived from the file โ name, description, typed args |
| Auditability of the "API surface" | "read the codebase" | "read the 50-line .perch file" |
| Testing | full backend test infra | perch --dry-run cmd, perch --ask cmd, perch --check |
| Local hand-execution | rare | perch <cmd> runs the same path |
| Deploy | container + secrets + ingress | one binary (go install); perch --build for embedded |
The right column is one file plus a process. The left column is a quarter of a sprint.
The schema IS the controlled-execution boundary¶
Honest scope: perch is controlled scripting, not a kernel-level sandbox. With
--no-shellthe boundary is airtight (no subprocess ever fires). Withshellallowed, the spawned process can still talk to the kernel โ perch only fences its own op dispatch. For genuinely adversarial input, layer perch underfirejail/sandbox-exec/AppContainer. The rejections below describe the perch-level boundary; the OS-level boundary is your responsibility.
When the agent tries something you didn't declare, there's no defensible-by-default code path it can reach. The chain of rejections at the perch level:
-
Verb not declared โ
-
Arg type wrong (agent passes a string where an int is expected) โ
-
Arg value fails your validation โ
(because you wrote if not regex_match "${ns}" "^[a-z0-9-]+$" fail โฆ)
-
Op outside the allowed catalog (agent crafts an arg that would trigger a banned op โ possible because the file uses
shell; you've also opted into--no-network) โ -
Env var not on the allowlist (script interpolates
${SECRET_AWS_KEY}you never declared) โ -
HTTP destination is off-allowlist (agent crafts a URL pointing at a host you never approved) โ
Even a 30x redirect from
api.github.comtoattacker.comis refused โ every redirect destination is re-validated. Combined with the default-on SSRF guard (no AWS metadata, no localhost pivot, no scheme downgrade, max 5 hops, DNS-rebinding multi-A check), the agent can hit only the hosts you declared. This is the critical piece for agents that pick URLs themselves.
Every one of these is a uniform, structured failure. You audit them by reviewing the .perch file โ not by reading a Go service across 14 files.
Three verticals โ same shape¶
1. Kubernetes / infrastructure ops¶
The example above. Verbs the agent gets: restart_pod, get_logs, scale_deployment. Anything else is unreachable. Combined with --no-network and --env KUBECONFIG,HOME, the agent has no path to exfiltrate state or hit external services.
2. Customer-support actions¶
command refund_order
description "Issue a refund (capped at $500)"
arg order_id type string end
arg amount type float end
arg reason type string end
do
if amount > 500.0
fail "amount > $500 needs a human"
end
body = format '{"order_id":"${order_id}","amount":${amount},"reason":"${reason}"}'
resp = http_post "https://billing.internal/refund" body
print "${resp}"
end
end
command reset_password
description "Send password-reset email"
arg user_email type string end
do
if not regex_match "${user_email}" "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]+$"
fail "invalid email"
end
support-cli reset-password --email=${user_email}
end
end
Run as perch-mcp --no-write --env BILLING_TOKEN -f support.perch. The agent can refund but only up to $500. It can reset passwords but only for validly-shaped emails. It cannot do anything else, regardless of how cleverly it phrases its request.
3. Database queries (canned, parameterized)¶
command sales_for_region
description "Last 30 days sales for one region"
arg region type string description "lower-case region code (us-east, eu-west, โฆ)" end
do
if not regex_match "${region}" "^[a-z]+-[a-z]+$"
fail "invalid region"
end
shell_output `psql -h db -U readonly -c "SELECT sum(amount) FROM sales WHERE region='${region}' AND created_at > now() - interval '30 days'"`
end
end
Run as perch-mcp --no-network --env PGPASSWORD --no-write -f reports.perch. The agent can query but only the canned queries you wrote. It cannot run raw SQL. There is no SQL-injection surface to write a filter for, because the agent never picks the query string.
Pair with --ask for in-the-loop review¶
In high-stakes settings, the operator can be the gate even when the agent picks the verb:
The agent proposed the action; the human sees [1] shell cmd="kubectl -n prod delete pod api-3" and answers y, n, a, or q. Halfway between "agent acts" and "agent suggests." The same .perch file works for both โ no separate review code.
Audit the file like you'd audit a config¶
--check rejects undeclared placeholders, missing args, type mismatches, calls to ops that don't exist. The file IS the policy; reading it IS the audit. A new colleague โ or a security reviewer โ can absorb the entire LLM-callable surface area in the time it takes to read a 50-line YAML config.
This is the property that makes perch genuinely cheap to ship: you can give someone the file and they know everything the agent can do.
Setting it up โ five minutes¶
# 1. Install the MCP server
go install github.com/olivierdevelops/perch/cmd/perch-mcp@latest
# 2. Write your ops.perch (use the perch skill or the language reference)
# https://olivierdevelops.github.io/perch/language/
# 3. Validate it
perch --check ops.perch
# 4. Wire into your agent (Claude Desktop shown; OpenAI / Anthropic SDK
# function-calling works the same โ perch_list returns the schema)
cat ~/Library/Application\ Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"ops": {
"command": "perch-mcp",
"args": [
"--no-network",
"--no-write",
"--env", "KUBECONFIG,HOME",
"-f", "/abs/path/to/ops.perch"
]
}
}
}
# 5. Restart Claude Desktop. Done.
Five steps, no backend service, no Docker image, no ingress, no secret manager wiring beyond what your shell already has via ${KUBECONFIG} etc.
When this is NOT the right tool¶
To be fair about what perch isn't:
- Streaming responses to the LLM. perch is request/response. If the LLM needs incremental output (e.g. "watch this build and tell me when it fails"), wrap the long-running thing in a command that polls + returns a structured summary.
- Stateful sessions across LLM turns. perch is stateless per-call. State lives in your files/databases/external systems โ which is usually where it belongs anyway.
- Building a public SaaS. perch is for org-internal or agent-internal tool access. It doesn't replace your customer-facing API.
- Anything that needs custom auth flows. "Is this agent allowed to talk to this
perch-mcpinstance" is a question for the orchestration layer (Claude Desktop config, kubernetes-secret-injection, etc.), not perch itself.
For everything else โ the broad case of "give an agent a fixed set of typed actions to perform" โ this is dramatically less code than building it yourself.
Summary¶
| Custom backend | perch + perch-mcp |
|
|---|---|---|
| Lines of code to expose 8 actions to an LLM | 1kโ3k | one .perch file (~50 lines) |
| What "audit" means | read a codebase | read the file |
| Where the security boundary lives | scattered across handlers/middleware/auth | the grammar + a few CLI flags |
| Where the LLM tool schema lives | hand-maintained per action | derived from the file |
| Time to add a new action | feature branch, PR, deploy | edit file, restart perch-mcp |
| Time to remove an action | same | delete the command block |
| Deploy footprint | service, secrets, ingress, dashboard | a binary on $PATH |
| Local replay | "spin up the backend on your laptop" | perch <cmd> |
| Restrict capabilities | code | composable --no-* flags + --env allowlist |
You're not getting fewer features. You're getting the same features with vastly less code to write and maintain โ because perch already implemented the framework half (typing, dispatch, validation, restrictions, audit) and your file just declares the actions on top.
That's the value proposition: a controlled LLM-action surface without backend infra.