Search by

icdev / claude-agent-sdk-php

PHP port of @anthropic-ai/claude-agent-sdk — drives the Claude Code CLI over stream-json stdio

Maintainers

Package info

github.com/ichavezrg/claude-agent-sdk-php

pkg:composer/icdev/claude-agent-sdk-php

Transparency log

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-09-04 15:08 UTC

This package is auto-updated.

Last update: 2026-09-04 16:55:39 UTC


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 sdkMcpServersno 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
maskClaims requiere decode y claims no vacíos
extract + decode juntos permitido (extract afina el localizador JWT) prohibido
onExtractNoMatch fail-closed con decode permitido prohibido (decode es fail-open)
maskDuplicates no existe
mask sobre directorio (path con / final) prohibido n/a
Nombre válido ^[A-Za-z_][A-Za-z0-9_]*$ n/a
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

  1. canUseTool no se dispara sin --permission-prompt-tool stdio. Sin ese flag el callback queda muerto y no hay error. Lo emite Options solo.
  2. allowedTools auto-aprueba. Si pones Bash ahí, canUseTool nunca se invoca para Bash. Para restringir qué herramientas existen usa tools; para pre-aprobar usa allowedTools. Son ejes distintos.
  3. Las notificaciones MCP necesitan envelope. Un notifications/initialized respondido con {} deja el servidor en status: "failed" y las herramientas nunca aparecen. Hay que devolver {"mcp_response":{"jsonrpc":"2.0","result":{},"id":0}}.
  4. Los servidores MCP en-proceso no van en --mcp-config. Solo en la lista sdkMcpServers del initialize.
  5. stderr hay que drenarlo. Si se llena el pipe y tú bloqueas leyendo stdout, deadlock. CliTransport usa stream_select sobre ambos descriptores.
  6. El CLI puede pedirte algo mientras esperas tu propia respuesta de control. Si no atiendes ese control_request intermedio, deadlock. Client::control() lo maneja y encola los mensajes SDK que lleguen fuera de orden.
  7. sandbox y una ruta de settings son excluyentes. Ambos usan --settings. Options lo detecta al construir los args, no al arrancar. Si pasas $settings como JSON inline, se hace merge y sandbox gana.
  8. enabled: true fuerza failIfUnavailable: 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 el initialize.
  9. El tool de despacho de subagentes es Agent, no Task, desde CLI 2.1.x.

Limitaciones de esta versión

  • Bloqueante/síncrono (generadores). Para concurrencia real: ReactPHP (react/child-process) o Amp, cambiando solo CliTransport.
  • stream_event (mensajes parciales) se emite si activas includePartialMessages: 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.