AI agents¶
footman's machine surface — one catalog call, taught refusals, a single-JSON stdout, structured results with task-returned data — is what a coding agent needs to drive a project safely. This page packages it: a paste-ready instructions snippet, and hooks that keep an agent's work formatted, linted, and gated.
An agent-readable index of this whole site lives at
llms.txt (and the full
text at
llms-full.txt).
The snippet¶
Put this in CLAUDE.md for Claude Code — the identical text works as
AGENTS.md for Codex, Cursor, Copilot, Zed, and most other agents (Gemini
CLI reads it as GEMINI.md). Two blanks to fill for your project: the
runner prefix and the gate task.
## Tasks (footman)
Tasks are typed Python functions in `tasks.py`, run with `uv run fm`.
The gate is `uv run fm check` — run it before calling any change done;
it must exit 0.
- Discover: `fm --list` (tasks + descriptions), or `fm --json --list`
for the full tree with parameter types, choices, and defaults.
- Inspect: `fm --help <task>` — typed usage, options, and an example.
`--help` anywhere on the line never executes anything.
- Validate a command line without running it: `fm --json --dry-run <chain>`.
- Run for machines: `fm --json <chain>` — stdout is exactly one JSON
envelope: {"schema": 1, "total_ms", "results": [{task, ok, code,
duration_ms, output, steps, error, returned}]}. A task's return value
lands in `returned`; refusals put a taught message in a top-level
`error`.
- Jump to a task's source: `fm --where <task>` prints file:line.
Grammar: globals (`--json`, `-k`, …) go **before** the first task; a
task's options come right after that task; several tasks on one line
form a chain, and independent tasks run in parallel (output never
interleaves). Everything after `--` passes through to the task's
`*args`.
Exit codes: 0 all ok · 1 a task raised · N a task exited N · 2 footman
refused the line (the stderr message states the fix) · 130 interrupted.
To add or change tasks, edit `tasks.py` — the signature is the CLI.
Never edit the completion cache under `~/.cache/footman/`; it's derived.
Hooks: Claude Code¶
Two recipes for .claude/settings.json. The mechanics in one sentence: a
hook's stderr plus exit code 2 is fed back to Claude as something to
fix; anything else is display-only — so route footman's output to stderr
and let the exit code do the talking.
Format and lint after every edit — the tree stays clean as the agent works, and lint failures land straight back in its context:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "uv run fm format lint 1>&2 || exit 2" }
]
}
]
}
}
The gate as the definition of done — a Stop hook that refuses to let
the session end red. stop_hook_active is the loop guard: when this stop
is already the retry, skip, so a stubborn failure can't ping-pong forever:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "jq -e '.stop_hook_active' >/dev/null && exit 0; uv run fm check 1>&2 || exit 2"
}
]
}
]
}
}
Two refinements when you need them: gate the PostToolUse command on Python
files (p=$(jq -r '.tool_input.file_path // empty'); case "$p" in *.py) …;;
esac) in repos where non-Python edits dominate, and swap check for a
lighter chain if the full gate is slow.
Hooks: Cursor¶
.cursor/hooks.json (project hooks run from the project root):
{
"version": 1,
"hooks": {
"afterFileEdit": [{ "command": ".cursor/hooks/fm-format.sh" }],
"stop": [{ "command": ".cursor/hooks/fm-gate.sh" }]
}
}
fm-format.sh is just uv run fm format lint — Cursor's afterFileEdit
is observational, so this keeps the tree formatted but can't push lint
output back into the loop. The feedback channel is the stop hook, which
may return a followup_message that auto-submits as the next prompt
(Cursor caps the loop at 5 by default) — and this is where --json earns
its keep:
#!/bin/sh
# .cursor/hooks/fm-gate.sh — block "done" on a red gate, with receipts.
out=$(uv run fm --json check) && exit 0
printf '%s' "$out" | jq '{followup_message:
("fm check failed — fix these, then finish:\n" +
([.results[] | select(.ok | not) | "\(.task): exit \(.code)\n\(.output)"] | join("\n")))}'
Cursor's hooks are beta
The event names and the followup_message shape above match
Cursor's hooks reference at the time
of writing, but the feature is marked beta and may move. If a hook
stops firing after a Cursor update, check that page first — the
footman side (fm --json check and its envelope) is the stable half.
Everyone else¶
The snippet is the portable layer — AGENTS.md reaches most agents. For
agents with no hook system (Copilot's coding agent runs in Actions, for
instance), the enforcement layer is the one you already have:
uv run fm check in CI plus branch protection, which catches
every agent and every human identically.