so here is the thing. i work on a laravel app where almost every feature touch an llm. extract structured data from a pdf, rewrite a paragraph, summarize, spell check, generate stuff. all of it go through prism, which is nice because i just write Prism::text()->using('gemini', 'gemini-2.5-flash') and forget about http clients.
but the api bill is the api bill. and i already pay for claude code subscription every month, sitting there, doing nothing when i sleep.
then i realize: claude code cli is just a binary. it take stdin, it print json to stdout. that is basically an llm api with extra steps. so why not make prism talk to it?
this post is how i did it. the whole thing is one class.
what prism actually want from a provider
prism is not magic. a provider is a class extending Prism\Prism\Providers\Provider and implementing the methods you care about. you do not need to implement everything. i only need two:
text(TextRequest $request): TextResponsestructured(StructuredRequest $request): StructuredResponse
no embeddings, no images, no streaming. if some code call ->asStream() on my provider it will blow up, and that is fine, because nothing in my app do that.
the cli contract
first, check what the binary give back:
echo "say hi" | claude -p --output-format json --model claude-haiku-4-5
you get an envelope like this:
{
"is_error": false,
"result": "hi!",
"session_id": "abc-123",
"model": "claude-haiku-4-5",
"usage": {
"input_tokens": 12,
"output_tokens": 3,
"cache_read_input_tokens": 0
}
}
that is everything prism need. result is the text, usage map straight to prism Usage, session_id and model map to Meta. nice.
the provider
app/Prism/ClaudeCli/ClaudeCliProvider.php:
final class ClaudeCliProvider extends Provider
{
public function __construct(
private readonly string $binary,
private readonly ?string $oauthToken,
private readonly string $defaultModel,
private readonly int $timeout,
) {}
public function text(TextRequest $request): TextResponse
{
$envelope = $this->invoke(
$this->resolveModel($request->model()),
$this->composeSystemPrompt($request->systemPrompts()),
$this->composeUserPrompt($request->prompt(), $request->messages()),
$this->timeoutFor($request->clientOptions()),
);
return new TextResponse(
steps: new Collection,
text: $this->resultText($envelope),
finishReason: FinishReason::Stop,
toolCalls: [],
toolResults: [],
usage: $this->usage($envelope),
meta: $this->meta($envelope),
messages: new Collection,
);
}
}
that is the whole shape. take the prism request, flatten it to a string, shell out, build a prism response back. no ai, just plumbing.
shelling out without shooting your foot
this is the part where you must be careful:
private function invoke(string $model, string $systemPrompt, string $userPrompt, int $timeout): array
{
$command = [$this->binary, '-p', '--output-format', 'json', '--model', $model];
if (trim($systemPrompt) !== '') {
$command[] = '--append-system-prompt';
$command[] = $systemPrompt;
}
$result = Process::timeout($timeout)
->path(sys_get_temp_dir())
->env($this->processEnv())
->input($userPrompt)
->run($command);
if ($result->failed()) {
throw PrismException::providerResponseError(
'claude-cli exited with code '.$result->exitCode().': '.trim($result->errorOutput()),
);
}
$envelope = json_decode($result->output(), true);
if (! is_array($envelope)) {
throw PrismException::providerResponseError('claude-cli returned non-JSON output: '.$result->output());
}
if (($envelope['is_error'] ?? false) === true) {
throw PrismException::providerResponseError('claude-cli reported an error: '.($envelope['result'] ?? 'unknown'));
}
return $envelope;
}
four things here are not accidents:
command as array, not string. laravel Process with an array never touch a shell. so user input that contain ; rm -rf / is just text. if you build the command as one string you are one paste away from rce. do not do it.
user prompt go through stdin, not argv. prompts get long. argv have a size limit, stdin does not. also stdin is not visible in ps aux.
->path(sys_get_temp_dir()). claude cli read CLAUDE.md, .claude/settings.json, hooks, all from the working directory. if you run it inside your laravel project, your own project instructions leak into every user request. run it from an empty temp dir instead.
is_error check. cli can exit 0 and still say the request failed inside the envelope. exit code alone is not enough.
for auth i pass the subscription oauth token as env:
private function processEnv(): array
{
$env = ['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' => '1'];
if ($this->oauthToken !== null && $this->oauthToken !== '') {
$env['CLAUDE_CODE_OAUTH_TOKEN'] = $this->oauthToken;
}
return $env;
}
no api key anywhere. it use the subscription.
structured output when the provider have no structured mode
this is the fun part. gemini and openai have native json schema mode. the cli does not. so i fake it: take the prism schema, dump it into the prompt, then be paranoid when parsing.
public function structured(StructuredRequest $request): StructuredResponse
{
$userPrompt = $this->composeUserPrompt($request->prompt(), $request->messages())
.$this->schemaInstruction($request->schema()->toArray());
$envelope = $this->invoke(/* ... */);
$text = $this->resultText($envelope);
return new StructuredResponse(
steps: new Collection,
text: $text,
structured: $this->decodeJson($text),
finishReason: FinishReason::Stop,
usage: $this->usage($envelope),
meta: $this->meta($envelope),
);
}
private function schemaInstruction(array $schema): string
{
return "\n\nYou must respond with ONLY a single valid JSON object that conforms to the JSON schema below. "
.'Do not include markdown code fences, comments, or any prose before or after the JSON.'
."\n\nJSON schema:\n".json_encode($schema, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
the caller side never change. they still write ->withSchema($schema)->asStructured() and get an array. the hack live only inside the provider, which is exactly where a hack should live.
and the parser, three levels of giving up:
private function decodeJson(string $text): array
{
$candidate = trim($text);
if (preg_match('/```
(?:json)?\s*(.+?)\s*
```/s', $candidate, $matches) === 1) {
$candidate = $matches[1];
}
$decoded = json_decode($candidate, true);
if (! is_array($decoded)) {
$start = strpos($candidate, '{');
$end = strrpos($candidate, '}');
if ($start !== false && $end !== false && $end > $start) {
$decoded = json_decode(substr($candidate, $start, $end - $start + 1), true);
}
}
if (! is_array($decoded)) {
throw PrismException::providerResponseError('claude-cli did not return valid JSON: '.$text);
}
return $decoded;
}
first try raw. then strip code fences, because models love fences even when you say no fences. then cut from first { to last }, because sometimes you get "Sure! Here is the JSON:" in front. after that, give up loudly.
register it
prism use a manager, so extend() is all you need. do it in the boot of a service provider:
public function boot(): void
{
$this->app->make(PrismManager::class)->extend('claude-cli', fn (): ClaudeCliProvider => new ClaudeCliProvider(
binary: config('prism.claude_cli.binary'),
oauthToken: config('prism.claude_cli.oauth_token'),
defaultModel: config('prism.claude_cli.model'),
timeout: (int) config('prism.claude_cli.timeout'),
));
}
config in config/prism.php:
'claude_cli' => [
'binary' => env('CLAUDE_CLI_BINARY', 'claude'),
'oauth_token' => env('CLAUDE_CODE_OAUTH_TOKEN'),
'model' => env('CLAUDE_CLI_MODEL', 'claude-haiku-4-5'),
'timeout' => (int) env('CLAUDE_CLI_TIMEOUT', 120),
],
note the closure. under octane the container stay alive between request, so never resolve config in a constructor you register as singleton. closure is resolved per make, so it is safe.
the payoff: swap provider with one env var
every ai call in the app already look like this:
Prism::structured()
->using(config('prism.text.provider'), config('prism.text.model'))
->withSchema($schema)
->withPrompt($prompt)
->asStructured();
so switching from gemini to the subscription is:
PRISM_TEXT_PROVIDER=claude-cli
PRISM_TEXT_MODEL=claude-haiku-4-5
and rollback is flipping it back. no code deploy. this is the actual reason i use prism, not the fluent api.
resolveModel also protect me from myself:
private function resolveModel(string $requested): string
{
return str_starts_with($requested, 'claude') ? $requested : $this->defaultModel;
}
if some old code still pass gemini-2.5-flash to this provider, the cli would explode. instead it fall back to the configured default. small thing, save me one production incident already.
things that bite me
timeout chain. cli call is slow. like 30 to 90 second slow for a big prompt. you have a timeout in the provider, a timeout in the queue worker, a timeout in php-fpm, a timeout in nginx. the innermost one must be the smallest, and every outer one must be bigger. i learn this the hard way when request keep dying with no error in the log.
cli must exist in the container. it is a node binary. your php docker image probably do not have node. either install it in the image, or keep an api provider as fallback.
concurrency. each call is a real process with real memory. api call is cheap to fan out, process is not. if you run twenty in parallel your box will feel it.
no streaming, no tools. i do not implement them, so features that need token by token streaming still use the api provider. mixing is fine, that is the whole point of config per feature.
should you do this
if you are a solo dev or a small product and you already pay for the subscription, yes. it is one class, maybe 200 line, and it cut the inference cost to zero for the features where latency does not matter.
if you serve real traffic at scale, no. process spawning is not a load bearing architecture. use it as a canary, keep the api provider one env var away.
the bigger lesson is not about claude cli anyway. prism extend() mean anything that can take text and return text can become a provider. a local ollama, an internal company gateway, a mock that return fixture in test. you write one class, and the rest of your app never know the difference.
that is the good part.
Top comments (0)