Safety & Idempotency
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against Python 3.12, Mermaid as rendered by this site’s Antora build and the docsctl running example of
this sub-section, as checked on 2026-09-30. The docsctl publish command and every output shown are illustrative; the
fictional tool exists only on these pages.
An agent will call your command with confidence, sometimes with the wrong arguments, and sometimes twice. It cannot see a consequence it was not told about. The tool therefore has to carry the safety: harmless by default, able to show what it would do, able to stop and ask, and safe to repeat. This page turns those requirements into a pattern that works for a person at a terminal, a CI job and an agent, and that maps cleanly onto the confirmation and annotation mechanisms of MCP and agent frameworks (see Tool Design and Human-in-the-Loop).
Read-only by default
Split commands by what they can change, and make the dangerous ones impossible to reach by accident.
-
Reads are reads.
search,show,list-componentsandvalidateindocsctlhave no side effects, and say so in their metadata (readOnly: true), which the help, the command tree and an MCP wrapper all reuse. -
Writes are explicit commands, not modes of a read command:
docsctl publish, notdocsctl show --write. Reading the command name should tell the reader which side they are on. -
The dangerous flag is opt-in, never default, and its name says what it does (
--force, not--fast). -
Least privilege in the credential, too: a token that can only read cannot publish however the command line is spelled. That is a property of the service the CLI talks to, and the strongest control there is.
Agent hosts add their own allow and deny rules on top; see Permissions & Settings. Those rules are matched against command lines, so a clear split between read commands and write commands makes them easy to write.
--dry-run and JSON plans
A dry run answers "what would you do?" without doing it. For humans a list of lines is enough; for agents the plan should be data they can inspect, summarise for the user, and hand back.
$ docsctl publish --component irurueta-algebra --dry-run --json
{"component":"irurueta-algebra","confirm":{"command":"docsctl publish --component irurueta-algebra --confirm-token pln_7c41e9 --json","required":true},"dryRun":true,"plan":{"add":3,"change":1,"files":["index.html","matrix-inversion.html","matrix-decompositions.html","search-index.js"],"remove":0},"planId":"pln_7c41e9","schemaVersion":1}
Rules for a dry run that can be trusted:
-
It exits 0 and writes nothing: no files, no network side effects, no credential-store changes. (Reading remote state to compute the plan is fine.)
-
It uses the same code path as the real run up to the point of the write, so the plan cannot drift from what actually happens. A dry run that is a separate approximation teaches the agent to distrust it.
-
The plan is structured: counts by kind of change (
add,change,remove), the list of affected items, and enough identification (planId) to refer to it later. -
It says how to proceed through the
confirmobject, described next. -
It has a stable schema, like any other output (see Machine-Readable Output).
The confirmation-envelope pattern
Humans get a prompt: "Publish 4 files? [y/N]". An agent has no reliable way to answer a prompt, and a prompt on a non-interactive stdin would hang or be auto-answered. The pattern for agents replaces the prompt with a refusal that tells the caller exactly how to proceed.
-
The caller runs the destructive command without a confirmation.
-
If it is not confirmed, the tool exits with a dedicated code, 5 (
CONFIRMATION_REQUIRED), writes nothing, and puts an error envelope on stderr that includes the exact command that would confirm. -
The agent shows the plan to the user (or to a policy check) and, only if approved, runs the confirm command.
{"error":{"code":"CONFIRMATION_REQUIRED","details":{"confirmCommand":"docsctl publish --component irurueta-algebra --confirm-token pln_7c41e9 --json","plan":{"add":3,"change":1,"remove":0}},"exitCode":5,"hint":"Review the plan, then run the confirm command.","message":"Publishing overwrites the live site for irurueta-algebra.","retryable":false},"schemaVersion":1}
Why this design:
-
The confirm command is the only bypass the message names, and it is bound to the plan through the token. A token from a dry run for
irurueta-algebradoes not confirm a publish of anything else, and it expires; if the state changed since the plan, the tool rejects the token and the agent has to plan again. -
The exit code is distinct so callers branch on it without parsing text (see Streams & Exit Codes). 5 means "ask", not "failed".
-
The human keeps the decision. The agent cannot confirm by itself unless a person or a policy allowed it: a wrapper that exposes the tool should return the envelope as a structured error and never supply the token on its own (see Wrapping a CLI as an MCP Server).
-
Interactive humans still get the prompt. On a TTY,
docsctl publishasks; the same command with--no-inputor on a non-TTY fails fast with exit 5 and names the bypass, as described on TTY, Colour & Interactivity.
import json
import sys
EXIT_CONFIRMATION_REQUIRED = 5
def require_confirmation(component: str, plan: dict, plan_id: str) -> None:
command = f"docsctl publish --component {component} --confirm-token {plan_id} --json"
envelope = {
"schemaVersion": 1,
"error": {
"code": "CONFIRMATION_REQUIRED",
"exitCode": EXIT_CONFIRMATION_REQUIRED,
"message": f"Publishing overwrites the live site for {component}.",
"hint": "Review the plan, then run the confirm command.",
"retryable": False,
"details": {"plan": plan, "confirmCommand": command},
},
}
print(json.dumps(envelope, sort_keys=True), file=sys.stderr)
sys.exit(EXIT_CONFIRMATION_REQUIRED)
The command string is built from values the tool itself validated (a known component name and a token it generated). An agent should still run it as an argv list, not through a shell, as the security rules of this sub-section require.
--force and --yes
Two different flags do two different jobs, and conflating them is a common mistake.
-
--yes/-yanswers a confirmation prompt with "yes" for callers that have already decided. It does not remove safety checks; it only removes the question. -
--forceoverrides a check the tool would otherwise enforce: publishing over a newer remote version, ignoringvalidateproblems, skipping the plan-binding token. It is named for what it overrides, documented as dangerous, and never implied by another flag.
For agents the practical rule is: the help says that the normal path is dry-run then confirm, and that --force exists for a
person who has decided to bypass the guard. A skill or instructions file can then say "never pass `--force`" (see
Self-Describing Help), and a host’s permission rules can deny it outright.
# 1. Plan, then confirm with the token from the plan (the agent path)
docsctl publish --component irurueta-algebra --dry-run --json
docsctl publish --component irurueta-algebra --confirm-token pln_7c41e9 --json
# 2. A person at a terminal: prompted, answers y
docsctl publish --component irurueta-algebra
# 3. A deliberate bypass of the plan binding (documented as dangerous)
docsctl publish --component irurueta-algebra --force
Idempotent create-or-update
Agents retry. A network blip, a timeout (exit 6) or a crashed session leads to the same command being run again, and the second run must not make things worse: no duplicate resource, no double charge, no half-applied change. There are two complementary tools.
-
Design the operation as "make it so", not "do it".
publishdeclares the desired state of the site for a component; running it again with the same content changes nothing and exits 0 (an idempotent no-op, which the exit-code table counts as success). Prefer create-or-update (upsert) to separatecreateandupdatecommands that fail in awkward ways on the second call. -
Accept an idempotency key for operations that cannot be naturally idempotent.
--idempotency-key KEYlets the caller label an attempt; the tool remembers the result for that key and returns it again instead of repeating the work. The key is generated once per intention by the caller and reused on every retry.
KEY=$(uuidgen)
docsctl publish --component irurueta-algebra --confirm-token pln_7c41e9 --idempotency-key "$KEY" --json
# ...timeout, exit 6: run the very same command again
docsctl publish --component irurueta-algebra --confirm-token pln_7c41e9 --idempotency-key "$KEY" --json
The second call either finds the first still running, waits for it, or returns its recorded result; it never publishes twice.
The same idea is the Idempotency-Key HTTP header, which is what a tool like this would send to its service.
Undo and rollback hints
The friendliest destructive command is one that says how to reverse it. After a real change, print (in the result, on stdout) the command that undoes it, so an agent or a person is not left reconstructing it under stress.
{"component":"irurueta-algebra","published":{"add":3,"change":1,"remove":0},"rollback":{"command":"docsctl publish --component irurueta-algebra --rollback pln_7c41e9 --json","note":"Restores the previous published version."},"schemaVersion":1}
-
Prefer reversible operations: publish to a new version and switch a pointer, move to a trash instead of deleting, keep the previous version for a period.
-
Say when something cannot be undone, in the plan itself (
"reversible": false), so the agent tells the user before the confirmation, not after. -
Log what was done, with the plan id, on stderr and, if you can, in an audit trail: the evidence a review needs afterwards.
The --rollback flag shown here is illustrative of the idea and not part of the base contract of this sub-section.
The flow
Reading the diagram: a bare publish never writes, it lands on the confirmation envelope (exit 5) and thus on the same plan
step as a dry run; only the confirm command applies changes; and every failure has a next step the exit code identifies.