# clawdforge — PHP SDK PHP 8.2+ client for the [clawdforge](http://192.168.0.5:3001/Sulkta-Coop/clawdforge) LAN-only HTTP service that wraps `claude -p` subprocess calls behind a bearer-token-gated REST API. - **PSR-4 autoload**, **PSR-12** code style, **strict types** everywhere. - **Guzzle 7** transport, injectable `ClientInterface` for tests / custom middleware. - **Readonly value objects** for request input, run results, file tokens, app tokens. - **Typed exception hierarchy** so callers can discriminate auth / api / transport failures without sniffing status codes. --- ## Install Once published, install via Composer: ```bash composer require clawdforge/clawdforge ``` To install from a local checkout (e.g. while the SDK lives in this repo): ```jsonc { "repositories": [ { "type": "path", "url": "../path/to/clawdforge/clients/php" } ], "require": { "clawdforge/clawdforge": "*" } } ``` ## Quickstart ```php healthz(); echo $health['claude_version'], "\n"; // Run a prompt. try { $result = $forge->run(new RunRequest( prompt: 'Reply with JSON: {"hello": "world"}', model: 'sonnet', system: 'Be terse.', timeoutSecs: 60, )); } catch (ForgeException $e) { // ForgeException is the abstract base; sub-types tell you what failed. fwrite(STDERR, "forge: {$e->getMessage()}\n"); exit(1); } echo "{$result->durationMs}ms, stop_reason={$result->stopReason}\n"; // `result` is mixed: array if the model produced valid JSON, string otherwise. if (is_array($result->result)) { echo $result->result['hello'] ?? 'no key', "\n"; } else { echo (string) $result->result, "\n"; } ``` ## API surface | Method | Endpoint | Purpose | |---|---|---| | `Client::healthz()` | `GET /healthz` | Liveness + `claude --version`. | | `Client::run(RunRequest)` | `POST /run` | Run a prompt, return a `RunResult`. | | `Client::uploadFile(string $path, int $ttlSecs = 3600)` | `POST /files` | Stream-upload a file from disk, return a `FileToken`. | | `Client::uploadStream(StreamInterface $stream, string $filename, int $ttlSecs = 3600)` | `POST /files` | Stream-upload from a PSR-7 stream (use this when the bytes don't live at a path). | | `Client::createSession(string $agent = 'claude', ?array $meta = null)` | `POST /sessions` | Create a multi-turn session, return a `Session`. **(v0.2)** | | `Client::session(callable $fn, string $agent = 'claude', ?array $meta = null)` | block helper | Create + auto-close around a callable. **(v0.2)** | | `Client::listSessions()` | `GET /sessions` | List sessions for the calling token. **(v0.2)** | | `Client::getSession(string $id)` | `GET /sessions/{id}` | Fetch one session's state. **(v0.2)** | | `Client::createToken(string $name, array $ipCidrs = [])` | `POST /admin/tokens` | Mint a per-app token (admin-only). | | `Client::listTokens()` | `GET /admin/tokens` | List known app tokens (admin-only). | | `Client::revokeToken(string $name)` | `DELETE /admin/tokens/{name}` | Revoke a token (admin-only). | ### Files in a prompt > **Security note.** `Client::uploadFile($path)` reads `$path` straight off > disk. **Never pass a user-supplied string here** — that turns the SDK into > a server-side file-read primitive. Validate the path yourself, or use > `Client::uploadStream()` below when handling form uploads or any other > bytes that didn't originate from a path you control. ```php // Path you control yourself (config, vetted asset path, your own tempfile): $ft = $forge->uploadFile('./recipe.png', ttlSecs: 3600); $out = $forge->run(new RunRequest( prompt: 'extract recipe data', files: [$ft->fileToken], )); // Form uploads / arbitrary streams — use uploadStream(): use Psr\Http\Message\UploadedFileInterface; function ingest(UploadedFileInterface $file, \Clawdforge\Client $forge): void { $ft = $forge->uploadStream( stream: $file->getStream(), filename: $file->getClientFilename() ?? 'upload.bin', ttlSecs: 3600, ); // ... use $ft->fileToken in a RunRequest } ``` ### Multi-turn / Sessions (v0.2) v0.2 adds a parallel `/sessions/*` surface for multi-turn conversations, backed server-side by [acpx](https://github.com/openclaw/acpx). The single- turn `Client::run()` path is unchanged — sessions are purely additive. Use the **block-form helper** for the common case — it auto-closes the session on exit, including when the callable throws: ```php use Clawdforge\Client; use Clawdforge\Session; $forge = new Client('http://localhost:8800', 'cf_xxxxxxxx'); $reply = $forge->session(function (Session $s): string { $r1 = $s->turn('Read README.md and summarize the auth flow.'); $r2 = $s->turn('Now look at the file-upload path. Where are the bounds checks?'); return $r2->text(); }, agent: 'claude'); echo $reply, "\n"; // Session is closed here — even if the callable threw. ``` For lifecycles that have to outlive a single function (e.g. a session parked on a job-queue worker), use **manual lifecycle** with `try/finally`: ```php $s = $forge->createSession(agent: 'claude'); try { $r = $s->turn('Read README.md and summarize'); echo $r->text(); $r = $s->turn('Now look at the auth flow'); echo $r->text(); } finally { $s->close(); // idempotent — safe to call from finally } ``` `Session::close()` is idempotent on both sides: - Server: `DELETE /sessions/{id}` returns 200 with `already_closed: true` on a re-close. - Client: subsequent calls short-circuit without an HTTP round-trip. If `close()` itself fails (transport blip, 5xx) the in-memory `closed` flag is rolled back so callers can retry against the same `Session` — the server's idempotency means a retry cannot land you in a half-closed state. #### Destructor as best-effort fallback If a `Session` goes out of scope without an explicit `close()` (forgotten in a long function, or an exception that skipped the `finally`), the destructor fires `DELETE /sessions/{id}` as a best-effort cleanup. Failures during destructor close are swallowed and surfaced via PHP's `error_log()` — raising from `__destruct` is undefined behaviour and would mask the surrounding exception path. The destructor is **not** a substitute for the block-form helper or `try/finally`. PHP's GC is reference-counted and the precise timing of destructor invocation depends on whether anything else still holds a reference to the Session (closures, framework DI containers, test mocks). Always prefer explicit close. #### Listing and inspecting sessions ```php foreach ($forge->listSessions() as $row) { echo "{$row->sessionId} — turns={$row->turnCount} live={$row->live}\n"; } $state = $forge->getSession($sessionId); echo $state->turnCount, "\n"; ``` `Client::listSessions()` is scoped to the bearer token making the call — per-app isolation is enforced server-side. A `getSession()` against an id that belongs to a different token surfaces as `ApiException` with `statusCode === 404` (the server returns 404 not 403 to avoid leaking session existence across token boundaries). #### TurnResult and events ```php final readonly class TurnResult { public bool $ok; public string $sessionId; public int $turnIndex; public array $events; // list public string $stopReason; public int $durationMs; public function text(): string; // concatenates 'text' events' content } ``` `TurnResult::text()` is sugar for the common "just give me the prose" case — it concatenates the `content` of every `type === 'text'` event and ignores `thinking` / `tool_call` events. For richer access, walk `$result->events` directly: ```php foreach ($result->events as $e) { if ($e->type === 'tool_call') { echo "tool: {$e->name} args=", json_encode($e->args), "\n"; } } ``` #### Token redaction in dumps `Session::__debugInfo()` returns only `id`, `agent`, `closed`, `createdAt` — the parent `Client` back-reference (which holds the bearer) is intentionally omitted so `var_dump($s)`, `print_r($s)`, framework error reflectors, and DI-container introspection cannot leak the token via the back-reference. Same redaction discipline as `Client::__debugInfo()` itself. #### Exceptions ``` Clawdforge\Exception\ForgeException ├── Clawdforge\Exception\ApiException │ └── Clawdforge\Exception\AuthException ├── Clawdforge\Exception\TransportException ├── Clawdforge\Exception\MalformedResponseException └── Clawdforge\Exception\ClosedSessionException (v0.2) ``` `ClosedSessionException` is raised by `Session::turn()` when the caller holds a stale reference to a session this client has already closed. The client never makes the HTTP call in that case — it's a guard against use-after-close, not a server response. ### Naming convention PHP-side identifiers are camelCase; the wire JSON uses snake_case. The conversion happens at the boundary inside `RunRequest::toWire()` and the response object factories. | PHP | Wire | |------------------------------|----------------| | `RunRequest::$timeoutSecs` | `timeout_secs` | | `FileToken::$fileToken` | `file_token` | | `FileToken::$ttlSecs` | `ttl_secs` | | `RunResult::$durationMs` | `duration_ms` | | `RunResult::$stopReason` | `stop_reason` | | `AppToken::$ipCidrs` | `ip_cidrs` | | `Session::$id` | `session_id` | | `Session::$createdAt` | `created_at` | | `TurnResult::$turnIndex` | `turn_index` | | `TurnResult::$durationMs` | `duration_ms` | | `TurnResult::$stopReason` | `stop_reason` | | `SessionState::$appName` | `app_name` | | `SessionState::$lastTurnAt` | `last_turn_at` | | `SessionState::$turnCount` | `turn_count` | | `SessionState::$closedAt` | `closed_at` | ### Exceptions ``` Clawdforge\Exception\ForgeException (abstract base) ├── Clawdforge\Exception\ApiException (4xx/5xx; exposes $statusCode, $body, $decoded) │ └── Clawdforge\Exception\AuthException (401/403) ├── Clawdforge\Exception\TransportException (Guzzle connect/timeout/TLS) ├── Clawdforge\Exception\MalformedResponseException (2xx with a body the SDK couldn't parse) └── Clawdforge\Exception\ClosedSessionException (v0.2 — Session used after close()) ``` A single `catch (ForgeException $e)` catches all SDK failures. Narrow with `instanceof` when you need to distinguish. A `/run` failure (subprocess timeout, claude crash, etc.) lands as a 502 with a JSON envelope — the SDK throws `ApiException` with `$statusCode = 502` and the envelope available as `$decoded` (`error`, `stderr`, `duration_ms`, `stop_reason`). `MalformedResponseException` is raised when the transport itself succeeded (typically 200) but the body wasn't the expected JSON object — e.g. a reverse proxy injected an HTML error page, the body was truncated, or something downstream returned non-UTF-8 noise. It is **not** raised for 4xx/5xx responses with a parseable body; those still come through as `ApiException`. ### Custom HTTP client The constructor accepts any `GuzzleHttp\ClientInterface`, which lets you add middleware (retries, logging, circuit breakers) or swap to a mock handler in tests: ```php use GuzzleHttp\Client as GuzzleClient; use GuzzleHttp\HandlerStack; use GuzzleHttp\Middleware; $stack = HandlerStack::create(); $stack->push(Middleware::retry(/* decider */, /* delay */)); $forge = new Client( baseUrl: 'http://localhost:8800', token: $token, http: new GuzzleClient(['handler' => $stack, 'http_errors' => false]), ); ``` > Don't pass `'verify' => false` to your Guzzle client outside of a > controlled test environment — it disables TLS certificate verification > and makes the connection trivially MITM-able. The clawdforge service > is LAN-only by design; if you need to talk to it over a hostile > network, fix the cert chain rather than disabling verification. ## Framework snippets ### Laravel Bind the client as a singleton in `app/Providers/AppServiceProvider.php`: ```php use Clawdforge\Client; public function register(): void { $this->app->singleton(Client::class, function () { return new Client( baseUrl: config('services.clawdforge.url'), token: config('services.clawdforge.token'), ); }); } ``` `config/services.php`: ```php 'clawdforge' => [ 'url' => env('CLAWDFORGE_URL', 'http://localhost:8800'), 'token' => env('CLAWDFORGE_TOKEN'), ], ``` Then inject anywhere: ```php use Clawdforge\Client; use Clawdforge\RunRequest; class RecipeParser { public function __construct(private readonly Client $forge) {} public function parse(string $line): array { $r = $this->forge->run(new RunRequest( prompt: "Sterilize this ingredient line: '{$line}'", system: 'You are a precise recipe parser. Always reply with valid JSON.', timeoutSecs: 30, )); return is_array($r->result) ? $r->result : []; } } ``` ### WordPress ```php require_once ABSPATH . 'vendor/autoload.php'; use Clawdforge\Client; use Clawdforge\RunRequest; use Clawdforge\Exception\ForgeException; function my_plugin_clawdforge(): Client { static $client = null; if ($client === null) { $client = new Client( baseUrl: defined('CLAWDFORGE_URL') ? CLAWDFORGE_URL : 'http://localhost:8800', token: defined('CLAWDFORGE_TOKEN') ? CLAWDFORGE_TOKEN : '', ); } return $client; } add_action('rest_api_init', function () { register_rest_route('myplugin/v1', '/summarize', [ 'methods' => 'POST', 'permission_callback' => fn() => current_user_can('edit_posts'), 'callback' => function (WP_REST_Request $req) { try { $r = my_plugin_clawdforge()->run(new RunRequest( prompt: 'Summarize: ' . (string) $req->get_param('text'), timeoutSecs: 30, )); return new WP_REST_Response(['summary' => $r->result], 200); } catch (ForgeException $e) { return new WP_Error('clawdforge', $e->getMessage(), ['status' => 502]); } }, ]); }); ``` ## Testing The SDK ships with PHPUnit 10 tests that mock Guzzle via `GuzzleHttp\Handler\MockHandler` — no live network. ```bash composer install vendor/bin/phpunit ``` `php -l` over every source file: ```bash find src -name '*.php' -exec php -l {} \; ``` ## Design notes - **No retries.** `claude -p` runs are not idempotent (they spawn subprocesses with side effects). Wrap calls in your own retry layer if you need one — Guzzle's `Middleware::retry` plugs right into the constructor. - **HTTP timeout = subprocess timeout + 30 s margin.** Keeps the client from bailing while clawdforge is still doing legitimate work for us. Override via the `httpTimeoutMargin` constructor argument. - **Streamed file uploads.** `uploadFile()` opens the file via `fopen($path, 'r')` and hands the resource to Guzzle's multipart adapter; payloads never get slurped into memory. - **Plaintext token discipline.** `createToken()` returns the plaintext bearer in `AppToken::$token` exactly once. `listTokens()` always returns `null` for that field — the server keeps only a sha256 hash. Persist immediately on mint. ## License MIT.