AI agents by framework / PHP

How to add an AI agent to a CodeIgniter app

An AI agent in a CodeIgniter 4 app is a controller that sends the conversation to a model with tool definitions, runs the tool calls the model makes against your models as the signed-in user, and streams the reply to the browser. CodeIgniter has no AI package of its own, so you write a short tool loop around a provider client such as openai-php/client and supply the tools, authorization and limits. If the agent only needs to do what your pages already do, you can embed one instead.

What an agent in a CodeIgniter app is made of

  • A server endpoint: a controller method on a POST route protected by filters, which reads the provider key from .env with env(). The key never goes into a view.
  • Tools: JSON Schema function definitions the model sees, and PHP that runs each call through your models, scoped to auth()->id().
  • The agent loop: written out in the controller. Call the model, run the tool calls it returns, send the results back, and repeat until it answers or a step cap is reached.
  • A chat UI: a PHP view and a small script that reads the event stream.
  • Authorization and confirmation: Shield's session filter and the csrf filter on the routes, ownership checks in every query, and a confirm endpoint the user triggers before data changes.

Ways to add an agent to CodeIgniter

ApproachGood forWhat you take on
A provider client (openai-php/client, anthropic-ai/sdk) with your own loopFull control in a small amount of code, with no framework integration to wait for.The tool loop, streaming, history, authorization in every tool, confirmation UI, rate and cost limits.
A framework-agnostic agent library (for example Neuron AI, or the Symfony AI Agent component on its own)A ready tool loop, provider switching and memory without writing them yourself.Another dependency and its conventions, plus authorization, UI and limits.
Hosted answer-only chat widgetQuestions about your docs or help center.Keeping its content current. It does not act in your app.
Embedded in-app agent (Bunkhouse)Letting signed-in users get tasks done through the pages and API you already have.A script tag, an identity endpoint and hints for operations discovery cannot see.

Build an agent with openai-php/client

openai-php/client is the community-maintained PHP client for the OpenAI API, including the Responses API with function tools and streaming. The examples below use version 0.22 with CodeIgniter 4.7 and Shield for sign-in. The client needs a PSR-18 HTTP client, so install Guzzle with it:

Terminal
composer require openai-php/client guzzlehttp/guzzle

Set OPENAI_API_KEY in .env. The model "gpt-6.1-sol" supports tool calling only through the Responses API, which is what the loop uses; see OpenAI's function calling guide for the request and response shapes.

1. The controller and the tool loop

Each step calls responses()->createStreamed() and forwards the text deltas to the browser. When a step completes, its function_call items are the tool calls: the controller runs each one for the signed-in user and sends the outputs back in the next step with previous_response_id, so OpenAI keeps the earlier items of the turn. The loop ends when a step makes no tool calls, or after MAX_STEPS. With strict schemas the API holds the model to the declared arguments, which is why the optional status is a required field that may be null.

cancel_order checks that the order is the user's and still open, then sends a confirm event instead of cancelling. CodeIgniter 4.7 has no streaming response class, so the controller closes the session first (session handlers lock it for the whole request), sends the headers, ends CodeIgniter's output buffer and flushes each event itself.

app/Controllers/Assistant.php
<?php

namespace App\Controllers;

use App\Models\OrderModel;
use OpenAI;

class Assistant extends BaseController
{
    private const MAX_STEPS = 5;

    private const TOOLS = [
        [
            'type'        => 'function',
            'name'        => 'list_orders',
            'description' => "List the signed-in user's recent orders",
            'strict'      => true,
            'parameters'  => [
                'type'                 => 'object',
                'properties'           => ['status' => ['type' => ['string', 'null'], 'enum' => ['open', 'shipped', 'cancelled', null]]],
                'required'             => ['status'],
                'additionalProperties' => false,
            ],
        ],
        [
            'type'        => 'function',
            'name'        => 'cancel_order',
            'description' => "Ask the signed-in user to confirm cancelling one of their open orders",
            'strict'      => true,
            'parameters'  => [
                'type'                 => 'object',
                'properties'           => ['order_id' => ['type' => 'integer']],
                'required'             => ['order_id'],
                'additionalProperties' => false,
            ],
        ],
    ];

    public function chat()
    {
        $userId = auth()->id(); // the route's session filter guarantees a signed-in user
        $input  = [];
        foreach ($this->request->getJSON(true)['messages'] ?? [] as $turn) {
            $input[] = ['role' => $turn['role'] === 'assistant' ? 'assistant' : 'user', 'content' => (string) $turn['text']];
        }

        // Release the session lock and CodeIgniter's output buffer before streaming.
        session()->close();
        $this->response->setContentType('text/event-stream')
            ->setHeader('Cache-Control', 'no-cache')
            ->setHeader('X-Accel-Buffering', 'no')
            ->sendHeaders();
        while (ob_get_level() > 0) {
            ob_end_flush();
        }
        $send = static function (string $event, mixed $data): void {
            echo "event: {$event}\ndata: " . json_encode($data) . "\n\n";
            flush();
        };

        $client   = OpenAI::client(env('OPENAI_API_KEY'));
        $previous = null;

        for ($step = 0; $step < self::MAX_STEPS; $step++) {
            $stream = $client->responses()->createStreamed([
                'model'        => 'gpt-6.1-sol',
                'instructions' => 'You help the signed-in user manage their orders. Use the tools; never guess order data.',
                'tools'        => self::TOOLS,
                'input'        => $input,
            ] + ($previous !== null ? ['previous_response_id' => $previous] : []));

            $calls = [];
            foreach ($stream as $event) {
                if ($event->event === 'response.output_text.delta') {
                    $send('text', $event->response->delta);
                } elseif ($event->event === 'response.completed') {
                    $previous = $event->response->response->id;
                    $calls    = array_filter($event->response->response->output, static fn ($item) => $item->type === 'function_call');
                }
            }
            if ($calls === []) {
                break;
            }

            // Run each tool call as the signed-in user and send the results back in the next step.
            $input = [];
            foreach ($calls as $call) {
                $result  = $this->runTool($call->name, json_decode($call->arguments, true), $userId, $send);
                $input[] = ['type' => 'function_call_output', 'call_id' => $call->callId, 'output' => json_encode($result)];
            }
        }

        return $this->response;
    }

    private function runTool(string $name, array $args, int|string $userId, callable $send): mixed
    {
        $orders = model(OrderModel::class)->where('user_id', $userId);

        if ($name === 'list_orders') {
            return $orders->when($args['status'], static fn ($query, $status) => $query->where('status', $status))
                ->orderBy('id', 'DESC')
                ->findAll(20);
        }

        if ($name === 'cancel_order' && $orders->where('status', 'open')->find($args['order_id']) !== null) {
            $send('confirm', ['orderId' => $args['order_id']]); // the UI asks the user; nothing is cancelled here

            return 'Asked the user to confirm the cancellation.';
        }

        return 'No open order with that ID.';
    }
}

2. The confirm endpoint and routes

The cancellation runs only when the user confirms it, through an ordinary POST that the session and CSRF filters protect like any other form. The query repeats the ownership and status checks, because the order ID comes from the browser.

app/Controllers/Orders.php
<?php

namespace App\Controllers;

use App\Models\OrderModel;

class Orders extends BaseController
{
    public function cancel(int $id)
    {
        $orders = model(OrderModel::class);
        $order  = $orders->where('user_id', auth()->id())->where('status', 'open')->find($id);
        if ($order === null) {
            return $this->response->setStatusCode(404);
        }

        $orders->update($id, ['status' => 'cancelled']);

        return $this->response->setJSON(['status' => 'cancelled']);
    }
}
app/Config/Routes.php (excerpt)
// Shield's session filter: only signed-in users reach the agent and the confirm endpoint.
$routes->post('assistant/chat', 'Assistant::chat', ['filter' => 'session']);
$routes->post('orders/(:num)/cancel', 'Orders::cancel/$1', ['filter' => 'session']);

Turn on the csrf filter in the $globals['before'] list of app/Config/Filters.php. It accepts the token in the X-CSRF-TOKEN header (Config\Security::$headerName). By default CodeIgniter regenerates the token on every submission, so a chat page that posts several times with one token fails after the first message: set $regenerate = false in app/Config/Security.php or send the new token with each response.

3. The chat view

The view renders the CSRF header name and token into the form, and the script reads the stream with fetch, because EventSource can only send GET requests. The script keeps the transcript and sends it with each message.

app/Views/assistant.php
<div id="agent-log" aria-live="polite"></div>
<form id="agent-form" data-csrf-header="<?= csrf_header() ?>" data-csrf="<?= csrf_hash() ?>">
  <input name="message" aria-label="Message" required>
</form>
<script src="/assistant.js" <?= csp_script_nonce() ?> defer><\/script>
public/assistant.js
const form = document.querySelector("#agent-form");
const log = document.querySelector("#agent-log");
const history = [];

const post = (url, body) =>
  fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json", [form.dataset.csrfHeader]: form.dataset.csrf },
    body: JSON.stringify(body),
  });

function confirmCancel(orderId) {
  const button = log.appendChild(document.createElement("button"));
  button.textContent = `Cancel order ${orderId}`;
  button.onclick = async () => {
    const response = await post(`/orders/${orderId}/cancel`, {});
    button.replaceWith(response.ok ? `Order ${orderId} cancelled.` : "The order was not cancelled.");
  };
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  history.push({ role: "user", text: form.elements.message.value });
  form.reset();
  const reply = log.appendChild(document.createElement("p"));
  const response = await post("/assistant/chat", { messages: history });
  const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";
  for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
    buffer += chunk.value;
    const events = buffer.split("\n\n");
    buffer = events.pop();
    for (const sse of events) {
      const type = sse.match(/^event: (.*)$/m)?.[1];
      const data = JSON.parse(sse.match(/^data: (.*)$/m)?.[1] ?? "null");
      if (type === "text") reply.textContent += data;
      if (type === "confirm") confirmCancel(data.orderId);
    }
  }
  history.push({ role: "assistant", text: reply.textContent });
});

Production checklist for CodeIgniter

  • Authorize in the filter and in every tool. Shield's session filter keeps guests out (see Shield controller filters); every query a tool or endpoint runs must still be scoped to auth()->id(). The transcript and the order IDs come from the browser, so treat them like any other request input.
  • Keep CSRF protection global. Leave the csrf filter on for every route, including the chat route, as described in CodeIgniter's Security guide.
  • Treat tool input and tool results as untrusted. Strict schemas fix the shape of the arguments, not their meaning. Check ownership and business rules in PHP, and keep irreversible changes behind a confirmation the user makes.
  • Limit cost. Keep MAX_STEPS low, set max_output_tokens on the request, rate-limit the chat route per user with the Throttler in a filter, and set spending limits with your model provider.
  • Allow for long streams. A streamed reply holds a PHP-FPM worker for its whole duration, so size the pool for concurrent chats and raise max_execution_time and the proxy timeouts. Close the session before streaming, as above, or the user's other requests wait for the lock.
  • Mind stored responses. previous_response_id relies on OpenAI storing responses, which is the default. If your data policy rules that out, set store to false and send the previous output items back as input instead.

Option: embed an in-app agent with Bunkhouse

If the agent's job is to help signed-in users do work your CodeIgniter app already supports, you may not need to build the tools, agent loop and chat UI yourself. Bunkhouse adds an Assistant pane to an existing web app with one script tag. When a user opens it, the assistant works with what the page already offers: buttons, links, form fields and tables, the API requests your app makes, a hints file you publish for operations it cannot see, and WebMCP tools your page declares.

The model writes small programs that run in a sandbox on the embed origin, not in your page. They can only call capabilities the assistant has discovered and your configuration allows. Write actions are shown to the user for review before they run, the model never sees credentials, and your CodeIgniter backend keeps enforcing its own authorization on every request.

In CodeIgniter, add the identity hook and the loader to the <head> of your layout view, before your application's own scripts, so they load once per document; csp_script_nonce() adds the nonce when Content Security Policy is on. For signed-in users, add a GET api/agent-identity route to a controller that signs a short-lived token for auth('session')->user(). Keep that route out of Shield's session filter so it answers 401 instead of redirecting to your login page, and keep the signing secret in a server environment variable.

app/Views/layout.php (excerpt)
<head>
    <?php // Before your application's own scripts ?>
    <script src="/agent-identity.js" <?= csp_script_nonce() ?>><\/script>
    <script src="https://embed.bunkhouse.ai/embed.js" <?= csp_script_nonce() ?>
        data-public-key="pk_YOUR_SITE_PUBLIC_KEY"><\/script>
</head>

The tested CodeIgniter integration guide has the complete code for each session mode (signed identity token, server mint and anonymous), CSRF headers and Content Security Policy settings.

When Bunkhouse fits, and when to build it yourself

  • Bunkhouse fits when users want tasks done inside your existing pages, such as filtering, editing or submitting through the controls and API you already have, and you would rather not write and maintain a tool definition for each action, a sandbox for model-written code, an approval UI and usage metering.
  • Build it yourself when the agent must run without a browser page (background jobs, email, chat platforms, scheduled work), when you need to choose the model provider, use your own keys or run inference on your own infrastructure, or when the conversation must be part of your own UI components.
  • Plan for the limits: discovery cannot see server-only routes, workflows the user has not visited, closed shadow roots or cross-origin frames (describe those in a hints file); some controls need a real user gesture; password, payment and file fields are never read, and you can mark other content private.
  • Cost: production use is $49 per month, including $20 of usage credit per paid billing period, then $1 per million input tokens and $3 per million output tokens. See billing.

Try it on your CodeIgniter app first

A coding agent such as Claude Code, Codex or Cursor can run a free, disposable agent trial on a dummy page of your CodeIgniter app. No account, card or model API key is needed: your coding agent acts as the assistant's model. A trial lasts 24 hours and allows 100 turns. Give your agent this instruction, changing the address if your app runs elsewhere:

Agent trialPrompt for your coding agent
Read https://api.bunkhouse.ai/agent-trial.md and follow it to run a Bunkhouse agent trial on my dummy CodeIgniter page at http://localhost:8080.

Use a page with disposable data: everything the assistant reads is sent to your agent.

The trial evaluation checklist shows how to test a read, a rejected write and an approved change, and what a trial does not prove about production use.