{"slug": "i-turned-the-claude-code-cli-into-a-prism-provider", "title": "I turned the claude code cli into a Prism provider", "summary": "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.", "body_md": "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')`\n\nand forget about http clients.\n\nbut the api bill is the api bill. and i already pay for claude code subscription every month, sitting there, doing nothing when i sleep.\n\nthen 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?\n\nthis post is how i did it. the whole thing is one class.\n\nprism is not magic. a provider is a class extending `Prism\\Prism\\Providers\\Provider`\n\nand implementing the methods you care about. you do not need to implement everything. i only need two:\n\n`text(TextRequest $request): TextResponse`\n\n`structured(StructuredRequest $request): StructuredResponse`\n\nno embeddings, no images, no streaming. if some code call `->asStream()`\n\non my provider it will blow up, and that is fine, because nothing in my app do that.\n\nfirst, check what the binary give back:\n\n```\necho \"say hi\" | claude -p --output-format json --model claude-haiku-4-5\n```\n\nyou get an envelope like this:\n\n```\n{\n  \"is_error\": false,\n  \"result\": \"hi!\",\n  \"session_id\": \"abc-123\",\n  \"model\": \"claude-haiku-4-5\",\n  \"usage\": {\n    \"input_tokens\": 12,\n    \"output_tokens\": 3,\n    \"cache_read_input_tokens\": 0\n  }\n}\n```\n\nthat is everything prism need. `result`\n\nis the text, `usage`\n\nmap straight to prism `Usage`\n\n, `session_id`\n\nand `model`\n\nmap to `Meta`\n\n. nice.\n\n`app/Prism/ClaudeCli/ClaudeCliProvider.php`\n\n:\n\n```\nfinal class ClaudeCliProvider extends Provider\n{\n    public function __construct(\n        private readonly string $binary,\n        private readonly ?string $oauthToken,\n        private readonly string $defaultModel,\n        private readonly int $timeout,\n    ) {}\n\n    public function text(TextRequest $request): TextResponse\n    {\n        $envelope = $this->invoke(\n            $this->resolveModel($request->model()),\n            $this->composeSystemPrompt($request->systemPrompts()),\n            $this->composeUserPrompt($request->prompt(), $request->messages()),\n            $this->timeoutFor($request->clientOptions()),\n        );\n\n        return new TextResponse(\n            steps: new Collection,\n            text: $this->resultText($envelope),\n            finishReason: FinishReason::Stop,\n            toolCalls: [],\n            toolResults: [],\n            usage: $this->usage($envelope),\n            meta: $this->meta($envelope),\n            messages: new Collection,\n        );\n    }\n}\n```\n\nthat is the whole shape. take the prism request, flatten it to a string, shell out, build a prism response back. no ai, just plumbing.\n\nthis is the part where you must be careful:\n\n```\nprivate function invoke(string $model, string $systemPrompt, string $userPrompt, int $timeout): array\n{\n    $command = [$this->binary, '-p', '--output-format', 'json', '--model', $model];\n\n    if (trim($systemPrompt) !== '') {\n        $command[] = '--append-system-prompt';\n        $command[] = $systemPrompt;\n    }\n\n    $result = Process::timeout($timeout)\n        ->path(sys_get_temp_dir())\n        ->env($this->processEnv())\n        ->input($userPrompt)\n        ->run($command);\n\n    if ($result->failed()) {\n        throw PrismException::providerResponseError(\n            'claude-cli exited with code '.$result->exitCode().': '.trim($result->errorOutput()),\n        );\n    }\n\n    $envelope = json_decode($result->output(), true);\n\n    if (! is_array($envelope)) {\n        throw PrismException::providerResponseError('claude-cli returned non-JSON output: '.$result->output());\n    }\n\n    if (($envelope['is_error'] ?? false) === true) {\n        throw PrismException::providerResponseError('claude-cli reported an error: '.($envelope['result'] ?? 'unknown'));\n    }\n\n    return $envelope;\n}\n```\n\nfour things here are not accidents:\n\n**command as array, not string.** laravel `Process`\n\nwith an array never touch a shell. so user input that contain `; rm -rf /`\n\nis just text. if you build the command as one string you are one paste away from rce. do not do it.\n\n**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`\n\n.\n\n** ->path(sys_get_temp_dir()).** claude cli read\n\n`CLAUDE.md`\n\n, `.claude/settings.json`\n\n, 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.\n\nfor auth i pass the subscription oauth token as env:\n\n``` php\nprivate function processEnv(): array\n{\n    $env = ['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC' => '1'];\n\n    if ($this->oauthToken !== null && $this->oauthToken !== '') {\n        $env['CLAUDE_CODE_OAUTH_TOKEN'] = $this->oauthToken;\n    }\n\n    return $env;\n}\n```\n\nno api key anywhere. it use the subscription.\n\nthis 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.\n\n```\npublic function structured(StructuredRequest $request): StructuredResponse\n{\n    $userPrompt = $this->composeUserPrompt($request->prompt(), $request->messages())\n        .$this->schemaInstruction($request->schema()->toArray());\n\n    $envelope = $this->invoke(/* ... */);\n    $text = $this->resultText($envelope);\n\n    return new StructuredResponse(\n        steps: new Collection,\n        text: $text,\n        structured: $this->decodeJson($text),\n        finishReason: FinishReason::Stop,\n        usage: $this->usage($envelope),\n        meta: $this->meta($envelope),\n    );\n}\n\nprivate function schemaInstruction(array $schema): string\n{\n    return \"\\n\\nYou must respond with ONLY a single valid JSON object that conforms to the JSON schema below. \"\n        .'Do not include markdown code fences, comments, or any prose before or after the JSON.'\n        .\"\\n\\nJSON schema:\\n\".json_encode($schema, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);\n}\n```\n\nthe caller side never change. they still write `->withSchema($schema)->asStructured()`\n\nand get an array. the hack live only inside the provider, which is exactly where a hack should live.\n\nand the parser, three levels of giving up:\n\n``` php\nprivate function decodeJson(string $text): array\n{\n    $candidate = trim($text);\n\n    if (preg_match('/```\n\n(?:json)?\\s*(.+?)\\s*\n\n```/s', $candidate, $matches) === 1) {\n        $candidate = $matches[1];\n    }\n\n    $decoded = json_decode($candidate, true);\n\n    if (! is_array($decoded)) {\n        $start = strpos($candidate, '{');\n        $end = strrpos($candidate, '}');\n\n        if ($start !== false && $end !== false && $end > $start) {\n            $decoded = json_decode(substr($candidate, $start, $end - $start + 1), true);\n        }\n    }\n\n    if (! is_array($decoded)) {\n        throw PrismException::providerResponseError('claude-cli did not return valid JSON: '.$text);\n    }\n\n    return $decoded;\n}\n```\n\nfirst try raw. then strip code fences, because models love fences even when you say no fences. then cut from first `{`\n\nto last `}`\n\n, because sometimes you get \"Sure! Here is the JSON:\" in front. after that, give up loudly.\n\nprism use a manager, so `extend()`\n\nis all you need. do it in the boot of a service provider:\n\n``` php\npublic function boot(): void\n{\n    $this->app->make(PrismManager::class)->extend('claude-cli', fn (): ClaudeCliProvider => new ClaudeCliProvider(\n        binary: config('prism.claude_cli.binary'),\n        oauthToken: config('prism.claude_cli.oauth_token'),\n        defaultModel: config('prism.claude_cli.model'),\n        timeout: (int) config('prism.claude_cli.timeout'),\n    ));\n}\n```\n\nconfig in `config/prism.php`\n\n:\n\n``` js\n'claude_cli' => [\n    'binary' => env('CLAUDE_CLI_BINARY', 'claude'),\n    'oauth_token' => env('CLAUDE_CODE_OAUTH_TOKEN'),\n    'model' => env('CLAUDE_CLI_MODEL', 'claude-haiku-4-5'),\n    'timeout' => (int) env('CLAUDE_CLI_TIMEOUT', 120),\n],\n```\n\nnote 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.\n\nevery ai call in the app already look like this:\n\n``` php\nPrism::structured()\n    ->using(config('prism.text.provider'), config('prism.text.model'))\n    ->withSchema($schema)\n    ->withPrompt($prompt)\n    ->asStructured();\n```\n\nso switching from gemini to the subscription is:\n\n```\nPRISM_TEXT_PROVIDER=claude-cli\nPRISM_TEXT_MODEL=claude-haiku-4-5\n```\n\nand rollback is flipping it back. no code deploy. this is the actual reason i use prism, not the fluent api.\n\n`resolveModel`\n\nalso protect me from myself:\n\n```\nprivate function resolveModel(string $requested): string\n{\n    return str_starts_with($requested, 'claude') ? $requested : $this->defaultModel;\n}\n```\n\nif some old code still pass `gemini-2.5-flash`\n\nto this provider, the cli would explode. instead it fall back to the configured default. small thing, save me one production incident already.\n\n**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.\n\n**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.\n\n**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.\n\n**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.\n\nif 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.\n\nif 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.\n\nthe bigger lesson is not about claude cli anyway. prism `extend()`\n\nmean 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.\n\nthat is the good part.", "url": "https://wpnews.pro/news/i-turned-the-claude-code-cli-into-a-prism-provider", "canonical_source": "https://dev.to/kevariable/i-turned-the-claude-code-cli-into-a-prism-provider-25bk", "published_at": "2026-07-25 08:57:35+00:00", "updated_at": "2026-07-25 09:33:56.875867+00:00", "lang": "en", "topics": ["developer-tools", "large-language-models", "ai-tools"], "entities": ["Prism", "Claude Code", "Laravel", "Gemini"], "alternates": {"html": "https://wpnews.pro/news/i-turned-the-claude-code-cli-into-a-prism-provider", "markdown": "https://wpnews.pro/news/i-turned-the-claude-code-cli-into-a-prism-provider.md", "text": "https://wpnews.pro/news/i-turned-the-claude-code-cli-into-a-prism-provider.txt", "jsonld": "https://wpnews.pro/news/i-turned-the-claude-code-cli-into-a-prism-provider.jsonld"}}