How-to: task-oriented recipes#

Recover a failed builtin edit#

A harness’s builtin Edit(path, old_string, new_string) just failed with a no-match error.

uv run tact heal PATH --old "OLD_STRING" --new "NEW_STRING"

Use the exact same old/new triple the failed builtin call used. heal is read-only. It returns either a corrected match (rung reached, actual current lines, a ready-to-apply preview) or a structured refusal (ambiguous with candidates, or genuinely no match). On success, either apply the corrected edit yourself or call tact edit/apply_edits with the same arguments directly. Those direct calls heal internally and write in one step.

Apply an LLM-emitted unified diff#

A model emitted a standard diff -u/git diff-shaped patch (not a builtin Edit call):

uv run tact apply-patch patch.diff        # write
uv run tact apply-patch patch.diff --check # dry run: parse + resolve, write nothing

Or pipe it in: cat patch.diff | uv run tact apply-patch. apply-patch also accepts this package’s own fenced old/new block convention. The convention uses a File: path line followed by fenced ```old/```new pairs. It is useful when a model was asked to show before/after rather than reconstruct a diff header.

Parsing is file-scoped. One file’s hunks either all parse cleanly or that file gets a structured error. Other files in the same input are unaffected.

Parsing never applies. The resulting edits still go through the same reconciliation ladder every other verb uses.

Multi-file rename via LSP#

Renaming a symbol requires knowing which occurrences are the same binding. It is not a textual find-replace. A textual find-replace risks renaming an unrelated local or a string that happens to match:

uv run tact rename PATH TARGET NEW_NAME

TARGET is a bare symbol name (resolved name-first) or a LINE:CHARACTER position. This is the one verb in the package that is not LSP-optional. It needs a live pyrefly server for the file’s project root, found by walking up for pyproject.toml/.git. If no server is available, it structurally refuses and never falls back to text search-and-replace. The computed WorkspaceEdit is converted into one apply_many transaction, so a rename across several files either lands everywhere or nowhere.

Anchor a block across sessions, then delta re-read#

Anchor a block of interest before a context compaction or session boundary:

uv run tact anchor PATH --block "def target_function():" --name my-anchor

Later, in a new session after compaction, re-locate it:

uv run tact resolve-anchor my-anchor

resolve_anchor short-circuits to the remembered position if the file’s sha256 hasn’t moved at all, else re-runs the reconciliation ladder biased toward the anchor’s own remembered line. To find out cheaply what changed in a file since a hash you already have (without re-sending the whole file):

uv run tact delta PATH KNOWN_SHA256

delta composes with the undo journal. If the journal still has the content behind KNOWN_SHA256, the result carries a real unified diff. If the baseline aged out of the journal’s ring or was never a tact-managed write, the result degrades honestly. The result includes the full current content plus a note that the baseline wasn’t recoverable. It never returns a silent empty diff.

Fleet-native routing#

tact hook is a JSON-over-stdin adapter for the five hook-bearing agent CLIs (Claude Code, Codex, Factory Droid, Gemini, Kimi). It reads only the tool name, working directory, and target path. It neither needs nor retains content.

The default posture is butler, not bouncer. In warn mode, the native edit proceeds and records the route without repeating standing advice in the agent context. The agent keeps a harness’s native rendering for the common case. It reaches for tact when an edit actually drifts, or via tact_heal after a builtin Edit fails. Switch to deny only when you want every eligible edit rerouted through tact unconditionally.

# default: native edit proceeds; the route is recorded without added context
cat native-hook-input.json | tact hook --harness claude            # --mode warn is the default
cat native-hook-input.json | tact hook --harness codex --mode warn
# stricter: refuse the builtin edit and point at tact
cat native-hook-input.json | tact hook --harness droid --mode deny
cat native-hook-input.json | tact hook --harness gemini --mode deny
# observe only, no context added (rollout dry-run)
cat native-hook-input.json | tact hook --harness claude --mode shadow

The eligibility boundary is deliberately narrow:

  • existing ordinary files route to tact;

  • new files and notebooks stay with harness-native tools;

  • unrecognized tools and malformed payloads fail open, as does any path tact cannot normalize;

  • the hook never reads file content.

For Claude Code, Codex, and Factory Droid, add this command to the native PreToolUse group for their edit tools. For Gemini, use BeforeTool and write_file|replace:

{
  "hooksConfig": {"enabled": true},
  "hooks": {
    "BeforeTool": [
      {
        "matcher": "write_file|replace",
        "hooks": [
          {"type": "command", "command": "tact hook --harness gemini --mode warn"}
        ]
      }
    ]
  }
}

A warn/shadow fleet rolls back to the bare native editor trivially. Drop the hook, and nothing changes about how edits land. A deny fleet depends on the agent actually following the redirect, so prefer warn unless you have a reason to force the issue.

Native-style rendering for tact tools#

A harness renders its own builtin Read/Edit with bespoke UI that no MCP mechanism can opt into. That rendering lives in the client, not the protocol. What the protocol does offer is the MCP Apps extension (io.modelcontextprotocol/ui), which tact-mcp wires up so tact tools render “something akin to native” in the hosts that support it.

GUI hosts (Claude.ai, Claude Desktop, Cursor)#

tact-mcp registers one inline widget (ui://tact/view.html) and points every tool at it. A GUI host that advertises the UI extension fetches the widget and renders it in a sandboxed iframe. The host pushes each tool result’s structuredContent into it.

The widget shows a lightly-syntax-highlighted file view for tact_read, a coloured unified-diff view for tact_check/tact_heal/tact_delta, and a key/value outcome/outline table. The model-facing text stays compact. The host owns whether and how it retains structuredContent. MCP does not guarantee that widget data costs zero context tokens. Terminal hosts (Claude Code, Codex CLI, Gemini CLI, Droid) do not fetch the widget.

Routine all-EXACT tact_edit, tact_check, and tact_apply_patch successes return a compact text receipt by default. Request detail="full" when a consumer needs the former JSON/data shape. Healed or ambiguity-resolved matches, refusals, drift, and new diagnostics always retain full evidence.

No configuration is needed beyond the MCP wiring above. The host negotiates the extension automatically at connect time.

Terminal cosmetic (optional, opt-in)#

Claude Code renders ANSI inside MCP tool output, but some other terminals strip it. On a compatible terminal, set TACT_MCP_ANSI=1 to prefix non-compact results with a single jade-coloured status word. This is a cosmetic touch, not a re-render:

TACT_MCP_ANSI=1 uv run --extra mcp --project /path/to/tact tact-mcp

It is off by default so it can never pollute a model’s context with literal escape codes in a host that strips ANSI. The honest ceiling in any terminal is that the result can get a coloured status word. The client renders the call (the grey parameter block), which has no server-side override. The butler-mode warn routing above is the practical mitigation. It lets the agent keep using the native, pretty-rendered Edit for the common case. It invokes tact, with its MCP rendering, only when an edit drifts or fails.

Gemini CLI versions before 0.26.0 do not execute runtime hooks. tact doctor reports the installed surfaces. The opt-in live suite makes the compatibility floor observable rather than silently treating a projected-but-dormant hook as healthy.

# ordinary suite: every live canary is skipped
uv run pytest

# all installed harnesses; real model calls, so explicit and billable
uv run pytest --run-live-harness -m live_harness -vv

# one harness, or an ephemeral known hook-capable Gemini build
TACT_LIVE_HARNESS=codex uv run pytest --run-live-harness -m live_harness -x -vv
TACT_LIVE_HARNESS=gemini \
TACT_GEMINI_COMMAND='bunx --bun @google/gemini-cli@0.26.0' \
uv run pytest --run-live-harness -m live_harness -x -vv

Diagnose a fleet install#

tact doctor
tact --json doctor

The doctor checks the Python and CLI surfaces, the optional MCP extra, the state directory, and telemetry readability. JSON output is stable enough for a fleet preflight. Human output keeps the authoritative path and failure detail visible.

Wire the MCP server into an agent harness#

The server is one stdio process: uv run --extra mcp --project /path/to/tact tact-mcp. --extra mcp is required. The server imports fastmcp, an optional dependency.

Claude Code#

Add a tact entry to your MCP config (e.g. .mcp.json):

{
  "mcpServers": {
    "tact": {
      "type": "stdio",
      "command": "uv",
      "args": ["run", "--extra", "mcp", "--project", "/path/to/tact", "tact-mcp"]
    }
  }
}

Factory Droid#

Merge the same server block into ~/.factory/mcp.json’s mcpServers object.

Codex CLI#

Add to ~/.codex/config.toml:

[mcp_servers."tact"]
command = "uv"
args = ["run", "--extra", "mcp", "--project", "/path/to/tact", "tact-mcp"]

Cursor#

Add to ~/.cursor/mcp.json’s mcpServers object (same shape as the Claude Code block above).

No env block is needed for any harness. tact-mcp reads no environment secrets.