jordandalton / laravel-tackle
An interactive, terminal-based AI coding assistant for Laravel — a Claude Code native to your Laravel app.
Requires
- php: ^8.3
- laravel/ai: >=0.1 <0.11
- laravel/framework: ^12.0 || ^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
This package is auto-updated.
Last update: 2026-08-16 21:04:37 UTC
README
An AI agent harness for Laravel.
📚 Full documentation: tackle.jordandalton.com
Tackle is the runtime layer that lets AI agents operate inside your Laravel application — reading code, executing tools, running tests, and taking action, with safety boundaries enforced at the framework level.
Think of it the way you think of Claude Code, Codex, or GitHub Copilot — but purpose-built for Laravel and installed directly into your app via Composer. The harness ships with three built-in agents and a full tool infrastructure you can extend or build on top of:
ai:code— an interactive coding agent that reads your codebase, edits files, runs tests, and formats code. Supports plan mode (approve a read-only plan before any edits), project-defined slash commands, and automatic context compaction for long sessions.ai:run— the same agent with no terminal attached: one task, a JSON result, and an exit code, for CI and cronai:fix— a focused fix session: paste an exception, point it at a Sentry issue (--sentry=ID) or GitHub issue (--issue=N), and the agent diagnoses, patches, and verifies the fix. Runs in worktree mode by default.ai:review— a read-only agent that reviews git diffs and surfaces real issues with severity levels. Point it at a GitHub pull request (--pr=42 --comment) and it posts the findings as inline PR review comments — drop it into apull_requestworkflow and every PR gets reviewed automatically.ai:respond— acts on a/tacklecomment left on a pull request: applies the requested change, pushes it to the PR branch, and replies in the thread. Wire it to a workflow and reviewers can type/tackle fix thisunder any finding.ai:explain— explains what a file, class, or method does in plain Englishai:test— generates a Pest test file for any class or methodai:upgrade— safe major version upgrades for Composer dependencies: audits what is upgradable, plans from the package's upgrade guide, resolves constraints, fixes the breaking changes, and verifies with your test suite — in an isolated worktree, delivered as a PR- Self-healer — an autonomous agent that listens for failed jobs and scheduled tasks, diagnoses the exception, patches the code, and opens a PR or applies the fix — without you lifting a finger
Every agent runs through the same tool infrastructure and safety layer. You can add your own tools, write new agents, and swap the default agent entirely — all without forking the package. And the terminal isn't the only way to drive it: Tackle Remote puts the same harness in your phone's browser, approval prompts and all.
Built on top of laravel/ai — and provider-agnostic
like it: Tackle runs on any provider laravel/ai supports with tool calling.
Anthropic (Claude) is the default; OpenAI, Gemini, Groq, and fully local models
via Ollama are two env vars away. See
Changing the model or provider.
Contents
- Before you start
- Requirements
- Installation
- Environment variables
- Usage
- Tips for better results
- Project instructions (TACKLE.md)
- Session memory
- Subagents
- Hooks
- Configuration
- Built-in tools
- Headless mode (CI, cron, scripts)
- Tackle Remote (drive it from your phone)
- MCP server
- Self-healing queue workers
- Fix an issue
- Code review
- Explain code
- Generate tests
- Upgrade a dependency
- Health check
- Replay a healing attempt
- Limitations
- Customization
- Safety
- Troubleshooting
- Known risks
- Development
How the harness works
Tackle has three layers:
| Layer | What it is |
|---|---|
| Tools | Action primitives agents can call — ReadFile, EditFile, RunTests, RunShell, etc. Every tool goes through PathGuard and shell policy before executing. |
| Agents | Classes implementing CodingAgent that receive a prompt, call tools, and return a result. Tackle ships three; you can add your own. |
| Safety | PathGuard blocks reads/writes outside the workspace and protected paths. BudgetTracker aborts the session when estimated spend exceeds the limit. Shell modes gate command execution. All enforced in PHP — not advisory. |
The self-healer adds a fourth piece: an event-driven runtime that spins up an agent autonomously in an isolated git worktree whenever a job or scheduled task fails. It is the same harness, running unattended.
Before you start
Run through this checklist once before your first session:
- Commit or stash any in-progress work — the agent will modify files, and a clean git state is your undo button.
- Set
ANTHROPIC_API_KEYin.env(or the key for your chosen provider). - Publish the
laravel/aiconfig:php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider" - Publish the Tackle config:
php artisan vendor:publish --tag="tackle-config"(publishes asconfig/tackle.php) - Run
php artisan ai:codeand type a small test task to confirm everything connects.
Requirements
- PHP ^8.3
- Laravel ^12.0
laravel/ai>=0.1 <0.11(see Known Risks)laravel/ai0.1.x itself requires PHP ^8.4; on PHP 8.3 Composer will resolve 0.2 or newer
Installation
composer require jordandalton/laravel-tackle php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider" php artisan vendor:publish --tag="tackle-config"
The service provider and ai:code command register automatically via Laravel
package auto-discovery.
API key setup
Tackle uses Anthropic (Claude) by default. Add your key to .env:
ANTHROPIC_API_KEY=sk-ant-...
The config/ai.php published above already includes the anthropic provider
block — just add the env var and you're ready.
Prefer another provider? Any provider in config/ai.php works, e.g. OpenAI:
AI_CODE_PROVIDER=openai AI_CODE_MODEL=gpt-4o OPENAI_API_KEY=sk-...
Budget rates resolve automatically from Tackle's built-in model catalog
(Anthropic, plus common OpenAI, Gemini, and Grok models — run /model in
ai:code to see them with their rates). For models the catalog doesn't know, pin rates
explicitly so budget enforcement stays meaningful — e.g. fully local via
Ollama, no API key, no cost:
AI_CODE_PROVIDER=ollama AI_CODE_MODEL=qwen3-coder AI_CODE_PRICE_INPUT=0 AI_CODE_PRICE_OUTPUT=0
You can also teach the catalog new models (or correct a stale built-in rate)
in config/tackle.php under pricing.models. Non-Anthropic built-in rates
are best-effort snapshots — verify against your provider's pricing page when
the budget matters.
Have a ChatGPT subscription? The tackle-codex add-on runs every Tackle agent on OpenAI's Codex models using your ChatGPT plan — no per-token billing, usage records as $0 against the session budget:
php artisan tackle:install codex # then: AI_CODE_PROVIDER=codex
Prefer Grok? The tackle-grok add-on runs the agents on xAI's Grok models — an xAI API key (the recommended path) or your grok.com plan:
php artisan tackle:install grok # then: AI_CODE_PROVIDER=grok
Switch models per session with ai:code --model=... [--provider=...] (also on
ai:run), or mid-session with /model.
Agent quality tracks the model: the coding and review agents lean heavily on tool calling, so weaker models produce weaker results. The plumbing is neutral; our recommendation is Claude.
Environment variables
All config options can be set via .env. Nothing requires editing a PHP file.
| Variable | Default | Description |
|---|---|---|
ANTHROPIC_API_KEY |
— | Your Anthropic API key (required for the default provider) |
AI_CODE_PROVIDER |
anthropic |
Provider name — must match a key in config/ai.php |
AI_CODE_MODEL |
claude-sonnet-4-6 |
Model to use |
AI_CODE_MAX_STEPS |
40 |
Tool-call ceiling for ai:run — cannot exceed the agent's own #[MaxSteps] |
AI_CODE_BUDGET |
1.00 |
Hard spend limit in USD per session |
AI_CODE_COMPACTION_THRESHOLD |
60000 |
Conversation size (chars) that triggers automatic history compaction |
AI_CODE_COMPACTION_KEEP |
4 |
Recent messages kept verbatim when compacting |
AI_CODE_PRICE_INPUT |
auto | Input price per million tokens for budget estimation. Unset = resolved from the built-in model catalog (falls back to 3.00 for unknown models) |
AI_CODE_PRICE_OUTPUT |
auto | Output price per million tokens for budget estimation. Unset = resolved from the built-in model catalog (falls back to 15.00 for unknown models) |
AI_CODE_SHELL |
approve |
Shell mode: off | allowlist | approve | yolo. Can be set per-environment in config/tackle.php — production defaults to off. |
AI_CODE_WORKTREE |
false |
Enable worktree isolation (production defaults to true). |
AI_CODE_MEMORY |
file |
Session persistence: file (resume on next run) | none |
AI_CODE_HEALING_ENABLED |
false |
Enable the self-healing queue worker feature |
AI_CODE_HEALING_MODE |
pr |
Healing mode: pr | patch |
AI_CODE_HEALING_QUEUE |
healer |
Queue name for the HealJobFailure job |
AI_CODE_HEALING_THRESHOLD |
1 |
Failures before healing is triggered |
AI_CODE_HEALING_BASE_BRANCH |
main |
Base branch for fix pull requests |
GITHUB_TOKEN |
— | GitHub token for opening pull requests (pr mode) |
Usage
php artisan ai:code
Type a task at the prompt. The agent maintains full conversation history within a session, so you can follow up, ask questions, and give corrections naturally.
Type exit or quit to end the session.
For CI, cron, or anything without a terminal, use
ai:run — the same agent, run once, with a
structured result and an exit code.
Shell mode flag
Pass --shell to override the configured shell mode for a single session without
touching your config or .env:
# Safe read-only exploration — no commands will run at all php artisan ai:code --shell=off php artisan ai:code --off # shorthand # Require your approval before every shell command (config default) php artisan ai:code --shell=approve php artisan ai:code --approve # shorthand # Only allow commands from shell_allowlist, no prompt php artisan ai:code --shell=allowlist php artisan ai:code --allowlist # shorthand # No restrictions, no prompts — CI or fully-trusted environments only php artisan ai:code --shell=yolo php artisan ai:code --yolo # shorthand
The flag is session-scoped and does not persist to config.
Worktree mode
Worktree mode runs the agent against an isolated git worktree rather than your live files. All edits land in a temp directory; nothing touches your working tree until you open a PR.
php artisan ai:code --worktree # force on for this session php artisan ai:code --no-worktree # force off for this session
When active, the intro line shows · worktree: on and a note box explains that
live files are untouched. After each turn, the git diff stat is labelled
"Worktree changes (live files untouched)" so it's clear no production code
has been modified.
Production environments default to worktree: on (see Configuration).
Worktrees are cleaned up automatically when the session ends. Use tackle:prune
to remove any that were left behind by interrupted sessions.
Plan mode
Have the agent think before it touches anything. In plan mode a read-only planning agent investigates the codebase and streams a numbered implementation plan — files, changes, risks. Nothing is edited until you approve it.
php artisan ai:code --plan # every task plans first
Or plan a single task from inside the REPL:
> /plan add soft deletes to the Invoice model
After the plan streams you choose: Execute (the coding agent follows the approved plan), Revise (describe what to change; the planner tries again), or Cancel. One approval replaces a session of per-edit vigilance.
Slash commands
The ai:code prompt understands commands. Type / to autocomplete them:
| Command | What it does |
|---|---|
/plan <task> |
Plan first, edit only after your approval |
/model [name] |
Switch the model mid-session — no name shows a picker listing known models with their per-MTok rates. /model <provider> <model> switches provider too. Budget rates update automatically for known models. |
/compact |
Summarize older session history to free context |
/clear |
Forget the session history entirely |
/sessions |
List saved sessions and how to resume them |
/help |
List all commands, including your project's own |
/<name> [args] |
Run a custom command from .tackle/commands |
Custom commands (.tackle/commands)
Reusable prompts your whole team shares, checked into the repo. Drop a markdown
file in .tackle/commands/ and its name becomes a command:
<!-- .tackle/commands/deploy-check.md -->
Review everything changed since the last tag for deploy risk: migrations that
lock tables, config that needs new env vars, breaking API changes. Focus on: $ARGUMENTS
> /deploy-check the billing module
$ARGUMENTS is replaced with whatever follows the command name (or the
arguments are appended when the template has no placeholder). Custom commands
work headlessly too:
php artisan ai:run "/deploy-check the billing module"
Context compaction
Long sessions re-send their whole history every turn — slower, costlier, and
eventually over the context limit. When the conversation exceeds
AI_CODE_COMPACTION_THRESHOLD (default 60,000 characters), Tackle summarizes
the older exchanges and keeps the last AI_CODE_COMPACTION_KEEP messages
verbatim, automatically. Force it any time with /compact, or start fresh with
/clear.
Images
Drag an image into the terminal (which pastes its path), or type the path, or
@-mention one in the workspace — Tackle detects it, attaches the image to the
prompt, and the model sees the actual pixels:
> the header is misaligned, see /Users/me/Desktop/Screen\ Shot.png — fix the CSS
> build this component @docs/mock.webp
PNG, JPEG, GIF, and WebP are recognized; quoted paths and escaped spaces from drag-and-drop both work. Requires a vision-capable model (the default Claude models are; some local models are not).
Interactive UX
ai:code uses Laravel Prompts throughout for a fully interactive terminal experience:
suggest()— the task prompt shows your previous tasks as autocomplete suggestions. Use ↑↓ to browse history.stream()— AI text responses stream to the terminal in real time, token by token.title()— the terminal tab title updates dynamically as the agent works: "Tackle — Thinking…", "Tackle — Reading files", "Tackle — Running tests", "Tackle — Ready".select()/multiselect()— when the agent callsAskUser, you're presented with a styled selection list rather than a raw text prompt.confirm()— when the agent callsConfirmActionbefore a destructive operation, you see a styled yes/no prompt.note()— after each turn agit diff --statis shown as a note block so you can see what changed.warning()— a styled warning appears when you approach 80% of your session budget.error()— styled errors on agent failures or budget overruns.intro()/outro()— session start and end use styled banners showing the model, budget, and shell mode.
Example session
┌──────────────────────────────────────────────────────────────┐
│ Laravel Tackle · claude-sonnet-4-6 · $1.00 · approve │
└──────────────────────────────────────────────────────────────┘
┌ What should I work on? ─────────────────────────────────────┐
│ Add a slug field to the Post model │
└─────────────────────────────────────────────────────────────┘
🔍 searching for Post model
📖 reading app/Models/Post.php
📝 creating database/migrations/2024_01_01_add_slug_to_posts.php
✓ File saved
✏️ editing app/Models/Post.php
✓ File saved
🧪 running tests
✓ Done
Migration created, `$fillable` updated, and all tests pass.
╭─────────────────────────────────────────────────────────────╮
│ app/Models/Post.php | 2 +- │
│ 1 migration file | 15 +++++++++++++++ │
╰─────────────────────────────────────────────────────────────╯
┌ What should I work on? ─────────────────────────────────────┐
│ Make the slug auto-generate from the title on creation ▲ │
│ Add a slug field to the Post model ▼ │
└─────────────────────────────────────────────────────────────┘
Tips for better results
Be specific about what you want. Vague tasks produce vague results. The more context you give upfront, the less back-and-forth is needed.
| Instead of… | Try… |
|---|---|
| "Add a feature" | "Add a published_at timestamp to Post with a scope for published posts and a migration" |
| "Fix the bug" | "The UserController@store is returning a 500 when email is null — find out why and fix it" |
| "Refactor this" | "The OrderService class is doing too much — extract the payment logic into a PaymentService" |
Use --off for questions and exploration.
If you just want to understand the codebase without making changes, --off mode
prevents the agent from running any commands, so you can ask freely.
php artisan ai:code --off # "How is authentication handled in this app?" # "What does the Job queue setup look like?"
Point it at the right place.
If you know which file or module is relevant, say so. "Look at
app/Services/BillingService.php" is faster than letting it search from scratch.
Correct it mid-session. If the agent does something wrong, just say so in the next prompt. It reads your correction in context and adjusts. You don't need to restart the session.
Keep tasks focused. One clear task per session works better than a long list. Once a task is done, review the diff, commit it, then start a new session for the next task.
Review before you move on.
After each task the agent shows a git diff --stat. Look at it before typing
your next task. If something looks wrong, say so or discard with
git checkout -- ..
Project instructions (TACKLE.md)
Every Tackle agent — ai:code, ai:fix, ai:review, ai:explain, ai:test,
ai:upgrade, and the self-healer — loads a TACKLE.md file from your project root at the
start of each session and follows it. It's the place to record project
conventions, boundaries, and gotchas once, instead of repeating them in every
prompt.
Generate a starter file:
php artisan tackle:init
This scans your project (composer.json, test framework, Pint/Larastan presence,
app/ structure) and writes a scaffold with ## Conventions, ## Boundaries,
and ## Gotchas sections for you to fill in. Use --force to overwrite an
existing file.
Example content:
## Conventions - All money values are integer cents — never floats. - New endpoints validate through Form Requests, never inline `validate()`. ## Boundaries - Never modify files under `app/Legacy/` — scheduled for deletion. - Do not add new composer dependencies without asking first. ## Gotchas - `User::active()` excludes soft-deleted AND suspended users.
Notes:
- If no
TACKLE.mdexists, Tackle falls back toAGENTS.md, thenCLAUDE.md— so instructions you already maintain for other AI tools work out of the box. - Content is capped at 20,000 characters to protect your context window and session budget; anything beyond that is truncated.
- Project instructions never override Tackle's safety layer — protected paths, shell modes, and allowlists are enforced in PHP regardless of what the file says.
Session memory
The memory config controls what happens to conversation history when you exit.
| Mode | Behaviour |
|---|---|
file |
(Default) The transcript is saved to storage/ai-code/ after every turn. Re-running ai:code resumes where you left off. |
none |
History is lost when the session ends. Every php artisan ai:code starts fresh. |
With file mode you'll see this on your next run:
Resumed session 'default' — 12 messages of history. Type /clear to start fresh.
Named sessions
--session keeps separate histories for separate streams of work:
php artisan ai:code --session=billing-refactor php artisan ai:code --session=bugfixes
ai:run joins a persisted session only when --session is passed — anonymous
one-shot runs never pollute your interactive history. That means a cron'd
ai:run --session=nightly accumulates context across nights.
Notes:
- Transcripts are JSON under
storage/ai-code/— delete a file (or/clearin the REPL) to forget a session; gitignore the directory to keep history out of your repository. - Context compaction applies to resumed sessions too: a long transcript is summarized before it eats your context window.
- Image attachments are not persisted — re-attach an image if a later session needs it.
- Text is persisted, not tool output: a resumed session remembers what was said and done, and the agent re-reads files as needed.
Subagents
The main coding agent can delegate self-contained work to subagents — separate agents that run the task in their own fresh context with their own (usually narrower) toolset, and hand back only their final report. Exploration happens in the child; conclusions come back. Long sessions stay coherent because reading twenty files to answer "how does billing work?" no longer costs the main conversation twenty files of context.
Two subagents ship enabled:
| Name | What it does |
|---|---|
explorer |
Read-only codebase exploration — locates files, traces how a feature works across classes, reports back with precise file references. |
test-writer |
Writes a Pest test file for a class or behaviour and runs it. |
The agent decides when to delegate (its instructions steer it toward broad
research and away from small lookups), calling the Delegate tool with a
subagent name and a complete brief. You'll see the call in the session like
any other tool call.
Guarantees
- Shared budget. Subagent token usage records into the same
BudgetTrackeras the parent session — delegation cannot exceed your spend limit, and a subagent is stopped mid-task if it exhausts it. - Same safety layer. Subagent tools go through
PathGuard, allowlists, hooks, andToolCalling/ToolCalledevents — exactly like the parent's. - One level deep. A subagent cannot delegate further.
- No user prompts. Subagents never ask questions; they make judgment calls and note them in the report.
- Fail-soft. A subagent that crashes returns an error message to the parent agent, which continues the session.
Adding your own
Register any Tackle\Contracts\CodingAgent implementation in
config/tackle.php — including agents you've written (see
Swapping the agent entirely):
'subagents' => [ 'explorer' => [ 'agent' => \Tackle\Agents\ExplorerAgent::class, 'description' => 'Read-only codebase exploration...', ], 'schema-expert' => [ 'agent' => \App\Ai\SchemaExpertAgent::class, 'description' => 'Answers questions about the database schema, migrations, and model relationships.', ], ],
The description is what the delegating model reads when deciding where to
send work — write it like a tool description. Set subagents to an empty
array to remove the Delegate tool entirely.
Hooks
Hooks are deterministic commands that run around agent activity — enforced in PHP and shell, not by the model. Use them to audit every tool call, block specific commands with your own policy, rewrite tool arguments before they run, or trigger follow-up work (formatting, notifications) after edits.
Declare them in config/tackle.php:
'hooks' => [ 'pre_tool' => [ // Shell hook: guard every RunShell call with your own script. ['match' => 'RunShell', 'run' => 'scripts/tackle/guard-shell.sh'], // Class hook: audit every tool call. ['match' => '*', 'using' => \App\Hooks\AuditToolCalls::class], ], 'post_tool' => [ // Format after every file edit. ['match' => ['EditFile', 'WriteFile'], 'run' => 'vendor/bin/pint --dirty'], ], 'session_start' => [], 'session_end' => [], ],
Four events fire:
| Event | When | Can block? | Can rewrite arguments? |
|---|---|---|---|
pre_tool |
Before a tool executes | Yes | Yes |
post_tool |
After a tool executes | No | No |
session_start |
An agent session begins | No | No |
session_end |
An agent session ends | No | No |
Each hook takes either run (a shell command) or using (a class name), plus
optional match (a tool-name glob or array of globs — 'Run*',
['EditFile', 'WriteFile']; default '*') and timeout (seconds, default 10).
Hooks run in declaration order; the first block wins, and argument rewrites
chain into the next hook.
Shell hooks speak a stable JSON protocol, so they can be written in any language:
- The event payload arrives on stdin:
{"event":"pre_tool","tool":"RunShell","arguments":{"command":"ls"}}(post_tooladdsresultandduration_ms). - Exit 0 allows the call. For
pre_tool, stdout may contain{"arguments": {...}}to rewrite the tool's arguments. - Exit 2 blocks the call — stderr becomes the refusal message the agent
sees, so make it instructive:
echo "Use RunTests instead" 1>&2; exit 2. - Any other exit code, a timeout, or a crash is logged and ignored — a broken hook never bricks a session.
Class hooks implement Tackle\Contracts\ToolHook (or are plain
invokables). Return null to allow, false to block, a string to block with
that message, or an array (pre_tool only) to replace the arguments:
namespace App\Hooks; use Tackle\Contracts\ToolHook; class AuditToolCalls implements ToolHook { public function handle(array $payload): null|false|string|array { logger()->info("Tackle called {$payload['tool']}", $payload['arguments']); return null; // observe only } }
Notes:
- Hooks apply everywhere tools run through the agent harness —
ai:code,ai:run,ai:fix, and the self-healer. They do not apply to tools served overtackle:mcp(the connected MCP client is the policy layer there). - Hooks complement the existing Laravel events:
ToolCallinglisteners can also veto calls in pure PHP. Reach for hooks when you want config-declared, ordered policy or non-PHP tooling; reach for listeners when you're already living in the event system. - Hooks are policy on top of the safety layer, not a replacement for it —
PathGuard, the artisan allowlist, and shell modes still apply first.
Configuration
After publishing the config, edit config/tackle.php. All values can be set
via environment variables — see the Environment variables
table above.
return [ // laravel/ai provider name — must match a key in config/ai.php 'provider' => env('AI_CODE_PROVIDER', 'anthropic'), // Model to use 'model' => env('AI_CODE_MODEL', 'claude-sonnet-4-6'), // Tool-call ceiling for ai:run — a cap, not a grant; it cannot raise // the agent's own #[MaxSteps] attribute 'max_steps' => env('AI_CODE_MAX_STEPS', 40), // Hard spend limit for the session in USD — aborts when exceeded 'budget_usd' => env('AI_CODE_BUDGET', 1.00), // Shell execution policy — string or per-environment array. // String form (backward-compatible): applies to all environments. // Array form: keyed by environment name; production defaults to 'off'. 'shell' => [ 'local' => env('AI_CODE_SHELL', 'approve'), 'staging' => env('AI_CODE_SHELL', 'approve'), 'production' => env('AI_CODE_SHELL', 'off'), ], 'shell_allowlist' => ['composer', 'npm', 'php artisan'], // Artisan commands the agent may run without confirmation — per environment. // Flat array form is still accepted for backward compatibility. 'artisan_allowlist' => [ 'local' => ['make:*', 'migrate:*', 'db:seed', 'route:list', 'test'], 'staging' => ['migrate', 'route:list'], 'production' => ['route:list'], ], // Artisan commands that require an interactive confirmation before running. 'artisan_destructive' => [ 'local' => ['migrate:fresh', 'migrate:reset', 'migrate:refresh', 'db:wipe'], 'staging' => [], 'production' => [], ], // Worktree isolation — edits go to a temp worktree instead of live files. // Production defaults to true; other environments default to false. 'worktree' => [ 'local' => env('AI_CODE_WORKTREE', false), 'staging' => env('AI_CODE_WORKTREE', false), 'production' => env('AI_CODE_WORKTREE', true), ], // Glob patterns (relative to workspace) the agent can never read or write 'protected_paths' => ['.env', '.env.*', 'storage/*', 'vendor/*', '.git/*'], // Root directory for the agent — null defaults to base_path() 'workspace' => null, // Session memory: file (default, resumes across runs) | none 'memory' => env('AI_CODE_MEMORY', 'file'), ];
Shell modes
| Mode | Behaviour |
|---|---|
off |
RunShell refuses everything. Use RunArtisan / RunTests instead. |
allowlist |
Only commands whose first token matches shell_allowlist run unattended. |
approve |
Default. Every command shows a confirmation prompt before running. Choosing "always allow this exact command" saves it to .tackle/permissions.json, and it runs without asking from then on. |
yolo |
Runs anything, no prompt. Dangerous — CI or fully-trusted environments only. |
Shell mode can be set as a plain string (applies to all environments) or as a
per-environment array (shown above). The production key defaults to off.
Artisan allowlist and destructive list
artisan_allowlist controls which commands the agent may run freely. artisan_destructive
lists commands that require an interactive terminal confirmation before running. Commands
in neither list are refused outright. Both support glob patterns (make:* covers
make:model, make:controller, etc.) and can be a flat array (all environments) or a
per-environment keyed array.
RunTests also respects the allowlist — if test is not in the allowlist for the
current environment, the tool is refused.
Protected paths
The protected_paths globs prevent the agent from reading or writing sensitive
files regardless of what it is asked to do. This is enforced in PHP, not via
prompting. Add your own patterns here if your project has additional secrets.
Built-in tools
These tools are available to the agent in every session.
Filesystem
| Tool | What it does |
|---|---|
ReadFile |
Reads a file's contents. Always runs through PathGuard first. |
Glob |
Lists files matching a pattern. Protected paths are excluded from results. |
SearchCode |
Grep-style search returning file + line + snippet. Capped at 50 results. |
EditFile |
str_replace edit — old_str must appear exactly once or the edit is refused. |
WriteFile |
Creates a new file. Refuses if the path already exists. |
Execution
| Tool | What it does |
|---|---|
RunArtisan |
Runs php artisan <command> in a subprocess. Allowlist-gated. |
RunTests |
Runs Pest or php artisan test in a subprocess. Returns full output. |
RunPint |
Runs Laravel Pint to format files. Called before finishing a task. |
RunLarastan |
Runs PHPStan / Larastan static analysis and returns the findings. Accepts an optional path and level override. No-ops gracefully if vendor/bin/phpstan is not present. |
RunShell |
General shell — governed by the shell config mode. |
RunComposer |
Composer with the dangerous parts fenced off — a fixed set of read-only and mutating subcommands, mutations always --no-scripts, scripts re-enabled only by a human at the terminal. Used by ai:upgrade. |
ReadPackageDocs |
Reads an installed package's upgrade guide, changelog, or composer.json from vendor/ — a docs-only carve-out of the vendor/* protected path; package code stays unreadable. Used by ai:upgrade. |
Observability
| Tool | What it does |
|---|---|
ReadLog |
Returns the last N lines of storage/logs/laravel.log. Accepts an optional filter string. |
QueryDatabase |
Runs a read-only SELECT query and returns results as JSON. Capped at 100 rows. |
ListRoutes |
Returns a formatted table of all registered routes with method, URI, name, and action. |
GitDiff |
Shows a git diff — supports staged, a specific commit, a branch range, or a path. |
ReadTelescopeEntry |
Reads Telescope exception entries. Pass a job UUID for a specific lookup, or omit to return recent exceptions. No-ops gracefully if Telescope is not installed. |
ReadSentryIssue |
Fetches a Sentry issue by ID — exception, stacktrace, breadcrumbs, and request context. Omit the ID to list recent unresolved issues for the configured project. No-ops gracefully if SENTRY_AUTH_TOKEN / SENTRY_ORG are not set. |
ReadGitHubIssue |
Fetches a GitHub issue by number — title, body, labels, and all comments. Omit the number to list recent open issues. No-ops gracefully if GITHUB_TOKEN / GITHUB_REPO are not set. |
ReadPullRequest |
Fetches a GitHub pull request by number — title, body, branch name (head ref), base branch, state, author, and comments. Use this (not ReadGitHubIssue) when the user references a PR number, especially when the branch name is needed for CommitAndPush. |
CreateGitHubIssue |
Opens a new GitHub issue with a title and body. |
CreatePullRequest |
Creates a branch, commits all worktree changes, pushes to origin, and opens a GitHub pull request. |
CommitAndPush |
Stages all changes, fetches the remote branch tip, rebases onto it, commits, and pushes via HEAD:<branch> — without checking out the branch. Use this to add follow-up commits to an existing PR from a worktree session. Always pass the branch parameter (get it from ReadPullRequest). |
AskUser |
Presents the user with a select() or multiselect() prompt and returns their choice. The agent calls this when there are multiple valid paths and it wants the user to decide. |
ConfirmAction |
Presents the user with a confirm() prompt before a destructive or irreversible operation. Returns "confirmed" or "cancelled". |
Delegate |
Runs a self-contained task in a subagent — a separate agent with its own fresh context and narrower toolset — and returns only its final report. Present only when tackle.subagents is non-empty. |
All file reads happen in-process. Everything that executes code runs as a subprocess, so a broken generated file cannot crash the agent session.
Headless mode (CI, cron, scripts)
ai:code is a REPL and needs a terminal. ai:run is the same agent, the same
tools, and the same safety layer — run once, to completion, with nothing
attached to stdin.
php artisan ai:run "Add a scopeActive to the Subscription model and a test for it"
It streams a plain-text log while it works and prints a summary at the end.
Machine-readable output
--output=json prints one JSON document on stdout. Diagnostics go to stderr, so
you can pipe stdout straight into jq without filtering.
php artisan ai:run "Fix the failing SubscriptionTest" --output=json | jq -r '.pr_url'
{
"ok": true,
"outcome": "completed",
"text": "Added the scope and a test covering both branches.",
"steps": 12,
"files_changed": ["app/Models/Subscription.php", "tests/Feature/SubscriptionTest.php"],
"diff_stat": "2 files changed, 24 insertions(+)",
"interactions_denied": 0,
"usage": { "input_tokens": 41233, "output_tokens": 2210, "estimated_cost_usd": 0.1563 },
"budget_usd": 1.0,
"worktree": "/tmp/tackle-worktree-9f2ab1c4",
"pr_url": "https://github.com/acme/app/pull/218",
"events": [
{ "type": "tool_call", "tool": "EditFile", "args": { "path": "app/Models/Subscription.php" } }
]
}
Exit codes
| Code | Meaning |
|---|---|
0 |
Completed |
1 |
Agent or provider error, or an invalid option |
2 |
Stopped — the spend limit was reached |
3 |
A confirmation was auto-denied (only with --fail-on-denied) |
4 |
Hit the step ceiling without finishing |
Confirmations without a user
Five tools ask before they act: AskUser, ConfirmAction, RunArtisan (for
destructive commands), RunShell (under shell=approve), and CommitAndPush.
With no terminal there is nobody to answer, so every confirmation is denied by
default — nothing that would have needed a human "yes" happens without one.
AskUser is the exception, because it is a choice rather than a confirmation:
the agent is told to pick the option it judges best, say which it chose and why,
and carry on. A denied choice would just stall the run.
Pass --yes to approve automatically instead. Only do that where you would have
clicked through the prompts yourself — it green-lights destructive Artisan
commands and pushes.
--fail-on-denied turns any auto-denial into exit code 3, for pipelines that
would rather fail loudly than get a partial result. Either way, the count is in
the JSON as interactions_denied.
Shell in unattended runs
shell=approve — the config default for local and staging — has no meaning
with no one to approve. Rather than silently promote it to yolo, ai:run
refuses those commands and says why. For a run that genuinely needs a shell,
choose the policy deliberately:
php artisan ai:run "..." --allowlist # only shell_allowlist commands php artisan ai:run "..." --yolo # unrestricted — trusted environments only
Bounding a run
--budget and --max-steps override budget_usd and max_steps for one run.
Both are hard stops: the run aborts and reports the outcome rather than
continuing past the limit.
php artisan ai:run "..." --budget=0.50 --max-steps=25
max_stepsis a ceiling, not a grant. Each agent also declares its own#[MaxSteps]attribute, whichlaravel/aireads by reflection and which cannot be raised at runtime — settingmax_stepsabove it has no effect.
In GitHub Actions
Worktree mode is worth forcing here: the agent edits an isolated copy and the live checkout is never touched.
name: Tackle on: workflow_dispatch: inputs: task: description: What should Tackle do? required: true jobs: run: runs-on: ubuntu-latest permissions: contents: write pull-requests: write steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: '8.3' - run: composer install --no-interaction --prefer-dist - name: Run Tackle env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPO: ${{ github.repository }} run: | php artisan ai:run "${{ inputs.task }}" \ --output=json \ --worktree \ --allowlist \ --budget=2.00 \ --max-steps=60 > result.json - run: jq -r '.text' result.json >> $GITHUB_STEP_SUMMARY if: always()
The job fails on any non-zero exit, so a run that blows the budget or hits the step ceiling fails the workflow rather than reporting success with half the work done.
Tackle Remote (drive it from your phone)
The terminal isn't the only way in. The companion package laravel-tackle-remote serves a mobile-first browser UI for the same harness:
php artisan tackle:install remote # composer-requires the package (--no-dev to add to require)
php artisan tackle:remote --host=0.0.0.0
Scan the QR code printed in your terminal and your phone is driving the agent
— send tasks (including photos as context), watch it work tool-by-tool, and
answer approval prompts from a bottom sheet: Deny / Allow once / Always
allow. Slash commands and @-file mentions work like they do in the
terminal, "always allow" writes to the same permission store, and every
safety guarantee in this README still applies — it's the same agents, tools,
and enforcement underneath, with a web page where the terminal used to be.
Pairing links are single-use, sessions are signed with a per-run secret, and the recommended remote path is a Tailscale tailnet rather than an exposed port — see the security model for details.
MCP server
Tackle's tools aren't only for Tackle's agents. tackle:mcp serves them over
the Model Context Protocol (stdio), so any
MCP client — Claude Code, Cursor, Zed — can use Laravel-aware tools like
ListRoutes, QueryDatabase, ReadTelescopeEntry, and RunLarastan against
your app, with Tackle's safety layer still enforced in PHP: protected paths,
the artisan allowlist, and SELECT-only database queries all apply exactly as
they do for Tackle's own agents.
Register it with Claude Code from your app directory:
claude mcp add tackle -- php artisan tackle:mcp
Or add it to .mcp.json manually:
{
"mcpServers": {
"tackle": {
"command": "php",
"args": ["artisan", "tackle:mcp"]
}
}
}
The exposed tool set is controlled by config('tackle.mcp.tools') and
defaults to read/inspect and analysis tools only — no file writes, no shell.
Add write tools (EditFile, WriteFile, RunPint, …) to the list if you
trust the connected client. Interactive tools (AskUser, ConfirmAction)
are refused outright: an MCP client has no terminal to answer their prompts.
Avoid tools that ask for terminal confirmation, such as CommitAndPush —
they would hang the stdio session.
GitHub Issues integration
When GITHUB_TOKEN and GITHUB_REPO are set, the ReadGitHubIssue tool becomes active. The agent can fetch any issue by number — title, description, labels, and all comments — giving it full context before starting work.
Configuration
Add these to your .env:
GITHUB_TOKEN=ghp_... # personal access token with repo scope GITHUB_REPO=owner/repo # e.g. acme/my-app
The GITHUB_TOKEN is shared with the self-healer (PR mode), so no extra setup is needed if healing is already configured. Generate a token at GitHub → Settings → Developer settings → Personal access tokens with repo scope (or a fine-grained token with Issues: read permission).
How it works
Ask the agent naturally:
> implement issue #42
> what are the open GitHub issues?
When given an issue number, the tool fetches the issue body plus all comments and returns them as a single block of context. When no number is given, it returns a summary list of recent open issues (pull requests are filtered out automatically).
Health check
php artisan tackle:health
Reports ✓ GitHub configured (owner/repo) — ReadGitHubIssue tool is active when both vars are present.
Sentry integration
When SENTRY_AUTH_TOKEN and SENTRY_ORG are set, the ReadSentryIssue tool becomes active. The agent can fetch the latest event for any Sentry issue — including the full exception, stacktrace, breadcrumbs, and HTTP request context — and use it as additional context when diagnosing bugs.
Configuration
Add these to your .env:
SENTRY_AUTH_TOKEN=sntrys_... # auth token with issue:read scope SENTRY_ORG=your-org-slug # visible in your Sentry URL (sentry.io/organizations/<slug>/) SENTRY_PROJECT=your-project # project slug — required for listing recent issues
These are the same env vars used by the Sentry CLI, so no extra setup is needed if you already use it.
Generate a token at Sentry → Settings → Account → API → Auth Tokens with the issue:read scope.
How it works
Ask the agent naturally:
> there's a DivisionByZeroError in Sentry (#4821) — can you fix it?
> what are my recent unresolved Sentry issues?
When given an issue ID, the tool calls GET /api/0/organizations/{org}/issues/{id}/events/latest/ and returns the exception type, message, stacktrace (top 15 frames, most recent first), breadcrumbs (last 10), and request method/URL.
When no ID is given, it calls GET /api/0/projects/{org}/{project}/issues/ and returns a summary list of recent unresolved issues.
Health check
php artisan tackle:health
Reports ✓ Sentry configured — ReadSentryIssue tool is active when credentials are present, or a warning with setup instructions if they are missing.
Self-healing queue workers
When enabled, Tackle listens for failed queue jobs and failed scheduled commands, dispatches an AI agent to diagnose the exception, patch the code, verify the fix with your test suite, and either open a pull request or apply the fix directly — all without you lifting a finger.
How it works
- A job fails → Laravel fires the
JobFailedevent. - Tackle's
JobFailureListenerpicks it up and dispatches aHealJobFailurejob to thehealerqueue (a separate queue from your normal workers). - A dedicated queue worker picks up
HealJobFailure. It:- Creates an isolated git worktree on a fresh branch (
tackle/heal-{id}). - Spins up a
HealingAgentpointed at that worktree. - Feeds the agent the exception class, message, stack trace, and (if Telescope is installed) the full Telescope exception entry.
- The agent reads the failing code, applies a minimal fix via
EditFile, and runs your test suite to verify.
- Creates an isolated git worktree on a fresh branch (
- After the agent finishes:
prmode (default): pushes the branch to GitHub and opens a pull request with the agent's reasoning as the description.patchmode: merges the fix back into your main workspace branch and re-dispatches the original job.
- The worktree is cleaned up regardless of outcome.
Prerequisites
- Your project must be a git repository with a remote named
origin. - A queue worker must be running the
healerqueue (see below). - For PR mode, a GitHub personal access token is required.
- For
patchmode, the working tree must be clean when healing runs.
Enabling the healer
Publish and run the migration, then enable via .env:
php artisan vendor:publish --tag="tackle-migrations"
php artisan migrate
AI_CODE_HEALING_ENABLED=true
The event listeners register automatically once this is set to true.
Starting the healer worker
The healer runs on a dedicated queue to avoid competing with your normal workers:
php artisan queue:work --queue=healer
Run this alongside your existing workers. In production (Supervisor, Forge, etc.)
add a separate process group for the healer queue.
For local development, the healer slots neatly into a
@laravel/multiplex tab next to the
rest of your stack:
npx @laravel/multiplex \ 'server,php artisan serve' \ 'queue,php artisan queue:listen' \ 'vite,npm run dev' \ 'healer@green,php artisan queue:work --queue=healer'
When a job throws in the queue tab, watch the healer tab diagnose it,
patch the code, and post the PR link — your dev environment healing itself.
(Multiplex spawns commands without stdin, so it suits the healer and
ai:run; the interactive ai:code and
ai:fix sessions need a real terminal.)
GitHub token setup
For PR mode, Tackle needs a GitHub token with the repo scope.
Resolution order:
GITHUB_TOKENin.env(or thetackle.healing.github_tokenconfig key)- GitHub CLI (
~/.config/gh/hosts.yml) — if you haveghinstalled and authenticated, Tackle reads your token automatically with no extra config. - If no token is found, the branch is pushed but the PR is not opened. A log entry records that you need to configure a token.
GITHUB_TOKEN=ghp_...
Configuration
All healer options live under the healing key in config/tackle.php:
| Option | Env var | Default | Description |
|---|---|---|---|
enabled |
AI_CODE_HEALING_ENABLED |
false |
Enable or disable the healer |
mode |
AI_CODE_HEALING_MODE |
pr |
pr = open a pull request; patch = apply directly |
queue |
AI_CODE_HEALING_QUEUE |
healer |
Queue name for the HealJobFailure job |
threshold |
AI_CODE_HEALING_THRESHOLD |
1 |
Number of failures before healing triggers |
base_branch |
AI_CODE_HEALING_BASE_BRANCH |
main |
Branch PRs are opened against |
branch_prefix |
AI_CODE_HEALING_BRANCH_PREFIX |
tackle/heal- |
Prefix for fix branches |
github_token |
GITHUB_TOKEN |
— | GitHub token for opening PRs |
telescope |
AI_CODE_HEALING_TELESCOPE |
true |
Use Telescope context if available |
Failure threshold
By default (threshold=1) the healer triggers on the first failure. If you want
the healer to wait until a job has failed a certain number of times before
intervening (e.g. to let transient failures resolve themselves), set:
AI_CODE_HEALING_THRESHOLD=3
PR mode vs patch mode
pr (default) |
patch |
|
|---|---|---|
| Human review required | Yes — merge the PR | No — merged automatically |
| Tests must pass | No (PR opened regardless) | Yes (only merges on green) |
| Job re-dispatched | No | Yes, after merge |
| Best for | Production / sensitive code | CI environments / trusted agents |
Laravel Telescope integration
If Laravel Telescope is installed in your application, Tackle uses it to give the agent richer context: the full exception entry including class, message, and stack frames. No extra configuration is needed — Tackle detects Telescope automatically and degrades gracefully if it is not present.
Scheduled command healing
Tackle also listens to the ScheduledTaskFailed event, which Laravel fires when
a task registered in App\Console\Kernel::schedule() (or a Schedule class)
throws an exception.
The healing flow is identical to queue jobs — an isolated git worktree, an AI agent, a test run, then a PR or patch. The one difference: scheduled tasks are not re-dispatched after a patch (they run on their own schedule). The fix simply takes effect the next time the task runs.
No extra configuration is needed beyond AI_CODE_HEALING_ENABLED=true.
Per-class opt-out
Some jobs should never be auto-patched — payment processors, email senders,
anything where an untested change would be worse than the failure. Use the
#[Healable(false)] attribute to opt out:
use Tackle\Attributes\Healable; #[Healable(false)] class ChargeSubscription implements ShouldQueue { public function handle(): void { // Tackle will skip this job entirely — even when AI_CODE_HEALING_ENABLED=true. } }
The listener checks for the attribute via reflection before dispatching a heal
job. Jobs without the attribute, or with #[Healable(true)], are healed
normally.
Audit log
Every healing attempt — successful or not — is written to the
tackle_healing_log table. View recent entries with:
php artisan tackle:healing-log
The table output shows when, what failed, whether tests passed, the outcome, and a link to the PR or branch:
+-------------+----------------+--------------------+-----------+-------+------------+
| When | Type | Subject | Tests | Out. | PR / Branch|
+-------------+----------------+--------------------+-----------+-------+------------+
| 2 mins ago | job | BrokenJob | ✗ | PR | github.com/|
| 1 hour ago | scheduled_task | SendWeeklyReport | ✓ | patched| tackle/... |
+-------------+----------------+--------------------+-----------+-------+------------+
Filters:
# Show only job failures php artisan tackle:healing-log --type=job # Show only scheduled task failures php artisan tackle:healing-log --type=scheduled_task # Show only successful patches php artisan tackle:healing-log --outcome=patched # Show only PR-mode results php artisan tackle:healing-log --outcome=pr_opened # Show more entries php artisan tackle:healing-log --limit=50
The audit log requires the migration to have been run:
php artisan vendor:publish --tag="tackle-migrations"
php artisan migrate
If the migration has not been run, healing continues normally — the log write degrades gracefully.
Healer limitations
- The healer targets code bugs — logic errors the AI can diagnose and fix. It is not designed for infrastructure issues (database down, disk full, etc.).
- The fix branch is pushed to
origin— your CI pipeline will run on it and can catch anything the local test run missed. - In
patchmode, if tests fail the healer falls back to PR mode automatically so nothing is merged without verification. - The healer never modifies
.env,vendor/,storage/, or.git/— the same path guards apply as in interactive mode. - Healer jobs have
$tries = 1. A failing healer does not create a healing loop.
Fix an issue
php artisan ai:fix opens a focused fix session. It loads context from a Sentry issue, a GitHub issue, or a pasted exception — then fires the agent immediately, without you having to describe the task. Worktree mode is on by default so live files are never touched until you open a PR.
# Paste or describe the exception at the prompt php artisan ai:fix # Load context from a Sentry issue php artisan ai:fix --sentry=4821 # Load context from a GitHub issue php artisan ai:fix --issue=42
After the agent applies the fix, the session stays open for follow-up:
> add a regression test for this
> open a pull request
> exit
All shell and worktree flags from ai:code are supported:
php artisan ai:fix --sentry=4821 --no-worktree # edit live files directly php artisan ai:fix --issue=42 --yolo # skip shell approval prompts
Code review
php artisan ai:review feeds your git diff to a read-only AI agent that reads
the surrounding codebase for context, then surfaces real issues grouped by file
with severity levels.
# Review everything since your last commit (staged + unstaged) php artisan ai:review # Review only staged changes php artisan ai:review --staged # PR-style review — your branch vs. another branch php artisan ai:review --against=main # Review a specific commit php artisan ai:review --commit=abc1234 # Tell the agent what to prioritise php artisan ai:review --against=main --focus=security,performance # Review a GitHub pull request (diff fetched via the GitHub API) php artisan ai:review --pr=42 # …and post the findings back to the PR as inline review comments php artisan ai:review --pr=42 --comment
Output format
Findings are grouped by file with three severity levels:
| Level | Meaning |
|---|---|
| 🔴 Critical | Bugs that will cause failures, security vulnerabilities, data loss risks |
| 🟡 Warning | Edge cases, missing error handling, performance concerns, breaking changes |
| 🟢 Suggestion | Improvements worth considering but not blocking |
The review ends with a one-line verdict: LGTM / LGTM with minor notes / Needs changes.
How it works
The ReviewAgent is a read-only agent — it has access to ReadFile, Glob,
and SearchCode but no editing tools. Before commenting on any changed function
or class it reads the full file for context, so findings are grounded in the
actual codebase rather than the diff alone.
Focus areas
Pass --focus with a comma-separated list to direct the agent's attention:
php artisan ai:review --focus=security php artisan ai:review --focus=performance,tests php artisan ai:review --staged --focus=bugs,security
Any plain-language description works — security, performance, n+1 queries,
missing tests, breaking changes, etc.
Reviewing pull requests
--pr=N reviews a GitHub pull request instead of a local diff. The diff is
fetched from the GitHub API, so it works regardless of which branches exist in
your local checkout — the checkout is only used by the agent's read tools to
understand the surrounding code. Requires GITHUB_TOKEN and GITHUB_REPO
(see GitHub Issues integration); the token needs
Pull requests: read permission, or read & write when using --comment.
# Print the review to the terminal php artisan ai:review --pr=42 # Post it to the PR as a single review with inline comments php artisan ai:review --pr=42 --comment
With --comment, each finding is anchored to the exact file and line as an
inline review comment (🔴/🟡/🟢 severity included), under a summary body with
the overall verdict. Findings that reference lines outside the diff are folded
into the summary instead of being dropped.
Incremental re-reviews
Re-running ai:review --pr on a PR Tackle has already reviewed does not
repeat the whole review. Each posted review embeds an invisible marker recording
the head commit it covered; on the next run Tackle finds it and:
- reviews only the changes pushed since the last review (via the GitHub compare API),
- tells the agent what it already reported, so findings aren't repeated,
- labels the posted review as a follow-up,
- and exits early with "Nothing new to review" when the head commit is unchanged.
If the previously reviewed commit was force-pushed away and the compare can't be
resolved, Tackle falls back to a full review automatically. Pass --full to
force a full re-review at any time:
php artisan ai:review --pr=42 --comment --full
This makes the pull_request-triggered workflow below cheap to run on
synchronize: each push reviews only its own delta instead of the entire PR
again.
Gating CI on the review
--fail-on makes the command exit non-zero when findings at or above the given
severity exist, so a workflow can block a merge:
php artisan ai:review --pr=42 --comment --fail-on=critical # fail on critical only php artisan ai:review --pr=42 --fail-on=warning # fail on critical or warning
--fail-on also works for local scopes (--staged, --against=main, …) —
useful in pre-push hooks.
Machine-readable review output
As with ai:run, --output=json prints one JSON document on stdout —
diagnostics (including the review prose) go to stderr — so stdout pipes
straight into jq. JSON mode always requests the structured findings block,
so verdict and findings are populated even without --comment or
--fail-on. Exit codes are unchanged.
php artisan ai:review --pr=42 --output=json | jq -r '.verdict'
{
"ok": true,
"outcome": "completed",
"error": null,
"verdict": "needs_changes",
"findings": [
{ "path": "app/Models/Subscription.php", "line": 42, "severity": "critical", "message": "Unchecked null." }
],
"text": "The review prose, without the findings block.",
"head_sha": "9f2ab1c4…",
"pr_number": 42,
"usage": { "input_tokens": 41233, "output_tokens": 2210, "estimated_cost_usd": 0.1563 }
}
outcome is completed, nothing_to_review, findings_gate_failed
(--fail-on tripped), or error. For local scopes, head_sha and
pr_number are null.
Reviewing every PR automatically
The easiest path is php artisan tackle:install review, which scaffolds
.github/workflows/tackle-review.yml using the
tackle-review action —
add the ANTHROPIC_API_KEY secret to the repository and every PR gets
reviewed. The generated workflow wraps the whole job in one step:
name: Tackle Review on: pull_request: permissions: contents: read pull-requests: write jobs: review: runs-on: ubuntu-latest steps: - uses: JordanDalton/tackle-review@v1 with: anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }} fail-on: critical # optional — omit for an advisory review
Or hand-roll the equivalent workflow in .github/workflows/tackle-review.yml:
name: Tackle Review on: pull_request: types: [opened, synchronize] permissions: contents: read pull-requests: write jobs: review: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: '8.3' - run: composer install --no-interaction --prefer-dist - name: Review the pull request env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPO: ${{ github.repository }} run: | php artisan ai:review \ --pr=${{ github.event.pull_request.number }} \ --comment \ --fail-on=critical
Notes:
pull-requests: writeis what lets the defaultGITHUB_TOKENpost the review.- Drop
--fail-on=criticalif you want the review to be advisory rather than blocking. - The review agent is read-only and the diff comes from the API, so the job
needs no database and no
--yes-style approval flags.
Acting on review comments (/tackle)
ai:respond closes the loop from found to fixed. When a reviewer replies
to a finding (or leaves any PR comment) asking Tackle to act, the command loads
the comment, its thread, and the diff context, runs the coding agent against
the instruction, pushes the resulting commit to the PR branch, and replies in
the thread:
php artisan ai:respond --pr=42 --comment-id=123456 --comment-type=review
# Machine-readable result — one JSON document on stdout, diagnostics on stderr
php artisan ai:respond --pr=42 --comment-id=123456 --output=json
--comment-type=reviewfor inline review comments (the usual case),--comment-type=issuefor comments in the PR conversation tab.--output=jsonreportsok,outcome,error,pr_number,comment_id,reply_posted,pushed, andusage— the same stdout/stderr discipline and usage shape asai:run. Exit codes are unchanged.- If the comment asks a question rather than requesting a change, the agent answers in the thread and touches nothing.
- The reply always arrives — success (with the pushed SHA and diff stat), no-op, or a clear failure message. Threads never dangle.
Guardrails, enforced in PHP:
- Fork PRs are refused — Tackle never pushes to a branch in someone else's repository; it replies explaining why instead.
- The checkout must match the PR head — the agent edits the working tree
and the result is pushed, so a mismatched checkout aborts before the agent
runs. In CI, check out
refs/pull/<n>/head. - Confirmations are auto-denied as in
ai:run; pass--yesonly where you would have approved them yourself.
The easiest way to wire this to GitHub is the respond action — see
tackle-review. Gate the
workflow on author_association so only maintainers can trigger it.
Explain code
php artisan ai:explain reads a file or class and explains what it does in plain
English — inputs, outputs, side effects, and any non-obvious behaviour. The agent
reads the full file and any closely-related classes before responding.
# Explain a whole file php artisan ai:explain app/Services/BillingService.php # Focus on a specific method php artisan ai:explain app/Services/BillingService.php --method=charge
Generate tests
php artisan ai:test reads a class, checks your existing test conventions, and
writes a Pest test file covering the happy path, edge cases, and error conditions.
It runs the tests after writing to confirm they pass.
# Generate tests for a class php artisan ai:test app/Services/BillingService.php # Focus on a single method php artisan ai:test app/Services/BillingService.php --method=charge # Force a feature or unit test php artisan ai:test app/Http/Controllers/UserController.php --feature php artisan ai:test app/Services/BillingService.php --unit
Test type is inferred from the path when no flag is given — controllers, jobs,
commands, listeners, and middleware default to Feature; everything else defaults
to Unit.
Upgrade a dependency
php artisan ai:upgrade performs a major version upgrade of a Composer package —
Laravel itself included — the way a careful human would, with the safety
boundaries enforced in PHP.
# What could be upgraded, and what blocks each one? Deterministic, no AI involved. php artisan ai:upgrade --audit # Same audit, mirrored to a GitHub issue — built for the scheduler php artisan ai:upgrade --audit --issue # Upgrade one package across a major version php artisan ai:upgrade laravel/framework # Upgrade several — sequential isolated sessions, one PR each php artisan ai:upgrade pestphp/pest spatie/laravel-permission # No package? Multi-select from the audit interactively. php artisan ai:upgrade
The session follows a fixed playbook:
- Audit —
composer outdatedestablishes what is installed and what is available;composer why-notnames the packages whose constraints block the jump. - Plan — the agent reads the package's
UPGRADE.md/CHANGELOG.mdfromvendor/(a narrow, docs-only carve-out of thevendor/*protected path), searches your code for actual usages, and presents a plan covering only the breaking changes that affect your app. Nothing mutates until you confirm. - Resolve — the constraint is bumped and
composer update --with-all-dependenciesruns. Solver conflicts are diagnosed withwhy-notand the blockers raised iteratively. - Fix — the code and config changes the upgrade guide requires, as minimal edits.
- Verify — your test suite, Larastan if installed, a boot smoke check, and Pint.
- Deliver — an honest summary (including which upgrade-guide items did not apply, and what your tests do not cover) and an offer to open a PR.
What makes it safe:
- Worktree by default. The whole upgrade — lockfile,
vendor/, code edits — happens in an isolated git worktree. A failed resolution can never leave your live checkout broken. Opt out with--no-worktree. - Lifecycle scripts stay off. Every composer mutation runs
--no-scripts(composer scripts are arbitrary project PHP — the same pathComposerScriptGuardblocks). Scripts run only when a human approves them in the terminal, after the lockfile change is reviewable. - Composer is fenced. The agent's composer tool permits a fixed set of
subcommands and refuses
--working-dir/--global;run-scriptandexecare not available at all. - Green ≠ proven. The final summary is required to say what the test suite did not exercise, rather than declaring the upgrade safe on a thin green run.
One major per session. For a framework major that forces ecosystem packages to
move together, the plan lists the full set before anything changes — that is
one atomic change and one PR. Independent majors are a different case: pass
several packages (or multi-select from the audit) and each runs as its own
sequential session with a fresh agent context, fresh worktree, and fresh
budget, delivering one PR per package — so a bad upgrade stays individually
reviewable, bisectable, and revertable. Each session's prompt fences the scope
to its own package, and every upgrade PR touches composer.lock, so after
merging one, rebase the next and re-run composer update on its branch.
Major upgrades are long sessions — consider raising AI_CODE_BUDGET beyond
the default $1 before starting one; in a batch the budget applies per package,
not to the batch as a whole.
Scheduled dependency watch
The audit needs no AI, no TTY, and costs nothing, so it is safe to run on the
scheduler. With --issue it maintains exactly one GitHub issue mirroring
the audit (requires GITHUB_TOKEN + GITHUB_REPO):
// routes/console.php Schedule::command('ai:upgrade --audit --issue')->daily();
- The first time majors appear, an issue titled "Composer major upgrades
available" is opened (labelled
tackle-upgrade-audit), listing each package with itswhy-notblockers. - When the audit changes, the issue body is updated in place. When it hasn't, nothing is written — no daily notification spam.
- When no major upgrades remain (you upgraded, or constraints resolved), the issue is commented on and closed.
The issue is the reminder; a human stays the trigger — read it and run
php artisan ai:upgrade <package> to turn it into a PR, or wire up the
unattended mode below.
Unattended upgrades (--headless)
php artisan ai:upgrade pestphp/pest --headless --output=json --ref-issue=42
Headless mode runs the same playbook with no terminal: the plan-confirmation
step is folded into the PR body, verification still gates delivery, and the PR
opens automatically — the pull request is the human gate, the same trust
model as the self-healer's mode=pr. --ref-issue=N makes the PR body carry
Refs #N (not Closes — the audit issue closes itself once no majors remain).
Safety properties specific to headless:
- Composer lifecycle scripts can never run. Enabling them requires an interactive approval, and the headless interaction policy is permanently non-interactive — there is no flag that overrides this.
- Explicit targets only: headless refuses to pick packages itself.
- Budget (
--budget, per package) and step ceiling (--max-steps) abort the run with distinct exit codes:0ok,1error,2budget,4max steps. --output=jsonemits one JSON document per package on stdout (progress goes to stderr): outcome, steps, diff stat, token usage, andpr_url.
A label-triggered GitHub Actions workflow closes the loop — the scheduler
maintains the issue, a maintainer applies the tackle-upgrade label, CI opens
the PR. The ephemeral runner with a scoped GITHUB_TOKEN is stronger
containment than any in-process guard:
name: tackle-upgrade on: issues: types: [labeled] permissions: contents: write pull-requests: write issues: read concurrency: tackle-upgrade jobs: upgrade: if: github.event.label.name == 'tackle-upgrade' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: { php-version: '8.4' } - run: composer install --no-interaction --no-scripts - run: | php artisan ai:upgrade pestphp/pest \ --headless --output=json --no-worktree \ --ref-issue=${{ github.event.issue.number }} \ --budget=3 env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPO: ${{ github.repository }} AI_CODE_GUARD_INJECTION: true
(--no-worktree because the checkout is already disposable. The label gate
matters: issue bodies are untrusted input, so only maintainer action — a label
they applied — may hand an agent write access. Enabling the injection
classifier fences what the issue reader returns.)
Health check
php artisan tackle:health verifies that the package is correctly set up. Run it
after installation or when something isn't working as expected.
php artisan tackle:health
It checks:
config/tackle.phpandconfig/ai.phpare published- An API key is configured for the active provider
- The project is a git repository with at least one commit
.env.testingexists (warns if missing)- If healing is enabled: migration has been run, GitHub token is available
Pruning dangling worktrees
If a session is interrupted before cleanup (e.g. a crash or kill -9), worktrees
may be left behind in /tmp. Use tackle:prune to remove them:
php artisan tackle:prune
# Preview without removing
php artisan tackle:prune --dry-run
Only directories matching the tackle-worktree-* pattern are touched — the
command will never remove your main working tree.
Replay a healing attempt
php artisan tackle:replay re-dispatches a previous healing attempt — useful when
you want to retry after adjusting config or fixing something manually.
# Replay the most recent healing attempt php artisan tackle:replay # Replay the last attempt for a specific job class php artisan tackle:replay --class="App\Jobs\ProcessPayment" # Replay a specific log entry by ID php artisan tackle:replay --id=42
Limitations
Things Tackle cannot do in v1:
- No internet access. The agent cannot fetch URLs, read documentation, or call external APIs. It works only with files in your workspace.
- No binary files.
ReadFilereads text. Images, compiled assets, and other binaries are not readable by the agent. - No auto-commit or push in standard mode. In a normal session all edits are
left unstaged. In worktree mode the agent can commit and push to an existing PR
branch using the
CommitAndPushtool, but it will always callConfirmActionfirst. - History persists as text. With the default
memory=file, sessions resume across runs, but tool outputs and image attachments are not replayed — the agent re-reads what it needs. Setmemory=noneto start fresh every time. - Budget is estimated, not exact. The spend limit is calculated from token counts using approximate per-model pricing. Actual charges from your provider may differ slightly.
- Tests need a working environment.
RunTestsruns your actual test suite. If tests require a database or other services, those must be running and configured before starting a session.
Customization
Generators
Tackle ships generator commands so you don't have to look up method signatures:
# Scaffold a tool at app/Ai/Tools/MyTool.php php artisan tackle:tool MyTool # Scaffold an agent that extends DefaultCodingAgent (most common) php artisan tackle:agent MyAgent # Scaffold a bare CodingAgent implementation php artisan tackle:agent MyAgent --full
To customise the generated stubs, publish them first:
php artisan vendor:publish --tag="tackle-stubs"
This copies the stubs to stubs/tackle/ in your project root. Both commands
check for published stubs before falling back to the package defaults.
Events
Tackle dispatches Laravel events around the agent lifecycle, so ordinary listeners can observe — or veto — what agents do. (For config-declared, ordered policy — including shell-command hooks and argument rewriting — see Hooks.)
| Event | When | Payload |
|---|---|---|
Tackle\Events\SessionStarted |
An ai:code / ai:run session begins |
command, provider, model |
Tackle\Events\SessionEnded |
The session ends | command, token counts, estimated cost |
Tackle\Events\ToolCalling |
Before a tool executes — vetoable | tool name, arguments |
Tackle\Events\ToolCalled |
After a tool executes | tool name, arguments, result, duration |
A ToolCalling listener that returns false blocks the call (the agent
receives a refusal and reroutes); returning a string uses it as the refusal
message. Anything else observes without interfering:
use Tackle\Events\ToolCalling; use Tackle\Events\ToolCalled; Event::listen(ToolCalling::class, function (ToolCalling $event) { if ($event->tool === 'RunShell' && now()->isWeekend()) { return 'No shell commands on weekends.'; } }); Event::listen(ToolCalled::class, function (ToolCalled $event) { AgentAudit::record($event->tool, $event->arguments, $event->durationMs); });
Events fire for the tools of DefaultCodingAgent (and subclasses) and the
self-healer. This is an extension layer on top of the safety guards, not a
replacement — PathGuard, shell modes, and allowlists still apply first.
Requires a laravel/ai version with ToolNameResolver (0.10+); on older
versions the events simply don't fire.
MCP client tools
Just as external clients can consume Tackle's tools over MCP, Tackle's agents
can consume external MCP servers. Install laravel/mcp
(requires laravel/ai >= 0.8), then return the client's tools from an agent's
tools() — laravel/ai wraps them automatically, prefixed mcp_tools_*:
use Laravel\Mcp\Facades\Mcp; class MyCodingAgent extends DefaultCodingAgent { public function tools(): iterable { return [ ...parent::tools(), ...Mcp::client('playwright')->tools(), ]; } }
Now the agent can drive a browser to verify its own fix, query an external system, or use any other MCP server you trust — while Tackle's own tools keep their PathGuard and shell-policy enforcement.
Adding your own tools
Create a class that extends Tackle\Tools\AbstractTool, then extend
DefaultCodingAgent to merge it into the tool list, and rebind the contract.
Step 1 — Generate the tool (or write it manually):
php artisan tackle:tool ReadDatabase
Step 2 — Implement the tool:
// app/Ai/Tools/ReadDatabase.php namespace App\Ai\Tools; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Support\Facades\DB; use Laravel\Ai\Tools\Request; use Tackle\Tools\AbstractTool; class ReadDatabase extends AbstractTool { public function description(): string { return 'Run a read-only SQL query and return results as JSON. SELECT only.'; } public function schema(JsonSchema $schema): array { return [ 'query' => $schema->string() ->description('The SELECT query to run.') ->required(), ]; } public function handle(Request $request): string { $sql = $request->string('query', ''); if (! str_starts_with(strtolower(ltrim($sql)), 'select')) { return 'Only SELECT queries are allowed.'; } return json_encode(DB::select($sql), JSON_PRETTY_PRINT); } }
Step 3 — Extend the agent (or generate it):
php artisan tackle:agent MyCodingAgent
Step 4 — Wire in your tool:
// app/Ai/MyCodingAgent.php namespace App\Ai; use App\Ai\Tools\ReadDatabase; use Tackle\Agents\DefaultCodingAgent; class MyCodingAgent extends DefaultCodingAgent { public function __construct( private ReadDatabase $readDatabase, ...$args, ) { parent::__construct(...$args); } public function tools(): iterable { return [...parent::tools(), $this->readDatabase]; } }
Step 5 — Rebind in your service provider:
// app/Providers/AppServiceProvider.php use App\Ai\MyCodingAgent; use Tackle\Contracts\CodingAgent; public function register(): void { $this->app->bind(CodingAgent::class, MyCodingAgent::class); }
The Laravel container resolves all constructor dependencies automatically, so your tool class can type-hint anything it needs (DB connections, services, etc.).
Tool contract
Every tool receives a Laravel\Ai\Tools\Request object in handle(). It
behaves like a read-only request bag:
$request->string('key', 'default'); // string value $request->boolean('key', false); // boolean value $request->integer('key', 0); // integer value $request->get('key', 'default'); // raw value $request->all(); // all arguments as array
When a tool should refuse an action, return a string explaining why rather than throwing an exception. The agent reads the refusal message and reroutes itself accordingly.
Swapping the agent entirely
If you need deeper control — different instructions, a different conversation
strategy, or a completely different set of tools — implement the CodingAgent
contract directly and rebind it:
// app/Ai/MyAgent.php namespace App\Ai; use Laravel\Ai\Promptable; use Tackle\Contracts\CodingAgent; class MyAgent implements CodingAgent { use Promptable; public function instructions(): string { return 'You are a specialist in this project. Only touch the billing module.'; } public function messages(): iterable { return []; } public function tools(): iterable { return [ // your tools here ]; } }
$this->app->bind(\Tackle\Contracts\CodingAgent::class, MyAgent::class);
The CodingAgent contract extends Laravel\Ai\Contracts\Agent, HasTools, and
Conversational, so laravel/ai's full streaming and tool-calling machinery
works automatically as long as you use the Promptable trait.
Changing the model or provider
The quickest way is via .env:
AI_CODE_PROVIDER=openai AI_CODE_MODEL=gpt-4o
The provider name must match a key in config/ai.php. Any provider supported by
laravel/ai (Anthropic, OpenAI, Gemini, Groq, Ollama, etc.) works as long as it
supports tool calling.
When switching models, also set AI_CODE_PRICE_INPUT / AI_CODE_PRICE_OUTPUT
to the model's per-million-token rates — the budget cap is estimated from token
counts, and the defaults assume Claude Sonnet pricing. For local models set both
to 0.
Internally, Tackle injects provider and model values via two custom Laravel
contextual attributes — #[AiProvider] and #[AiModel] — so any agent you
write by extending DefaultCodingAgent inherits these config values
automatically through the container.
Safety
- Protected paths — Tackle's file tools cannot read or write
.env,storage/,vendor/, or.git/by default. Enforced inPathGuard, not via prompting — no wording in a prompt makesReadFilereturn your.env. (This guards the tools, not the whole process — see What the guards do and don't stop.) - Unstaged edits — in standard mode all file changes are left unstaged.
Review with
git diff; discard withgit checkout -- .. - Worktree isolation — in worktree mode all edits go to a temp copy of the repo. Live files are untouched until you open a PR. The worktree is cleaned up automatically when the session ends.
- Environment-aware defaults — production defaults to
shell: offandworktree: on. The artisan allowlist is narrowed per environment (e.g.testandmigrate:*are only allowed locally). All of this is enforced in PHP, not via prompting. - Allowlist-gated commands —
RunArtisanandRunTestscheck the allowlist for the current environment before executing. Commands not in the list are refused with a clear message. Destructive commands (e.g.migrate:fresh) require an interactive confirmation even when allowlisted. - Budget cap — the session aborts once estimated spend exceeds
budget_usd. - Subprocess isolation —
RunArtisan,RunTests,RunPint, andRunShellall run as child processes. A broken generated file cannot crash the session. - Shell is gated — the default
approvemode requires your confirmation before any shell command runs. Use--offfor read-only exploration.
What the guards do and don't stop
Be clear-eyed about what these guarantees are worth, because it changes how you should deploy Tackle.
By default, the agent runs as you. Every subprocess it starts — RunTests,
RunArtisan, RunPint, an allowlisted composer under RunShell — executes
with your operating-system user's full privileges. Those are separate processes
that Tackle's in-PHP guards do not sit inside.
The consequence: an agent that can write code and trigger its execution
can reach anything your user can, PathGuard notwithstanding. It never needs to
call ReadFile on .env — it can write a Pest test that reads env('APP_KEY')
and surfaces it, then call RunTests; the subprocess reads the file your user
can already read, and PathGuard is never in the path. Composer scripts are
arbitrary PHP and are the same story. Worktree mode does not change this —
the subprocess still runs as you and still reads the real .env.
This is not a bug in the implementation. It is the fundamental limit of running an agent at your own privilege level: in-process guards cannot contain in-process code execution. So:
- What the guards do stop: honest mistakes, casual missteps, and the
accidental blast radius of a confused agent — a stray
rm, an edit to the wrong file, amigrate:freshon the wrong database. This is the overwhelming majority of what actually goes wrong day to day, and it is real protection. - What they do not stop: a determined agent — or, more realistically, a
prompt-injected one.
ai:fix --sentry=,ai:respond, and issue-driven runs all ingest text an attacker can influence (a crafted exception message, a PR comment, an issue body). Treat that text as untrusted input that reaches an agent running as you.
How to actually bound it, in ascending order of real containment:
- Keep the production defaults. In
production, shell isoff, worktree ison, and the artisan allowlist narrows toroute:list— the code-execution paths above are closed there. The exposure is real mainly on a developer's own machine and in staging. - Run untrusted-input jobs (
ai:fix,ai:respond, self-healing) in CI or a container with scoped, throwaway credentials — never with your production secrets in the environment. If the agent exfiltrates the env, there is nothing there worth taking. - Isolate the process. True containment comes from an OS-level jail (a locked-down container, a disposable VM, throwaway credentials), not from in-language checks. If you need a structural guarantee rather than a best-effort one, that is the layer that provides it.
The short version: Tackle's guards make the agent safe to work with. They do not make an agent running on your machine safe to distrust. Deploy the untrusted-input paths accordingly.
Guard pack
Tackle ships optional first-party hooks that block the concrete paths above. Install the recommended registration with:
php artisan tackle:install guard
It prints three pre_tool hook entries to add under hooks.pre_tool in
config/tackle.php:
SecretExfiltrationGuard(WriteFile, EditFile) — refuses writing code that reads secrets to surface them:env('…_KEY'),config('app.key'), reading.envdirectly. This closes the write-a-test-that-dumps-env path.NetworkExfiltrationGuard(WriteFile, EditFile, RunShell) — flags the exfiltration transport: outbound HTTP in agent-authored code,curl … | sh, externalcurl/wget. Modeblock(default),confirm, oroff.ComposerScriptGuard(RunShell) — blockscomposer run-script/execand lifecycle-script invocations, since composer scripts are arbitrary PHP.
Tune each via tackle.guard (AI_CODE_GUARD_SECRETS, …_NETWORK,
…_COMPOSER); extend the secret patterns with tackle.guard.secret_patterns.
This is defense-in-depth — one more layer, not the wall. The guard pack raises the cost of the known exfiltration paths and catches mistakes and unsophisticated injection, but it runs in-process at the agent's privilege and a determined attacker who avoids the signatures is not stopped by it. It sits below mitigation #3 (OS-level isolation), never in place of it.
Injection shield (experimental)
The guards above defend the outbound paths — code the agent writes. The
inbound threat is prompt injection through the untrusted text the agent
reads: a crafted exception message, an issue body, a PR comment carrying
instructions aimed at the agent. The injection shield screens the untrusted
readers (ReadSentryIssue, ReadGitHubIssue, ReadPullRequest) with a cheap
classifier model. Flagged content is returned fenced and labelled as
untrusted data the agent must not obey — reframed, not blocked, so the reader
still works. Enable it in config/tackle.php:
'guard' => [ 'injection_classifier' => [ 'enabled' => env('AI_CODE_GUARD_INJECTION', true), 'model' => 'claude-haiku-4-5-20251001', // a small, fast model ], ],
It costs one cheap model call per untrusted read and fails open — a classifier error passes the content through unshielded rather than breaking the read. Same honest caveat, doubly so here: the classifier is itself an LLM and can be injected. It lowers the odds a crafted payload steers the main agent; it does not eliminate them. Defense-in-depth, still below OS isolation.
Troubleshooting
HTTP request returned status code 401
Your API key is missing or incorrect. Check that ANTHROPIC_API_KEY (or the key
for your chosen provider) is set in .env and that config/ai.php has been
published and contains the matching provider block.
Agent error: ... and the session continues
The agent caught an exception during a turn. The error is shown but the session stays alive — type your next task to continue. If the same error repeats, check the message for clues (auth issues, missing binaries, filesystem permissions).
Session aborted: estimated cost exceeds the budget limit
You've hit the budget_usd cap. Increase it in .env:
AI_CODE_BUDGET=5.00
Or pass a higher limit for a single session by editing the config temporarily. The default $1.00 limit is intentionally conservative.
ai:code requires an interactive TTY
ai:code is an interactive REPL and must be run in a real terminal — not piped,
not in a CI job, not through a non-interactive shell, because its approval
prompts need user input.
For pipes, CI jobs, and cron, use
ai:run instead. It runs the same agent with
the same tools and reports a structured result and an exit code.
Path '...' is outside the workspace root
The agent tried to access a file outside the configured workspace (defaults to
base_path()). If you're working in a monorepo or non-standard layout, set
workspace in config/tackle.php to the correct root path.
Path '...' matches protected pattern
The agent tried to read or write a protected file (.env, vendor/, etc.).
This is intentional — protected paths are blocked in code, not via prompting. If
you need to unblock a path (e.g. you're working on a package inside vendor/),
remove or narrow the relevant pattern in protected_paths.
old_str not found / old_str appears N times
The agent is trying to edit a file but the string it wants to replace either doesn't exist or appears more than once. This usually means the agent needs to re-read the file to get the current content. Tell it: "read the file again before editing."
Pint is not installed
Install Pint as a dev dependency in the host app:
composer require laravel/pint --dev
Tests fail during a session
This is expected behaviour. When RunTests returns failures, the agent reads the
output and attempts to fix the code. If it gets stuck, tell it what the failure
means or paste the relevant stack trace as your next message.
Healer branch is pushed but no PR is opened
Tackle could not find a GitHub token. Check the resolution order:
GITHUB_TOKENin.env- GitHub CLI: run
gh auth status— if it shows "not logged in", rungh auth login tackle.healing.github_tokeninconfig/tackle.php
git worktree add failed
This means either:
- The project is not inside a git repository. Run
git init && git commit -am "initial". - The branch name already exists. Delete it:
git branch -D tackle/heal-<id>and retry.
Healer ran but didn't fix the bug
The agent's fix will be in the PR (or logged). Review the PR description for the agent's reasoning. If the diagnosis was wrong, close the PR and fix it manually — the agent's attempt gives you a starting point and a working branch to build on.
Healer triggered a loop
HealJobFailure has $tries = 1, so a failing healer cannot create a loop with
itself. If you see repeated healer jobs it means the original job keeps failing
and threshold is set to 1. Raise the threshold or disable healing until the
root cause is resolved:
AI_CODE_HEALING_ENABLED=false
Known Risks
laravel/aiis new and fast-moving. It reshapes itsAgentcontract on most 0.x minors, and an incompatible method signature is a compile-time fatal — the agent class cannot be declared at all, taking down every command with it. Tackle supports>=0.1 <0.11and CI runs the suite against the oldest, middle, and newest of that range on PHP 8.3 and 8.4. A release beyond 0.10 is not covered until the matrix is extended.
This tool modifies your codebase and runs commands. Always run it inside a committed git working tree so you have a clear undo path (
git checkout -- .).
Development
composer install ./vendor/bin/pest # run tests ./vendor/bin/pint # format code
License
MIT