PHP 8.2+ Guzzle-based client mirroring the Python SDK surface: - Client::healthz / run / uploadFile / createToken / listTokens / revokeToken - Readonly value objects: RunRequest, RunResult, FileToken, AppToken - Exception hierarchy: ForgeException (abstract) -> ApiException -> AuthException, plus TransportException - camelCase PHP <-> snake_case wire conversion at the boundary - Streamed multipart uploads via fopen($path, 'r') - Injectable GuzzleHttp\ClientInterface (MockHandler-friendly) - HTTP timeout = subprocess timeout + 30s margin - 15 PHPUnit tests, 61 assertions, no live network - README with Laravel + WordPress integration snippets - MIT license, no Sulkta-specific assumptions |
||
|---|---|---|
| .. | ||
| examples | ||
| src | ||
| tests | ||
| .gitignore | ||
| composer.json | ||
| phpunit.xml | ||
| README.md | ||
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
ClientInterfacefor 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, return a FileToken. |
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
$ft = $forge->uploadFile('./recipe.png', ttlSecs: 3600);
$out = $forge->run(new RunRequest(
prompt: 'extract recipe data',
files: [$ft->fileToken],
));
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 |
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)
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).
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]),
);
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(): 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 -pruns are not idempotent (they spawn subprocesses with side effects). Wrap calls in your own retry layer if you need one — Guzzle'sMiddleware::retryplugs 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
httpTimeoutMarginconstructor argument. - Streamed file uploads.
uploadFile()opens the file viafopen($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 inAppToken::$tokenexactly once.listTokens()always returnsnullfor that field — the server keeps only a sha256 hash. Persist immediately on mint.
License
MIT.