ergebnis/agent-detector
Detect whether your PHP code is running under an AI agent. This Composer package checks environment variables for 30+ agents like Claude Code, Codex, Cursor, and Copilot, exposing a simple Detector class with one isAgentPresent() method.
Install the package — there is no service provider, facade, config file, or auto-discovery:
composer require ergebnis/agent-detector
Requires PHP 7.4+ (through 8.6), so it drops into Laravel 8+ without friction.
Where to look first: the single class Ergebnis\AgentDetector\Detector and the README's agent/environment-variable table. The entire public API is one method:
use Ergebnis\AgentDetector;
$detector = new AgentDetector\Detector();
$isAgentPresent = $detector->isAgentPresent(\getenv());
Note the signature: it takes an array of environment variables as an argument rather than reading globals itself. That is the whole design — it is a pure-ish predicate over ['KEY' => 'value']. Everything else in this assessment follows from that.
First use case: make interactive Artisan commands safe to run under an agent. Agents (Claude Code, Codex, Cursor, Copilot CLI, Gemini CLI…) run your php artisan commands non-interactively and often can't answer questions or parse ANSI-decorated, progress-bar-laden output. The quickest win is a base command that refuses to prompt and switches to plain/JSON output when an agent is detected.
Minimal wiring in app/Providers/AppServiceProvider.php:
use Ergebnis\AgentDetector\Detector;
public function register(): void
{
$this->app->singleton(Detector::class, fn () => new Detector());
}
Then anywhere:
$detector = app(Detector::class);
$isAgent = $detector->isAgentPresent(getenv());
The class is final, so don't try to Mockery it or subclass it. Define a tiny interface and bind the package to it — this is what makes the rest of your codebase testable:
namespace App\Support;
interface AgentDetector
{
public function isAgentPresent(): bool;
}
namespace App\Support;
use Ergebnis\AgentDetector\Detector;
final class EnvAgentDetector implements AgentDetector
{
public function isAgentPresent(): bool
{
return (new Detector())->isAgentPresent(getenv());
}
}
// AppServiceProvider::register()
$this->app->singleton(AgentDetector::class, EnvAgentDetector::class);
Now tests can bind a trivial fake, and if you later want to add a X-Client-Agent header check or a --agent CLI flag, you only touch the decorator.
Create a base command that centralises the policy, then extend it:
abstract class AgentAwareCommand extends Command
{
protected function agentPresent(): bool
{
return $this->laravel->make(AgentDetector::class)->isAgentPresent();
}
protected function configureOutput(): void
{
if ($this->agentPresent()) {
$this->output->setDecorated(false);
$this->input->setInteractive(false);
}
}
}
Typical policy decisions inside commands:
$this->confirm() returning false under an agent is a silent footgun — short-circuit it explicitly).--format=json output rather than tables, spinners, or ProgressBar.migrate:fresh, db:wipe, cache clears on production) unless an explicit opt-in flag is passed.Agents browsing your app produce unusual traffic (no JS, rapid navigation, Accept headers skewed). A middleware that tags requests gives you a lever for debugging/telemetry:
public function handle(Request $request, Closure $next)
{
if (app(AgentDetector::class)->isAgentPresent()) {
Log::withContext(['is_ai_agent' => true]);
// e.g. disable the debug toolbar, skip CSRF-token-heavy onboarding,
// or serve a machine-readable variant of a docs page
}
return $next($request);
}
Keep this cheap: resolve the detector once (singleton), don't re-read the environment per request in a hot loop.
If queued jobs call a paid LLM API, write to a third-party service, or post to Slack, add an escape hatch:
public function handle(AgentDetector $detector): void
{
if ($detector->isAgentPresent() && ! config('services.llm.allow_from_agents')) {
$this->release(60);
return;
}
// ... real work
}
This is particularly valuable in CI, where agents routinely shell out and trigger jobs you didn't intend to run twice.
Because isAgentPresent() takes an array, the cleanest test is against the real class with a fixture array — no environment mutating at all:
$detector = new Detector();
$this->assertTrue($detector->isAgentPresent(['CLAUDECODE' => '1']));
$this->assertTrue($detector->isAgentPresent(['AI_AGENT' => '1']));
$this->assertFalse($detector->isAgentPresent([]));
For feature tests, bind your AgentDetector contract to a fake returning true/false. Prefer that over putenv(), which leaks between tests and is unreliable under PHPunit's process isolation settings.
The package documents AI_AGENT as a generic "an agent is here" marker. Use it deliberately: set it in your own scripts or CI jobs to exercise the agent code paths without installing Claude Code or Cursor.
getenv() returns false on missing keys, and the array form can be empty. getenv() with no argument returns an array in modern PHP, but under some SAPIs or if variables_order in php.ini omits E, $_ENV is empty. If detection behaves strangely, dump getenv() and $_ENV and compare — $_SERVER usually holds the same variables and is more reliable under PHP-FPM.clear_env = on (the default in many distro configs) strips inherited environment variables from workers. An agent-detection check that works in CLI may silently return false over HTTP-FPM. Do the detection in the CLI/queue context unless you've explicitly allowed the variables through env[...] in the pool config.config:cache and env(). Never put env('CLAUDECODE')-style lookups or a cached boolean in a config file and expect it to reflect a runtime-spawned agent. Call getenv() at the moment of decision, or bind a singleton that reads it at boot.isAgentPresent() returns bool. It does not tell you which agent, and there is no agentName(). If you need the specific agent (e.g., "Cursor users get X"), roll a small mapper over the README's table yourself — the column values are the contract.AI_AGENT=1 or CLAUDECODE=1. Never use this to gate authorisation, rate limits, or "trusted" code paths. It's a UX/operational heuristic only, and it fails closed in the wrong direction (an unset variable looks like a human).php artisan, the variables are inherited and detection fires. If a subprocess is spawned with a scrubbed environment (env -i, some Docker exec calls), it won't. That's a feature for blast-radius control, but it surprises people.$this->input->isInteractive() isn't automatically false just because an agent is running. Set it yourself, or agents will hang on $this->ask() until a timeout kills the job.$this->output->setDecorated(false) plus --no-ansi keeps logs parseable. Also consider --no-progress-style flags on any long-running command you expose.COPILOT_CLI and PI_CODING_AGENT arrived in 1.2.0; Cursor's CURSOR_EXTENSION_HOST_ROLE in 1.1.0). Pin ^1.3 and let Dependabot pull new agent coverage in; don't pin to an exact patch or you'll miss the next agent entirely.in_array check) is a one-file change.How can I help you explore Laravel packages today?