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. 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 https://prismphp.com , 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: php 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: php 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: php 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 : js '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: php 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.