AI agents

Fluent text generation with OpenAI, Anthropic and compatible local providers, including real HTTP streaming.

PhpOnWheels\Ai\Agent provides a fluent, immutable builder for text generation. OpenAI, Anthropic and OpenAI-compatible services share one interface. Each call owns its HTTP connection; prompts, credentials and conversation state are never cached globally. This is a text-generation API, not an autonomous tool-running agent.

Start here: a runnable PHP script

Use an installed phponwheels project with vendor/autoload.php. This example runs in the terminal, without publishing an endpoint or adding authentication. Enable ext-curl, then put your own key and an account-accessible model in .env:

.env

OPENAI_API_KEY=your-provider-key

OPENAI_MODEL=gpt-4

Create ask-ai.php beside composer.json:

PHP

<?php

declare(strict_types=1);

use PhpOnWheels\Ai\Agent;
use PhpOnWheels\Ai\Providers\OpenAI;
use PhpOnWheels\Config\Environment;

require __DIR__ . '/vendor/autoload.php';

$env = Environment::load(__DIR__ . '/.env');

$answer = Agent::using(OpenAI::class)
    ->withKey($env->required('OPENAI_API_KEY'))
    ->model($env->required('OPENAI_MODEL'))
    ->prompt('Explain request isolation in PHP in three short sentences.')
    ->maxTokens(256)
    ->timeout(30)
    ->send();

echo $answer . PHP_EOL;
Shell

php ask-ai.php

You should see generated text in the terminal, not a fixed answer. No web server or migration is needed for this script. Each run makes a real provider request and can incur costs. Use a non-sensitive prompt and do not commit your key. If the call fails, follow the error checklist; do not share raw credentials or provider responses in a bug report.

For Claude, switch the provider, key and model together. To print chunks instead of waiting for one answer, use the streaming variant. Framework configuration methods are typed; a global env() helper does not exist.

Configuration

Enable ext-curl and configure a provider key in your real environment or .env. Configuration is read at boot through Environment, not a global env() helper. Keep keys on the server. The framework does not contact a provider automatically.

.env

OPENAI_API_KEY=your-provider-key

# Only if you use Anthropic:

ANTHROPIC_API_KEY=your-provider-key

A live call can incur costs and sends your prompt to the configured provider. Do not send secrets or personal data without an appropriate data-handling policy and consent. Only synthetic, local provider responses are used by the framework tests; live model access and billing have not been verified here.

One answer

PHP

<?php
use PhpOnWheels\Ai\Agent;
use PhpOnWheels\Ai\Providers\OpenAI;
use PhpOnWheels\Config\Environment;

// $env is the injected Environment, loaded once at application boot.
$answer = Agent::using(OpenAI::class)
    ->withKey($env->required('OPENAI_API_KEY'))
    ->model('gpt-4')
    ->system('Be concise and distinguish verified facts from assumptions.')
    ->prompt('Explain the benefits of a worker-safe PHP framework.')
    ->maxTokens(1024)
    ->timeout(120)
    ->send();
// $answer is a string.

The model is explicit, not a framework default. Choose a model available to your provider account. OpenAI uses /v1/chat/completions, with max_completion_tokens; this supports the example's familiar chat interface, not every capability of OpenAI's Responses API. Parameter/model compatibility is the provider's responsibility.

Streaming in the CLI

Keep the autoloader and Environment::load() setup from ask-ai.php above. Replace its answer-generation and echo statements with this loop, choosing a model available to your account:

PHP

<?php
use PhpOnWheels\Ai\Agent;
use PhpOnWheels\Ai\Providers\OpenAI;

$stream = Agent::using(OpenAI::class)
    ->withKey($env->required('OPENAI_API_KEY'))
    ->model('gpt-4')
    ->prompt('Explain worker-safe request handling in PHP.')
    ->withStreaming()
    ->send();

foreach ($stream as $chunk) {
    echo $chunk;
    flush();
}

withStreaming() returns a StreamingAgent whose send() returns a Generator<int, string>. It does not issue a request until iteration starts. Chunks contain text only, not raw SSE/JSON or provider metadata. Chunks are arbitrary text fragments, not complete words or lines. Configuration methods belong before withStreaming(). You can also use $agent->stream() directly.

If iteration is abandoned, release the generator (for example unset($stream)) so its finally closes the upstream connection promptly. There is no automatic retry: retrying a partial generation could repeat text and incur another charge.

Live HTTP streaming

Inside a controller, return a response rather than echoing or calling flush(). The adapters own the output layer:

Generate a starting class with php pow make:controller Explain, then replace app/src/Controllers/ExplainController.php with the class below. The example demonstrates the response API; it does not register a route or authorize a caller. Before exposing it, wire it into your application's guarded routes and apply rate limits and a budget policy. Do not use a public generated API as an unrestricted AI proxy.

PHP

<?php
namespace App\Controllers;

use PhpOnWheels\Ai\Agent;
use PhpOnWheels\Ai\Providers\OpenAI;
use PhpOnWheels\Config\Environment;
use PhpOnWheels\Http\Response;
use PhpOnWheels\Http\ServerRequest;
use PhpOnWheels\Routing\Handler;

final class ExplainController implements Handler
{
    public function __construct(private readonly Environment $env) {}

    public function handle(ServerRequest $request): Response
    {
        return Agent::using(OpenAI::class)
            ->withKey($this->env->required('OPENAI_API_KEY'))
            ->model('gpt-4')
            ->prompt('Explain worker-safe PHP request handling.')
            ->withStreaming()
            ->response();
    }
}

This is an incremental text/plain; charset=utf-8 response. It sets Cache-Control: no-store and X-Accel-Buffering: no. The own server uses HTTP/1.1 chunk framing; FPM/Apache/FrankenPHP flush PHP's output layer. Browser clients can consume a fetch response's readable body. This is not browser EventSource framing; use a custom Response::stream() producer if you need SSE.

The first text fragment is obtained before committing response headers. An initial failure becomes an ordinary 500; failures after output starts are logged by exception class and terminate the stream. A partial response cannot retroactively become a JSON error or change its HTTP status. Never treat an incomplete stream as a completed answer.

The kernel keeps the request scope and temporary uploads alive through emission and closes the scope in finally, including when a client disconnects or an upstream fails. Complete authentication, cookies and session changes in the handler before returning the response. Session writes during lazy generation throw rather than being silently lost. Do not open a transaction across a long AI call; TransactionGuard also cleans transactions opened during stream iteration.

Configure your reverse proxy/CDN not to buffer this route, disable compression/output handlers that buffer, and allow suitable request timeouts. The web emitter supports at most one flushable PHP output buffer; nested/nonflushable buffers fail explicitly instead of silently buffering a stream. PHP/web-server configuration can still buffer despite application flush calls. The PHP On Wheels development server is sequential: one long generation occupies it until completion. Production streaming also occupies a worker. Authenticate and rate-limit any publicly accessible AI endpoint and enforce your own budget policy.

Request-logging middleware measures handler/pipeline time, not the full duration of stream consumption. The kernel logs later emission failures separately. Stream responses and their iterators are single-use; an emitter must consume synchronously, not save them for use after the scope closes.

Claude through Anthropic

Claude is supported by PhpOnWheels\Ai\Providers\Anthropic. In the runnable script, change the provider import and Agent::using() to Anthropic, and use ANTHROPIC_API_KEY and ANTHROPIC_MODEL instead of the OpenAI settings. Configure a model available to your Anthropic account, for example:

.env

ANTHROPIC_API_KEY=your-provider-key

ANTHROPIC_MODEL=claude-sonnet-4-5

The key and model calls become withKey($env->required('ANTHROPIC_API_KEY')) and model($env->required('ANTHROPIC_MODEL')). The same send() returns a string; streaming uses:

PHP

<?php
use PhpOnWheels\Ai\Agent;
use PhpOnWheels\Ai\Providers\Anthropic;

$stream = Agent::using(Anthropic::class)
    ->withKey($env->required('ANTHROPIC_API_KEY'))
    ->model('claude-sonnet-4-5')
    ->system('Answer clearly.')
    ->prompt('Explain request isolation.')
    ->maxTokens(1024)
    ->withStreaming()
    ->send();

Anthropic uses its Messages API, x-api-key, the pinned anthropic-version: 2023-06-01 contract and required max_tokens. The adapter extracts text blocks and text deltas. Thinking/signature deltas are not exposed as answer text. Tool calling, images, audio, embeddings, structured-output schemas, usage accounting and conversation persistence are not part of this API.

Compatible and local providers

PHP

<?php
use PhpOnWheels\Ai\Agent;
use PhpOnWheels\Ai\Providers\OpenAICompatible;

$answer = Agent::using(OpenAICompatible::class)
    ->withKey('local') // Placeholder only for a local server that ignores auth.
    ->endpoint('http://127.0.0.1:11434/v1/chat/completions')
    ->model('llama3.2')
    ->prompt('Explain dependency injection.')
    ->send();

OpenAICompatible uses the OpenAI chat/SSE shape and max_tokens. Its default endpoint is the loopback URL above. endpoint() takes the complete request URL, not a base URL. Remote endpoints require HTTPS with peer/host verification; plain HTTP is permitted only for literal loopback hosts. Redirects are disabled, and URLs must not contain credentials, query strings or fragments. Endpoint configuration is operator-controlled: never accept an arbitrary endpoint from user input.

Errors, limits and test doubles

  • Missing extension: check php -m for curl and enable it in the configuration reported by php --ini.
  • Missing key or model: verify the names in .env match the chosen provider. Real environment variables override the file. Do not print configuration values while diagnosing a failure.
  • Provider HTTP error: check account access, the exact model identifier, quota and billing in the provider's dashboard. No automatic fallback or retry is performed.
  • Delayed web output: return response(), not an echoed send() result, and check the buffering and timeout requirements.

Catch AiFailed for transport, HTTP, provider-response, truncation or event-size failures. Exception messages never contain provider response bodies, keys or prompts. Missing/invalid builder inputs throw InvalidArgumentException. A key is validated without silently falling back to another provider or environment variable. Agent/request/credential debug output is redacted and serialization is refused.

Default limits: 1 MiB for prompt plus system text, 16 MiB total provider response, 1 MiB per pending transport buffer/SSE event, 1024 output tokens and a 120-second timeout (configurable from 1 to 600 seconds). Each provider request uses a fresh connection. No retries, background jobs or provider calls at boot are added.

Agent::using() accepts a Provider class or instance. The interface supplies the endpoint, request body/headers and text/SSE decoding. withTransport(Transport $transport) supports isolated tests; a transport returns raw byte fragments and must enforce HTTP status, media type, size and timeout rules. Framework providers and builders are stateless/immutable. Custom providers/transports must not retain per-user request data when registered as singletons.