AGENTLTL.yaml reference¶
This page comes from agentltl-claude-code/skills/rules/reference.md
It is kept with the code it documents. Edit it there.
settings: # all optional
mode: block # default mode for rules without one
retries: 3 # blocked attempts allowed by mode: retry before the user is asked
retry_counting: cumulative # or consecutive (an allowed call resets), hybrid (either)
report: 3 # how many broken rules of the same mode one refusal lists
finish_retries: 2 # times per turn `finally` rules send Claude back before it finishes
unparseable: # shell commands the guard cannot analyse (eval, `cmd &`, $CMD args...)
interactive: ask # ask | note | allow | deny, in normal permission modes
auto: note # in auto / bypass mode: note = let through, tell Claude it was unchecked
announce: true # list the rules to Claude at session start and after compaction
scope: session # default memory for rules: session | project (see Memory)
memory_first: true # refuse memory writes once, steering rules into this file
scan_output: true # after each call, warn when its output contains a credential
use: # packaged rules from the plugin's library (`agentltl library`)
- no-force-push
- {tests-before-push: {mode: warn}} # with another mode (or scope)
disable: [memory-first] # switch rules off by id: from use:, ~/.claude/AGENTLTL.yaml, or built in
rules:
- id: short-kebab-id # required, unique; the same id as a packaged rule replaces it
<kind>: ... # exactly one kind, see below
why: ... # shown to Claude when it is blocked; say the reason, not the rule
fix: ... # optional: what to do instead
mode: block # optional, see Modes
scope: session # optional, see Memory
from: {file: CLAUDE.md, line: 9, id: 674054a6cf} # optional: the memory statement it
# enforces (`agentltl memory scan` ids); `validate` shows it, and
# the scan stops proposing that statement
tools: # optional cli-to-tools specs for project commands (see below)
Targets¶
A target says which calls a rule is about.
| Form | Matches |
|---|---|
git_push |
any git push |
[Edit, Write] |
either tool |
"kubectl_*" |
any tool whose name matches the glob (every kubectl subcommand) |
{tool: git_push, with: {force: true}} |
exact argument values |
{tool: [Edit, Write], where: {file_path: "*.lock"}} |
glob on argument values |
{tool: [rm, cat], where: {"*": "**/.env"}} |
glob on ANY argument |
{tool: [Edit, Write, rm], where: {"*": "migrations/*"}, exists: true} |
only paths that already exist (so creating a new file is not caught) |
{tool: pytest, succeeded: true} |
only calls that ran and succeeded (exit status 0); false: only failed ones |
[{tool: Write, where: {...}}, {tool: rm, with: {...}}] |
any of several targets |
How matching works:
withcompares values for equality. A list argument matches if it contains the value.falsealso matches a flag that is absent.whereglobs:*also matches/.- Paths are tried as written, as absolute paths, relative to the project root, and by basename
when the pattern has no
/. - Several patterns in a list mean any of them.
- Several keys in
withorwheremust ALL match. - Files known only at run time (
ls | xargs rm,find . -exec rm {} +,rm $UNSET) count as a possible match for a path condition.neverrefuses them,requirecannot confirm them so it refuses too, and they never satisfy thefirstside of abefore.F=x; rm $Fand$HOMEare resolved, so they are judged normally. existsis checked on disk when the call is made. Earlier calls in the trace are judged against the disk as it is now.succeededreads how a call that ran ended: a Bash command that exits non-zero has failed. A call that has not run yet may or may not succeed, so putsucceededon the side of a rule that looks back (before'sfirst,finally), not on the call being made.
Use agentltl translate "<command>" to see names and arguments; agentltl validate warns
about a tool or argument name that nothing produces (such a rule never fires, or, for
require, fires on every call). Commands with no spec become a
tool named after the executable, with a single argv list (where: {argv: "--prod"}). Output
redirections (> file, >> file, 2> file, &> file, cat <<EOF > file) appear as
redirect_to; the ones that truncate the file (all but >>) also as overwrite_to. Input
redirections (< file) appear as redirect_from. patch and git apply list
the files their diff modifies as paths, and curl -O its output file as output.
Runners and package managers (make, npm, yarn, cargo, go, uv, poetry, conda,
apt, brew) have no spec on purpose: name them as {tool: npm, with: {argv: install}}.
CLIs with specs name their subcommands: kubectl_delete, terraform_apply, gh_pr_merge,
aws_s3_rm, git_apply; the in-place editors sed, perl and awk expose in_place and
paths.
Variables: the same value in two calls¶
A with value written $name is a variable. In a before rule it ties the two calls to the
same value, implicitly for every value. "Read a file before you overwrite it":
- id: read-before-overwrite
before:
first: {tool: Read, with: {file_path: $f}}
then:
- {tool: [Edit, Write], with: {file_path: $f}, exists: true} # new files are fine
- {tool: rm, with: {paths: $f}}
It reads as "for every f the call being made has, an earlier Read had file_path f".
It compiles to AgentLTL's ForAll, over the values in the call being checked.
- Path arguments are made absolute first, so
rm a.pyandRead /proj/a.pymatch. - Bind every variable on every target of both sides. Several variables must match together:
{chart: $chart, version: $v}needs an earlier call with that same chart and version. With list arguments, every combination of values needs an earlier call. - On the
firstside, give onlywithvalues. A list argument matches when it contains the value: aftercat a b,ahas been read. sincecannot be combined with a variable yet.
Rule kinds¶
| Kind | Meaning |
|---|---|
never: T |
No call matching T. with:/where: may sit at rule level. |
before: [A, B] |
A call matching B needs an earlier call matching A (in the rule's memory). |
before: {first: A, then: B, since: S} |
The A must come after the last call matching S. This is "run tests after your last edit". |
require: T |
When one of T's tools is called, its arguments must match T's with/where. |
at_most: {call: T, times: n} |
At most n calls matching T (in the rule's memory). |
finally: T |
Before Claude finishes its turn, a call matching T has run. Never refuses a call: at the end of the turn Claude is sent back to do it (at most finish_retries times per turn). |
finally: {call: T, since: S} |
... a call matching T has run after the last call matching S. "Run the tests before handing back work you edited." |
ltl: '<formula>' |
Raw AgentLTL. now("x"), called("x"), before("a","b"), G, X, U, !, &, \|, ->, and looking back: Y (previous call), O (once), H (always so far), S (since) |
formula: {type: ..., args: ...} |
Structured AgentLTL, e.g. {type: Before, args: {a: x, b: y}} |
How rules are evaluated:
- Every kind compiles to an AgentLTL formula.
never,before,requireandat_mostlook back from the call being made (G(matches(B) → Y O matches(A))), so AgentLTL judges them on that call alone: a rule broken earlier, for example by an override, never blocks unrelated later calls. ltlandformulause AgentLTL's own semantics over the whole trace of the rule's memory.now("x")means "the call at this step is x";called("x")means "x appears anywhere in the trace". UnderGandXyou almost always wantnow:G(now("deploy") -> X(G(!now("deploy"))))is "deploy at most once".- A formula is refused for a call only for what that call newly breaks. After a
warnoverride,G(now("deploy") -> X(G(!now("deploy"))))doesn't refusels, but still refuses the next deploy. - Two kinds of formula are rejected, because the guard judges each call as it is made:
- one that fails until some call happens, such as a bare
called("x"): every call before x would be refused. Say when it applies:G(now("deploy") -> called("pytest")). - one that can't fail before the session ends, such as
F(called("pytest"))orG(now("a") -> F(now("b"))): it would never refuse anything. Bound it withwithin_steps("a", "b", 3).
before("a", "b") is accepted: it fails, for good, at the first b with no a before it.
Memory¶
scope |
Remembers | Resets |
|---|---|---|
session (default) |
Calls made in this Claude Code session | With each new session; compaction and resume keep it |
project |
Every call made in this project, across sessions | Never on its own; agentltl reset --project |
Pick session for "since you started working" rules (tests before pushing, one migration per
task). Pick project for facts that stay true (a one-time setup step, a release cap). When the
user says "ever", "already", "once per project" or "in any session", that is project.
Modes¶
| Mode | What happens on a violation | Who can override |
|---|---|---|
block |
denied, every time | only the user (edit the rule, or run the command themselves) |
warn |
denied once, with the reason | Claude, by repeating the exact same call next |
retry |
denied; the retries-th try (default: the third) asks the user |
the user, after the retries |
ask |
the user gets a permission prompt with the reason | the user |
stop |
denied, and no tool runs until the user replies (Claude explains); a possible match (files known at run time) is only denied | the user |
log |
allowed; Claude is told it broke the rule | n/a |
When one call breaks several rules, the strongest mode decides (stop > block > ask > retry > warn > log).
Examples¶
rules:
# "Run the tests before pushing, and again if you changed code since"
- id: tests-before-push
before:
first: [pytest, {tool: make, with: {argv: test}}, {tool: npm, with: {argv: test}}]
then: git_push
since: [Edit, Write]
why: CI is slow and a red main blocks everyone.
fix: Run pytest after your last edit, then push.
# "Never force-push, unless you really have to"
- id: no-force-push
never: git_push
with: {force: true}
why: Force-pushing rewrites shared history.
mode: warn
# "Don't touch .env files"
- id: no-secrets
never: [Edit, Write, Read, cat, cp, mv, rm, sed, tee, echo, printf, head, tail, grep]
where: {"*": ["**/.env", "**/.env.*"]}
why: .env files hold credentials.
mode: stop
# "Only delete things inside build/"
- id: rm-only-in-build
require: {tool: rm, where: {paths: [build, "build/**"]}}
why: Everything else is source or data.
# "Ask me before installing packages"
- id: ask-before-install
never:
- pip_install
- {tool: [npm, yarn, apt_get], with: {argv: install}}
why: Dependencies need review.
mode: ask
# "Don't throw away work"
- id: no-hard-reset
never: [{tool: git_reset, with: {hard: true}}, {tool: git_clean, with: {force: true}}]
why: Uncommitted work is lost for good.
mode: warn
# "Don't push unless the tests passed"
- id: green-before-push
before: {first: {tool: pytest, succeeded: true}, then: git_push, since: [Edit, Write]}
why: A red build blocks everyone.
# "Before you hand back work you edited, run the tests"
- id: tests-before-finishing
finally: {call: pytest, since: [Edit, Write]}
why: Work handed back untested is work I have to test.
fix: Run pytest now.
# "At most one migration per task; if stuck, ask me"
- id: one-migration
at_most: {call: alembic_revision, times: 1}
mode: retry
# Raw LTL: rebase only after fetching
- id: fetch-before-rebase
ltl: 'before("git_fetch", "git_rebase")'
why: Rebasing onto a stale upstream causes conflicts later.
tools: # teach the translator a project command, so rules can name its arguments
alembic:
subcommands:
revision:
options:
- {flags: [-m, --message]}
- {flags: [--autogenerate], type: bool}
helm:
options:
- {flags: [-n, --namespace], global: true} # accepted before or after the subcommand
subcommands:
upgrade: {positionals: [{name: release}, {name: chart}]}
kubectl: # already bundled: this ADDS rollout's arguments, the rest of kubectl stays
subcommands:
rollout: {positionals: [{name: action}, {name: resource}]}
When a command needs a spec¶
The specs under tools: are used everywhere the rules are: on Claude's Bash calls, and by
agentltl translate, check and tools. Add or extend one when agentltl translate shows
that the argument a rule needs is not named:
- The command has no spec: the result is
{"argv": [...]}. The lint warns "has no spec". - The flag is unknown: it lands in
extra_args. - A value went to the wrong argument: for example, the namespace appears in
names.
How the spec format works:
- Extending a bundled spec: a spec for a command that is already bundled extends it.
Options are merged by flag, and subcommands one by one. Write extend: false to replace it
completely.
- Flags after the subcommand: mark them global: true when the real CLI accepts them
after the subcommand (kubectl, helm, gh, aws). git's -C is not global.
- Testing the spec: after adding one, re-run agentltl translate to check it. Keep it in
the same AGENTLTL.yaml as the rule. In ~/.claude/AGENTLTL.yaml, it applies in every
project.
Limits (tell the user when they matter)¶
- Unanalysable commands are not checked.
eval,cmd &,$CMD args, and shell functions fall undersettings.unparseable.if,while, and loops over runtime lists are checked with every command they might run, listed once. - No visibility into scripts or programs. The guard does not see inside
bash script.sh,make target,npm run x, orpython -c "...". It sees only the command. Name those commands in the rule too, for example{tool: make, with: {argv: test}}in thefirst:list. - Only calls that ran count as done. A call is recorded once it has run, so a denied call,
or one you refused, never counts toward
before. A command that ran and failed is recorded as failed: it counts unless the rule sayssucceeded: true. - Session memory starts empty in each new session. Use
scope: projectwhen the rule should remember earlier sessions. existsre-judges earlier calls against the disk as it is now (to do). Mention it when a rule depends on it. (cdwithin a command line is followed:cd sub && rm aissub/a.)