- 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
436 lines
15 KiB
Markdown
436 lines
15 KiB
Markdown
# 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
|
|
<?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.
|
|
|
|
```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<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:
|
|
|
|
```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.
|