cd /news/developer-tools/i-turned-the-claude-code-cli-into-a-… · home topics developer-tools article
[ARTICLE · art-73165] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

I turned the claude code cli into a Prism provider

A Laravel developer built a custom Prism provider that routes LLM requests through the Claude Code CLI binary, enabling reuse of an existing Claude subscription for structured data extraction, text rewriting, and summarization tasks. The provider shells out to the Claude CLI, parses its JSON output, and maps it back to Prism's response objects, effectively turning the CLI into an API endpoint.

read7 min views1 publishedJul 25, 2026

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.

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): TextResponse

structured(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.

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.

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.

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.

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.

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.

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.

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.

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @prism 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/i-turned-the-claude-…] indexed:0 read:7min 2026-07-25 ·