icdev / claude-agent-sdk-php
PHP port of @anthropic-ai/claude-agent-sdk — drives the Claude Code CLI over stream-json stdio
Requires
- php: >=8.2
- ext-json: *
Requires (Dev)
- phpunit/phpunit: ^11
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Port en PHP de @anthropic-ai/claude-agent-sdk.
Framework-agnóstico a propósito. Sin dependencias más allá de php >= 8.2
y ext-json. Pensado como base sobre la que montar integraciones (Laravel,
Symfony, CLI propio) en paquetes separados.
La idea clave
El SDK de TypeScript no es un cliente de la API. Es un envoltorio de ~1.3 MB
alrededor del binario claude (Claude Code), al que lanza como subproceso y con
el que habla NDJSON por stdin/stdout:
claude --output-format stream-json --verbose --input-format stream-json
El harness del agente —el bucle, las herramientas integradas (Read, Write, Edit, Bash, Glob, Grep, WebFetch…), la compactación de contexto, los subagentes, los permisos, las sesiones— vive dentro del binario. Por eso portarlo a PHP no significa reimplementar un agente: significa implementar dos protocolos.
1. Protocolo de mensajes (NDJSON, una línea por objeto)
| Dirección | Tipos |
|---|---|
| PHP → CLI | user |
| CLI → PHP | system (subtype: init), assistant, user (tool_result), stream_event, result, rate_limit_event |
2. Protocolo de control (bidireccional, sobre el mismo canal)
{"type":"control_request","request_id":"…","request":{"subtype":"…", …}}
{"type":"control_response","response":{"subtype":"success","request_id":"…","response":{…}}}
{"type":"control_response","response":{"subtype":"error","request_id":"…","error":"…"}}
PHP → CLI: initialize, interrupt, set_permission_mode, set_model, mcp_set_servers
CLI → PHP: can_use_tool, hook_callback, mcp_message
initialize es obligatorio y es donde viajan systemPrompt, appendSystemPrompt,
agents, settingSources, hooks y sdkMcpServers — no son flags de CLI.
Uso
use ClaudeAgent\Options; use function ClaudeAgent\query; foreach (query('¿Cuánto es 17 * 23?', new Options(model: 'claude-opus-5')) as $m) { if ($m['type'] === 'assistant') { foreach ($m['message']['content'] as $b) { if ($b['type'] === 'text') echo $b['text'], "\n"; } } }
Multi-turno con sesión viva:
$client = new ClaudeAgent\Client(new Options(model: 'claude-opus-5')); foreach ($client->query('Recuerda el número 42.') as $m) { /* … */ } foreach ($client->query('¿Qué número era?') as $m) { /* … */ } $client->close();
Herramientas MCP dentro del proceso PHP (con tu Eloquent / PDO / contenedor DI):
$srv = (new SdkMcpServer('facturacion'))->tool( 'saldo_cliente', 'Devuelve el saldo pendiente de un cliente.', ['type' => 'object', 'properties' => ['cliente_id' => ['type' => 'integer']], 'required' => ['cliente_id']], fn (array $in) => Cliente::find($in['cliente_id'])->saldoPendiente(), ); new Options( sdkMcpServers: ['facturacion' => $srv], allowedTools: ['mcp__facturacion__saldo_cliente'], );
Subagentes, plugins y sandbox
Los tres son opciones tipadas. Cada uno viaja por un canal distinto — ese es el detalle que hay que respetar:
| Opción | Tipo | Canal |
|---|---|---|
agents |
array<string, Agent> |
payload de initialize |
plugins |
list<Plugin> |
flags --plugin-dir / --plugin-dir-no-mcp |
sandbox |
Sandbox |
JSON inline en --settings, bajo la clave sandbox |
use ClaudeAgent\{Agent, Plugin, Options, Effort, MemoryScope, PermissionMode}; use ClaudeAgent\Sandbox\{Sandbox, Network, Filesystem}; new Options( agents: [ 'revisor' => new Agent( description: 'Revisa código PHP en busca de bugs.', prompt: 'Eres un revisor de PHP. Sé conciso.', tools: ['Read', 'Grep', 'Glob'], model: 'sonnet', effort: Effort::Low, memory: MemoryScope::Project, permissionMode: PermissionMode::Plan, ), ], plugins: [ Plugin::local('/ruta/plugin'), Plugin::localWithoutMcp('/ruta/otro'), // carga skills/hooks, ignora su .mcp.json ], sandbox: new Sandbox( enabled: true, network: new Network(allowedDomains: ['api.anthropic.com'], strictAllowlist: true), filesystem: new Filesystem(allowWrite: ['/tmp/work'], denyRead: ['/root/.ssh']), ), );
Atajos: Sandbox::enabled() y Sandbox::locked(allowedDomains: [...], allowWrite: [...]).
Enums para lo que era string: PermissionMode, Effort, MemoryScope.
Credenciales
Sandbox::$credentials está tipado por completo. deny bloquea la lectura;
mask muestra un centinela dentro del sandbox y el proxy del host sustituye
centinela→real al salir hacia injectHosts.
use ClaudeAgent\Sandbox\{Credentials, CredentialFile, CredentialEnvVar, DecodeFormat, NoMatchBehavior, AwsPair, Sigv4, Sigv4Policy}; new Credentials( files: [ CredentialFile::deny('/root/.ssh'), CredentialFile::mask('/root/.netrc', extract: 'password\s+(\S+)', // grupo 1 = el secreto onExtractNoMatch: NoMatchBehavior::Error, // fail-closed maskDuplicates: true), CredentialFile::mask('/root/.token.jwt', decode: DecodeFormat::Jwt, maskClaims: ['sub', 'email']), ], envVars: [ CredentialEnvVar::mask('AWS_SECRET_ACCESS_KEY', injectHosts: ['sts.amazonaws.com']), CredentialEnvVar::mask('DATABASE_URL', extract: '://[^:]+:([^@]+)@'), CredentialEnvVar::deny('GITHUB_TOKEN'), ], awsPairs: [new AwsPair('MI_KEY_ID', 'MI_SECRET', 'MI_TOKEN')], sigv4: new Sigv4(streaming: Sigv4Policy::Deny, presigned: Sigv4Policy::Passthrough), );
Enums: CredentialMode, DecodeFormat, NoMatchBehavior, Sigv4Policy.
Las reglas se validan al construir, no al arrancar el CLI (que es permisivo
con settings y descarta entradas inválidas en silencio). Ojo con la asimetría
entre files y envVars, que es real en upstream:
| Regla | files |
envVars |
|---|---|---|
extract necesita ≥1 grupo de captura |
sí | sí |
maskClaims requiere decode y claims no vacíos |
sí | sí |
extract + decode juntos |
permitido (extract afina el localizador JWT) | prohibido |
onExtractNoMatch fail-closed con decode |
permitido | prohibido (decode es fail-open) |
maskDuplicates |
sí | no existe |
mask sobre directorio (path con / final) |
prohibido | n/a |
Nombre válido ^[A-Za-z_][A-Za-z0-9_]*$ |
n/a | sí |
Cada var en un solo slot de awsPairs |
— | sí (dentro y entre pares) |
En una entrada deny los campos solo-de-mask son "aceptados pero ignorados"
upstream, así que no se validan — rechazarlos descartaría configs que el CLI
acepta.
Trampas que cuestan horas
canUseToolno se dispara sin--permission-prompt-tool stdio. Sin ese flag el callback queda muerto y no hay error. Lo emiteOptionssolo.allowedToolsauto-aprueba. Si ponesBashahí,canUseToolnunca se invoca para Bash. Para restringir qué herramientas existen usatools; para pre-aprobar usaallowedTools. Son ejes distintos.- Las notificaciones MCP necesitan envelope. Un
notifications/initializedrespondido con{}deja el servidor enstatus: "failed"y las herramientas nunca aparecen. Hay que devolver{"mcp_response":{"jsonrpc":"2.0","result":{},"id":0}}. - Los servidores MCP en-proceso no van en
--mcp-config. Solo en la listasdkMcpServersdelinitialize. - stderr hay que drenarlo. Si se llena el pipe y tú bloqueas leyendo stdout,
deadlock.
CliTransportusastream_selectsobre ambos descriptores. - El CLI puede pedirte algo mientras esperas tu propia respuesta de control.
Si no atiendes ese
control_requestintermedio, deadlock.Client::control()lo maneja y encola los mensajes SDK que lleguen fuera de orden. sandboxy una ruta de settings son excluyentes. Ambos usan--settings.Optionslo detecta al construir los args, no al arrancar. Si pasas$settingscomo JSON inline, se hace merge ysandboxgana.enabled: truefuerzafailIfUnavailable: true. Igual que upstream: activar el sandbox sin decir qué pasa si no está disponible no debe terminar ejecutando sin sandbox. Si falta bubblewrap, el CLI aborta en elinitialize.- El tool de despacho de subagentes es
Agent, noTask, desde CLI 2.1.x.
Limitaciones de esta versión
- Bloqueante/síncrono (generadores). Para concurrencia real: ReactPHP
(
react/child-process) o Amp, cambiando soloCliTransport. stream_event(mensajes parciales) se emite si activasincludePartialMessages: true, pero no hay helper de acumulación.
¿Es esto lo que necesitas?
- Quieres las herramientas integradas de Claude Code (leer/editar archivos, bash, grep) desde PHP → este enfoque, envuelve el CLI.
- Quieres solo la API con tus propias herramientas → usa el SDK PHP oficial
de Anthropic y su
toolRunner()/BetaRunnableTool. No necesitas nada de esto.
Seams para envolverlo desde un framework
Lo relevante si vas a construir una capa encima:
| Punto de extensión | Cómo |
|---|---|
| Construcción del cliente | new Client(Options $options) — inyectable tal cual; Options es un value object con props promovidas públicas. |
| Config declarativa | Options solo tiene constructor con named args. Si vas a mapear desde un array de config, añade un Options::fromArray() en la capa de arriba, no aquí. |
| Logging | Options::$onStderr es un Closure(string): void. Ahí engancha un PSR-3 sin que este paquete dependa de PSR-3. |
| Binario | Options::$cliPath fuerza la ruta; si no, CliTransport::locateCli() escanea PATH en PHP puro. |
| Herramientas de dominio | SdkMcpServer corre en tu proceso: los handlers pueden resolver del contenedor DI. |
| Ciclo de vida | Client::start() es idempotente y lazy; close() mata el subproceso. Un job largo puede mantener la sesión abierta entre turnos. |
Dos cosas a tener presentes en contexto web (php-fpm), ya cubiertas aquí:
proc_open debe estar habilitada (se valida con un error claro), y la resolución
del binario no usa shell_exec porque suele estar en disable_functions.
Tests
composer test # 130 tests unitarios, ~0.5 s, sin red ni API key
composer test:integration # contra el CLI real (cuesta tokens, ~40 s)
Los unitarios no tocan la red. tests/Fixtures/fake-cli.php es un doble del
binario claude que habla el mismo protocolo NDJSON + control, elegido por
escenario con FAKE_CLI_SCENARIO, y Options::$cliPath lo enchufa. Eso permite
probar el protocolo de control de verdad —incluidos los deadlocks— sin gastar
tokens.
Los de integración se saltan solos salvo que definas CLAUDE_SDK_INTEGRATION=1.
Cuatro tests cubren las trampas de arriba, y verifiqué por mutación que fallan si se quita la protección:
| Test | Mutación aplicada | Resultado |
|---|---|---|
testAFloodOfStderrDoesNotDeadlock… |
quitar stderr de stream_select |
cuelga |
testTheCliMayAskSomethingWhileWeAwait… |
ignorar control_request reentrante |
cuelga |
testSdkMessagesArrivingBefore…AreNotLost |
descartar la cola fuera de orden | falla |
testMcpRequestsAndNotifications… |
responder {} a notificaciones MCP |
falla |
Requisitos
PHP ≥ 8.2 y el binario claude en el PATH:
curl -fsSL https://claude.ai/install.sh | bash
Verificado contra
Claude Code CLI 2.1.240 · PHP 8.5 · protocolo del SDK TS 0.3.235
agents, plugins, sandbox y credentials se verificaron por diff contra
el SDK de TypeScript real: se lanza el SDK TS con un ejecutable falso que
captura su argv y su initialize, se genera la misma config en PHP, y se
comparan. Resultado: --settings (sandbox + credentials completo) y el payload
agents son idénticos. La única diferencia de argv es --thinking adaptive,
que este puerto añade por defecto y upstream omite.
Esa comparación es necesaria porque el CLI no valida el schema de settings:
acepta credentials.envVars[].mode: "bogus" sin protestar. Un arranque
exitoso no prueba que la config esté bien formada.