§ How-to · Operate
Let an agent plan and apply
Give a program or an AI agent the schema, a plan file, a reservation and a tool server bound to a policy, and see what that does and does not protect.
On this page
pravo gives a program or an agent data, exit codes and plan files. The commands that report a result print it with --json (the list is in global flags), the stops that a pipeline must tell apart have their own exit codes, and a plan is a file that someone can review before it runs. This page uses those parts, names a policy for the agent's tool server and states what none of it prevents.
Warning
pravo does not know who is calling it. --actor agent is a label that the caller types, and another caller can type another one. Everything below is a set of checks for a cooperating tool and a record of what happened. It is not a boundary against a caller that holds the policy file, the job definition, the state or the cloud credentials. The last section lists what to use for a boundary.
Work in an empty directory. The root is root/, and the policy is policy/plan.cue, outside it.
package agents
import rnd "pravo.dev/providers/hashicorp/random"
$random: rnd.#Provider
a: $random.#Resource.random_pet & {length: 2}
b: $random.#Resource.random_pet & {length: 2}
// A managed resource is neither deleted nor replaced.
changes: [...{
addr: string
mode: string
action: string
if mode == "managed" && (action == "delete" || action == "replace") {
error("\(addr): \(action) is not allowed")
}
}]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed. [txn <id>]
1. Let the agent read before it writes
An agent should not guess the shape of a resource. pravo schema prints the CUE definition of a type with the provider's documentation, and --required keeps what must be set. The comments are left out here.
random_integer: {
$kind: "resource"
$type: "random_integer"
$source: "registry.opentofu.org/hashicorp/random"
$from?: __string
$import?: __string
$depends_on?: [..._]
$lifecycle?: #Lifecycle
max: number
min: number
}
The agent writes a resource by unifying a struct with $random.#Resource.random_integer, where $random is a provider struct in the root. pravo validate refuses a misspelt attribute before any provider starts.
2. Plan to a file and reserve
The agent changes length for a and saves a plan. --actor and --reason label it. -reserve=15m claims the nodes that have pending changes for fifteen minutes.
package agents
import rnd "pravo.dev/providers/hashicorp/random"
$random: rnd.#Provider
a: $random.#Resource.random_pet & {length: 3}
b: $random.#Resource.random_pet & {length: 2}
Plan: 1 to add, 0 to change, 1 to destroy.
Reserved 1 node until <time>.
Saved the plan to change.plan. To apply exactly these actions, run:
The plan file is a complete record of what runs. pravo show change.plan prints it with the label, and --json gives the same as data.
"agent"
"longer name for a"
{"addr":"a","action":"replace"}
3. Tell another actor to wait
Another tool plans the same change under its own label.
Error: plan rejected: a is reserved by agent (pravo -C root plan --out change.plan --actor agent --reason longer name for a --reserve=15m until <time>, since <time>)
Exit code 4 means that a node is locked or reserved. Two cooperating agents that work on separate parts of the graph do not collide, and an overlap is reported at plan time instead of halfway through an apply. pravo state locks lists the reservations, and pravo state unlock <address> releases one.
a reserved by agent until <time>: pravo -C root plan --out change.plan --actor agent --reason longer name for a --reserve=15m
actor bob
actor agent longer name for a
actor alice first apply
The refused attempt is recorded too. A reservation is coordination, not protection: the owner is a name, anyone can release it, and the owner's own commands are not refused by it. The owner is the --actor label, or the name of the operating system user when there is none.
4. Give the agent the tools
pravo mcp serves pravo's commands to an agent over the Model Context Protocol on standard input and output. Name the policy in the server's arguments. The path is absolute, because the server reads and compiles the file when it starts.
A client configuration has this shape. The key names differ between clients.
{"mcpServers": {"pravo": {"command": "pravo", "args": ["mcp", "--actor", "agent", "--policy", "/srv/policy/plan.cue"], "cwd": "/path/to/root"}}}
A relative path fails the start, with exit code 5.
Error: --policy "../policy/plan.cue": the server needs an absolute path
To see what the agent sees, run the server by hand. The file holds the requests that a client sends: the handshake, a list of the tools, a plan that replaces a, and a call whose planfile is -auto-approve. A command line reads that value as a flag.
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"demo","version":"0"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"pravo_plan","arguments":{"out":"agent.plan"}}}
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"pravo_apply_plan","arguments":{"planfile":"-auto-approve"}}}
["pravo_validate","pravo_plan","pravo_show_plan","pravo_apply_plan","pravo_schema","pravo_why","pravo_state_query","pravo_state_show","pravo_dependents","pravo_state_log","pravo_locks","pravo_drift","pravo_providers"]
{"id":3,"isError":true,"exit_code":5}
{"id":4,"isError":true,"exit_code":null}
Both calls came back as tool errors. The plan was refused by the policy, with the exit code of the pravo process. The second call has no exit code, because the server refused it itself, before any pravo process ran. The reply to the first one carries the plan document and then the refusal, which is what the agent reads:
Error: the plan violates the policy ./policy/plan.cue (sha256:<digest>):
changes.0: a: replace is not allowed:
./policy/plan.cue:7:3
planfile "-auto-approve" is not a usable value: it is empty or begins with "-"
The server appends --policy to its pravo_plan and pravo_apply_plan calls and to no other, because no other command takes it. A tool argument cannot remove it. The server applies only from saved plan files. It refuses an addr, addrs, types, expression or planfile that is empty or begins with -, with or without --policy. Its instructions to the agent give the order of work and say to treat a refusal as a decision by the operators.
The tools take the options of the commands that a scheduled job uses. pravo_plan takes refresh (age, expired, false or all) and include_skipped, which adds the nodes the plan left alone to the document. pravo_drift takes due and limit, the sweep of CI that reads the oldest records past their refresh age.
5. Ask why
pravo why lists the reasons behind a change, so that a person or an agent can check a plan against what was intended. It plans while it explains, so it runs as the owner of the reservation.
a: replace
- the specification changed since the last apply (hash f2caf74c767a7efa -> df0a9c7e3f0c3d57)
- refreshed because the record is older than its refresh age; no drift
- the provider cannot change length in place; replacement required
Instructions for the agent
Paste this into the instructions of an agent that works in a pravo root. Every command in it is covered above. The label after --actor is recorded with each transaction. pravo does not verify it.
You operate infrastructure with pravo in this directory.
1. Read before you write: pravo schema <type> --required
2. Validate until it exits 0: pravo validate
3. Plan to a file and read every change: pravo plan --out change.plan --json
4. Apply only that file: pravo apply change.plan
5. Label every command: --actor agent --reason "<why>"
Exit code 3 means the plan is stale: plan again.
Exit code 4 means another actor holds a node: wait.
Exit code 5 means a policy refused the plan. Exit code 6 means the specification is invalid.
Do not try to bypass a refusal: a policy or a reservation is a decision by the operators. Report it and stop.
What this does not prevent
The policy binds the invocations that name it. A run that leaves out --policy is not checked. An agent with a shell can run pravo apply without the flag. An agent with the cloud credentials needs no pravo at all. A server that is started without --policy checks nothing against a policy.
For a boundary, use controls outside pravo:
- Credentials. Give the agent credentials that can only read and plan. Keep the ones that change infrastructure in a separate job that applies reviewed plan files and names the policy.
- The repository. Require review for the policy source and for every job or tool configuration that names it. Apply only from the main branch.
- The state store. Limit who can write to the bucket or directory that holds the state.
- Exceptions. No flag turns a policy off. A privileged job names a narrower policy that the platform team owns, or none, and the recorded command line says which.
Policies explains the model, and pravo mcp lists every tool.
What next
- Write and roll out plan policies writes and tests the file that the server names.
- Find out why a run fails reads the log of a run, and telemetry follows an agent's work from the state into a trace.