Skip to main content

Inside the Cursor afterFileEdit hook: what fires on save

The Cursor afterFileEdit hook fires on agent writes, not Tab autocomplete. Event payload, hooks.json shape, exit semantics, and how tailtest hooks it.

Pramod W. · February 11, 2026

The Cursor afterFileEdit hook is the integration point that makes deterministic testing work inside Cursor. It is also the hook with the most surprising semantics of the four agents tailtest supports, because Cursor is an IDE first and an agent runtime second. The event boundary has to discriminate between an agent write and an editor write, and the way it does so is worth understanding before you build anything on top of it.

I am Pramod. I own the Cursor plugin. I wrote the first version of the afterFileEdit handler in March 2026 against Cursor 0.46, then rewrote it twice as the hook surface stabilized. What follows is the contract as it stands at Cursor 0.48, with a note on the parts the official docs do not cover.

The invariant the hook has to preserve

Before the mechanics, the invariant: an afterFileEdit hook must fire exactly once per logical agent write, never on Tab autocomplete, never on human keystrokes, and never on background file refreshes from the language server. If the hook fires on anything else, the test runner downstream becomes noise, the user disables it within a day, and the entire testing discipline collapses.

Cursor enforces this by routing afterFileEdit through the agent’s tool call layer rather than the editor’s file system layer. The hook sees only writes that originated from an agent tool (edit_file, write, multi-file applies). It does not see Cmd-S, it does not see the formatter on save, and it does not see the language server’s refactor actions. This is the single most important property and the reason Cursor’s hook can be load-bearing for testing.

Where the config lives

Cursor honors a .cursor/hooks.json at the project root. The shape, as of 0.48:

{
  "version": 1,
  "hooks": {
    "sessionStart": [
      { "command": "python3 ./scripts/session_start.py" }
    ],
    "afterFileEdit": [
      { "command": "python3 ./scripts/after_file_edit.py" }
    ],
    "stop": [
      { "command": "python3 ./scripts/stop.py", "loop_limit": null }
    ]
  }
}

Three event names matter here: sessionStart, afterFileEdit, and stop. The middle one is the per-edit handler. The other two bracket the agent turn. The loop_limit field on stop controls how many times the hook may re-fire if the agent decides to continue after the hook surfaces context. null means unbounded, which is what tailtest needs because we want to be able to surface the same test result repeatedly until the agent acts on it.

There is no user-level config equivalent to ~/.claude/settings.json. Cursor’s hook surface is project-scoped only. Tailtest’s installer writes the file at the project root and never touches user state.

The event payload

When afterFileEdit fires, Cursor pipes a JSON payload to the hook command’s stdin. The shape:

{
  "event": "afterFileEdit",
  "file_path": "/abs/path/to/file.py",
  "workspace_roots": ["/abs/path/to/project"],
  "tool": "edit_file",
  "session_id": "01HMXN...",
  "timestamp": 1739212800123
}

Notice what is not in this payload. There is no diff. There is no old_string or new_string. There is no success flag. Cursor’s afterFileEdit assumes the write already succeeded, because the hook only fires on completed writes. If you need the diff, you have to compute it yourself against the file system or git index. Tailtest does not need the diff for the per-edit cycle, so we read only file_path and workspace_roots and dispatch from there.

There are no environment variables. Everything you need comes from stdin. This is different from Claude Code, which sets TOOL_FILE_PATH and friends. Cursor expects you to parse the payload.

What the hook actually does

In tailtest’s case the answer is: as little as possible, as fast as possible. The afterFileEdit handler is a per-edit accumulator. It does not run the test suite. It does not call the model. It writes one row to .cursor/hooks/state/tailtest.json and exits.

The real work happens in the stop hook at turn boundary. The rationale: agent turns in Cursor often contain bursts of related edits (a service, its model, a migration, the controller that wires them). Running the full test cycle per edit would multiply latency by the batch size. Running it once per turn over the union of edited files is the right granularity for IDE-speed feedback.

The afterFileEdit handler has a budget of under 100 milliseconds. We hit that by doing four things and nothing else:

  1. Parse the JSON payload.
  2. Run the file path through the ignore filter and language detector.
  3. Append the row to the pending-files list in session state.
  4. Save and exit.

If any step fails, we exit zero with no output. The cost of a noisy hook is high enough that we would rather miss an edit than create a false signal.

Exit semantics

Cursor reads the hook’s exit code and stdout. The contract:

  • 0 exit means the hook succeeded. Stdout is surfaced to the agent as a context note in the next turn.
  • Non-zero exit is logged but does not block the agent. afterFileEdit cannot veto a write because the write already happened.
  • Stdout above a few kilobytes gets truncated. The agent will not see anything past the cap.
  • Stderr is logged to Cursor’s hook log but not shown to the agent.

For per-edit hooks the right pattern is to exit zero with empty stdout. Surface nothing in the moment. Let the turn-end stop hook do the talking. The user does not need to see “tailtest acknowledged your edit” eight times in a row; they need to see “after this turn, two tests failed and one is a real bug.”

What we observed across 3,400 events

Across 3,400 afterFileEdit events in April on the tailtest repo and a handful of design partners, the latency distribution at the hook entry point was p50 38ms, p90 71ms, p99 142ms. Under the 100ms target at p90 and over it at p99. The over-budget tail is dominated by cold-start Python imports on first hook fire in a session. About 6 percent of afterFileEdit events were on files we filter out (lockfiles, generated code, vendored packages). The filter pays for itself by avoiding a runner dispatch on every dependency bump.

Non-obvious behaviors

A handful of things that bit us.

The hook fires before the file is fsynced in some workspace setups. If your hook reads the file immediately on entry, you can race the write. We defer any file read until the stop hook, by which time the write is durable. If you need to read in afterFileEdit, sleep a few milliseconds or check mtime.

workspace_roots can be empty. If the user has Cursor in single-file mode (no workspace open), workspace_roots is an empty list. Tailtest falls back to the directory of the file in that case. Without the fallback the hook silently no-ops, which is a confusing failure mode.

Loop limits matter for stop hooks. Cursor will re-invoke the stop hook if the agent decides to continue after the hook surfaces output. Without loop_limit: null you cap how many times the test cycle can re-fire within a turn. We want unbounded because the agent may legitimately need to act on the test result and trigger another verification round.

Tab autocomplete does not fire afterFileEdit. Verified empirically across 600 Tab acceptances on the tailtest repo. None of them fired the hook. This is by design and it is what makes the hook usable for testing. If Cursor ever changes this, the entire testing discipline has to be rebuilt.

Where afterFileEdit sits next to the other agent hooks

Tailtest supports four agents through the same R-series rule layer: Claude Code, Cursor, Codex CLI, and Cline. Each one exposes a different event for the same conceptual moment.

  • Claude Code: PostToolUse (see the Claude Code deep-dive).
  • Cursor: afterFileEdit (this post).
  • Codex CLI: PostToolUse plus Stop (see the Codex deep-dive).
  • Cline: .clinerules/ markdown plus an MCP server, no runtime hook.

The shared insight is in hook-based testing explained: the test cycle has to fire on the agent’s event boundary, not from the prompt. The four agents disagree on the event name and the config format, but they agree on the boundary.

That convergence is what lets tailtest ship the same R1-R15 rule layer across all four. The R12 three-label failure classification (real_bug, test_bug, environment) is the same code path on every agent. The 8 R15 adversarial categories (boundary inputs, format and injection, type confusion, concurrent state, time and locale edges, partial failures, resource exhaustion, off-by-one) run identically too. The per-agent code is only the hook entry point and the payload parser. Everything past dispatch_to_scanner() is shared.

That sharing is enforced by the test suite. We currently maintain 1,234 tests across the four plugins. A regression in the shared scanner is caught by tests in all four. A regression in the Cursor entry point is caught only by the Cursor suite.

Real bugs the per-edit cycle catches

Across 55 open-source Python repositories we have run tailtest against, the per-edit cycle has surfaced 17 real bugs that were not caught by the projects’ existing CI. The full set is on the case studies page. The pattern that recurs: an agent makes a small, plausible-looking edit, the human reviewer reads it and nods, CI does not exercise the new path, and the bug ships. afterFileEdit catches it because it runs the per-edit test cycle before the edit ever leaves the agent’s turn.

The R15 adversarial pass is what catches a meaningful share of these. The agent writes a function that handles the happy path correctly. R15 generates a boundary-input test (empty string, zero, negative number, off-by-one), the test fails, the agent sees the failure in the next turn, and the agent fixes it. Without R15 the bug ships. The agent’s training data does not push it to test boundaries hard enough.

How to wire your own afterFileEdit hook

The minimum viable shape, without tailtest, looks like this:

#!/usr/bin/env bash
# scripts/after_file_edit.sh
set -e
file_path="$(jq -r '.file_path' < /dev/stdin)"
case "$file_path" in
  *.py) pytest --testmon -q "$(dirname "$file_path")" ;;
  *.ts|*.tsx) jest --findRelatedTests "$file_path" ;;
  *) exit 0 ;;
esac

And the .cursor/hooks.json:

{
  "version": 1,
  "hooks": {
    "afterFileEdit": [
      { "command": "bash ./scripts/after_file_edit.sh" }
    ]
  }
}

This is a real starting point. It will run pytest after every agent edit to a Python file. If you only have one language and one runner, it is enough. What tailtest adds on top: the four-language runner dispatch, the per-edit accumulator that batches at turn boundary, R12 classification, R15 adversarial generation, the structured report at .tailtest/reports/latest.json, and the cross-agent abstraction so the same config carries to Claude Code, Codex CLI, and Cline.

Tailtest is MIT licensed, ships no telemetry, and does not require a SaaS account. The installer is uvx tailtest install --agent cursor. It writes the hook config to .cursor/hooks.json and a minimal .tailtest/config.yaml to the repo. The Cursor solution page walks through the full integration. The per-edit testing platform page covers the runtime.

FAQ

What is the Cursor afterFileEdit hook?

afterFileEdit is a Cursor hook event that fires after every agent-initiated file write. It does not fire on Tab autocomplete, human keystrokes, or background editor writes. The hook is configured in .cursor/hooks.json and receives a JSON payload on stdin.

Does afterFileEdit fire on Tab autocomplete?

No. Verified across 600 Tab acceptances in tailtest’s own logs. The hook fires only on agent tool calls that write files. This is what makes it usable as a testing trigger.

Can afterFileEdit veto an edit?

No. The hook fires after the write has already completed. There is no Cursor equivalent to Claude Code’s PreToolUse veto for agent writes. If you need to block a write, you have to do it inside the agent’s tool call layer, not in the hook.

What is the maximum runtime for the afterFileEdit hook?

Cursor will wait for the hook before continuing the agent turn, so the hook is effectively bounded by user patience rather than a hard cap. Tailtest targets under 100ms at the per-edit handler and defers the heavy work to the stop hook at turn boundary.

How does Cursor’s hook compare to Claude Code’s PostToolUse?

Both fire on agent file writes and both receive a JSON payload. The differences: Claude Code sets environment variables in addition to stdin, supports user-level and project-level config, and gives you the diff in the payload. Cursor is project-scoped only, stdin-only, and does not include the diff. The conceptual model is the same.