clawdforge/clients/php/README.md
Kayos 42b1516bc3 clients/php: v0.2 multi-turn Session API
- Session class with try/finally pattern + Client::session(callable) block helper
- Idempotent Session::close (in-memory + server idempotent)
- __destruct best-effort close fallback (errors logged via error_log, never raised)
- Client::createSession / ::session / ::listSessions / ::getSession
- TurnResult::text() helper concatenating text events
- TurnEvent value object preserves tool_call / thinking / text uniformly
- SessionState value object for list/get responses
- ClosedSessionException for use-after-close guard
- Session::__debugInfo redacts forge back-reference (no bearer in var_dump)
- tests/SessionTest.php: 15 tests covering create/close/turn/idempotency/exception/list/state/text/destruct/regression
- README "Multi-turn / Sessions (v0.2)" section + try/finally + block-form
  + __destruct + __debugInfo notes; exception tree updated

v0.1 surface unchanged — 44 existing ClientTest tests still green, +15 new
v0.2 tests in SessionTest. Client.php diff is purely additive (285 lines
appended, zero v0.1 lines touched).

Naming follows the existing PHP SDK convention: Clawdforge\Client (not the
spec's generic Sulkta\Clawdforge\Forge), with the new types living in the
same Clawdforge\ namespace and Exception\ClosedSessionException slotted
under the existing ForgeException tree.

Spec: memory/spec-clawdforge-v0.2.md
Server core: 940861f
2026-04-29 06:51:17 -07:00

15 KiB

clawdforge — PHP SDK

PHP 8.2+ client for the 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:

composer require clawdforge/clawdforge

To install from a local checkout (e.g. while the SDK lives in this repo):

{
    "repositories": [
        { "type": "path", "url": "../path/to/clawdforge/clients/php" }
    ],
    "require": {
        "clawdforge/clawdforge": "*"
    }
}

Quickstart

<?php
require 'vendor/autoload.php';

use Clawdforge\Client;
use Clawdforge\RunRequest;
use Clawdforge\Exception\ForgeException;

$forge = new Client(
    baseUrl: 'http://localhost:8800',
    token: 'cf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
);

// Liveness + claude --version smoke check.
$health = $forge->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.

// 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. 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:

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:

$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

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

final readonly class TurnResult {
    public bool $ok;
    public string $sessionId;
    public int $turnIndex;
    public array $events;       // list<TurnEvent>
    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:

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:

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:

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:

'clawdforge' => [
    'url' => env('CLAWDFORGE_URL', 'http://localhost:8800'),
    'token' => env('CLAWDFORGE_TOKEN'),
],

Then inject anywhere:

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

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.

composer install
vendor/bin/phpunit

php -l over every source file:

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.