Skip to content

AI agents

An agent can drive a project with three commands: fm --json --list to learn the surface, fm --json --dry-run <chain> to validate a line, and fm --json <chain> to run it: refusals teach, stdout stays one JSON document, and results carry the task's own data. This page packages that: a paste-ready instructions snippet, and agent 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.
- Contracts: `fm --describe` gives the whole input+output API as one JSON
  document, return shapes rendered as JSON Schema; `fm --describe=<task>`
  for one task. Nothing runs.
- Inspect: `fm --help <task>` gives typed usage, options, and an example.
  `--help` anywhere on the line never executes anything.
- Validate a command line: `fm --json --dry-run <chain>`. A typo refuses with exit 64; a valid chain rehearses (bodies run, footman's recorded work is faked) and answers in the items envelope.
- Run for machines: `fm --json <chain>`. stdout is exactly one JSON
  envelope: {"schema": 1, "total_ms", "items": [...]}, one flat list
  where a task row carries {task, ok, code, duration_ms, output, error,
  returned} and each recorded command is its own row keyed "command".
  A task's return value lands in `returned`, its declared shape beside
  it as `returned_schema`; 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 · 64 footman
refused the line (the stderr message states the fix) · 130 interrupted ·
143 a signal stopped the run.

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

A hook is a pipeline whose producer and consumer happen to be an agent harness: JSON arrives on stdin, the verdict leaves as an exit code plus stderr. That is exactly the boundary Pipelines describes, so a hook is a small footman task: the payload binds to a dataclass, the loop guard is a field read, and no jq is involved. 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; and footman's own refusal (a typo'd flag, an unknown task) exits 64, which the harness shows to the human, so a wiring problem never impersonates a verdict about the project.

The tasks, in tasks.py, where hidden=True keeps machine-called adapters out of --list and --tree, while Tab still spells them for you when you run one by hand:

from dataclasses import dataclass, field
from typing import Annotated
from footman import RunFailed, fail, group, stdin, task

hooks = group("hooks", hidden=True, help="Agent lifecycle hooks")

@task
def format(): ...   # stand-ins for your own gate tasks

@task
def lint(): ...

@task
def check(): ...

@dataclass
class ToolInput:
    file_path: str = ""

@dataclass
class HookEvent:
    tool_input: ToolInput = field(default_factory=ToolInput)
    stop_hook_active: bool = False

@hooks.task
def post_edit(event: Annotated[HookEvent, stdin]) -> None:
    """Format and lint a Python file the agent just edited."""
    if not event.tool_input.file_path.endswith(".py"):
        return
    try:
        format()   # your own format/lint tasks, body-called
        lint()
    except RunFailed:
        fail("format/lint failed; fix it before continuing", code=2)

@hooks.task
def stop(event: Annotated[HookEvent, stdin]) -> None:
    """Refuse to let a session end on a red gate."""
    if event.stop_hook_active:
        return  # this stop already is the retry; never ping-pong
    try:
        check()
    except RunFailed:
        fail("the gate is red; fix it before stopping", code=2)

The wiring, in .claude/settings.json, where the redirection is what puts the receipts on the channel Claude reads:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "uv run fm hooks.post-edit 1>&2" }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "uv run fm hooks.stop 1>&2" }
        ]
      }
    ]
  }
}

Unknown payload fields are ignored by construction, so the hook survives the harness growing its schema. To debug one, keep a fixture and replay it: uv run fm hooks.stop < fixture.json exercises the real parse, and Runner.invoke("hooks.stop", stdin=payload) is the same replay in a test. (This repository wires its own hooks exactly this way.)

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" +
   ([.items[] | select(.task) | 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, since 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.