# CodeIgniter 4: LMS integration
A step-by-step guide to connecting a **CodeIgniter 4** LMS (4.4 or later, PHP 8.1+) to the AI Tutor chat.
This guide implements the contract in the [LMS integration guide](https://aichat.tutorpro.in/developers/lms). Read its Introduction first, and check it for the exact rules: manifest fields, display shapes and error codes. The code here is a working starting point. Wherever it says **Adapt**, replace the example queries with your own tables or models.
> **Using Claude Code?** Give it `https://aichat.tutorpro.in/developers/lms/codeigniter-4.md` together with the prompt at the end of this guide.
## What you'll build
| File | Purpose |
| --- | --- |
| `app/Config/AiChat.php` | The chat's URL, your project API key and the signing secret |
| `app/Filters/AiChatSignature.php` | Rejects any request that isn't signed by the chat |
| `app/Config/Filters.php`, `app/Config/Routes.php` | Registers the filter and the routes |
| `app/Libraries/AiChat/AiTool.php`, `AiToolException.php`, `ToolRegistry.php` | The base class for tools, and the list of tools |
| `app/Libraries/AiChat/Tools/*.php` | One class per tool: progress, exams, packages |
| `app/Controllers/AiChatTools.php` | Serves `GET /ai/manifest` and `POST /ai/tools/{name}` |
| `app/Libraries/AiChat/AiChatClient.php` | Opens chat sessions and signs learners out |
| `app/Controllers/AiTutor.php` | The "AI Tutor" link learners tap |
| `tests/feature/AiChatToolsTest.php` | Signature and tool tests |
## 1. Configuration
`app/Config/AiChat.php`:
```php
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class AiChat extends BaseConfig
{
public string $url = '';
public string $apiKey = ''; // Project chat key: opens chat sessions
public string $signingSecret = ''; // Verifies requests from the chat
}
```
Set the values in `.env`, using what the AI chat admin gives you. CodeIgniter maps `aichat.*` to this class:
```ini
aichat.url = https://aichat.tutorpro.in
aichat.apiKey = ak_...
aichat.signingSecret = lms_...
```
## 2. Verify every request
The chat signs each request with HMAC-SHA256 over `"<timestamp>.<raw body>"`. This filter rejects anything unsigned, signed with the wrong secret, or older than 5 minutes.
`app/Filters/AiChatSignature.php`:
```php
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
class AiChatSignature implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$secret = config('AiChat')->signingSecret;
$timestamp = $request->getHeaderLine('X-AI-Chat-Timestamp');
$signature = $request->getHeaderLine('X-AI-Chat-Signature');
// Sign the raw bytes: never re-encode the parsed JSON.
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . (string) $request->getBody(), $secret);
if ($secret === ''
|| ! ctype_digit($timestamp)
|| abs(time() - (int) $timestamp) > 300
|| ! hash_equals($expected, $signature)) {
return service('response')
->setStatusCode(401)
->setJSON(['ok' => false, 'error' => ['code' => 'forbidden']]);
}
return null;
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
return null;
}
}
```
In `app/Config/Filters.php`, add the alias:
```php
public array $aliases = [
// ...existing aliases
'aichat' => \App\Filters\AiChatSignature::class,
];
```
If the `csrf` filter runs globally, exclude the chat's routes. The chat can't send a CSRF token; the signature protects these routes instead.
```php
public array $globals = [
'before' => [
'csrf' => ['except' => ['ai/*']],
// ...
],
];
```
## 3. Tools
Each tool is a small class. Its properties form its manifest entry, and `handle()` returns the `data` for the response.
`app/Libraries/AiChat/AiToolException.php`:
```php
<?php
namespace App\Libraries\AiChat;
use RuntimeException;
/** An expected failure, sent to the chat as {"ok": false, "error": {"code": ...}}. */
class AiToolException extends RuntimeException
{
public function __construct(
public readonly string $errorCode, // not_found, forbidden, invalid_arguments or unavailable
string $message = '',
public readonly int $status = 400,
) {
parent::__construct($message);
}
}
```
`app/Libraries/AiChat/AiTool.php`:
```php
<?php
namespace App\Libraries\AiChat;
use DateTimeImmutable;
abstract class AiTool
{
/** Lowercase snake_case; the URL is /ai/tools/{name}. */
public string $name;
public string $title;
/** What the chat reads to decide when to call this tool. Say what it returns and when to use it. */
public string $description;
/** Arguments, as JSON Schema properties (string, integer, number or boolean). Empty: none. */
public array $parameters = [];
public array $required = [];
/** true: the result depends on the learner, who is given by $userId. */
public bool $personal = true;
public int $cacheSeconds = 0;
/** text, progress, exam_list, package_cards or table. */
public string $display = 'text';
/**
* Return the "data" for the chat. $userId is the learner from the chat session: only ever
* return this learner's data. Throw AiToolException for expected failures.
*/
abstract public function handle(?string $userId, ?string $courseId, array $args): array;
public function definition(): array
{
$definition = [
'name' => $this->name,
'title' => $this->title,
'description' => $this->description,
'personal' => $this->personal,
'cache_seconds' => $this->cacheSeconds,
'display' => $this->display,
];
// Leave "parameters" out when empty: PHP would encode [] as a JSON list, not an object.
if ($this->parameters !== []) {
$definition['parameters'] = ['type' => 'object', 'properties' => $this->parameters];
if ($this->required !== []) {
$definition['parameters']['required'] = $this->required;
}
}
return $definition;
}
/** A database DATETIME as ISO 8601 with offset, as the chat expects. */
protected function iso(?string $value): ?string
{
return $value ? (new DateTimeImmutable($value))->format(DATE_ATOM) : null;
}
}
```
`app/Libraries/AiChat/ToolRegistry.php`:
```php
<?php
namespace App\Libraries\AiChat;
class ToolRegistry
{
/** Every tool the chat may see. The admin still chooses which ones it uses. */
private const TOOLS = [
Tools\CourseProgress::class,
Tools\UpcomingExams::class,
Tools\Packages::class,
];
/** @return list<AiTool> */
public function all(): array
{
return array_map(static fn (string $class) => new $class(), self::TOOLS);
}
public function find(string $name): ?AiTool
{
foreach ($this->all() as $tool) {
if ($tool->name === $name) {
return $tool;
}
}
return null;
}
}
```
### Example tools
These use the Query Builder with example tables (`enrollments`, `courses`, `exams`, `packages`). **Adapt** the queries to your schema, or use your models; keep the returned shapes.
`app/Libraries/AiChat/Tools/CourseProgress.php` (display `progress`):
```php
<?php
namespace App\Libraries\AiChat\Tools;
use App\Libraries\AiChat\AiTool;
class CourseProgress extends AiTool
{
public string $name = 'get_course_progress';
public string $title = 'Your progress';
public string $description = "The learner's completion percentage, lessons done and next lesson for each enrolled course. Use when they ask about their progress, how much is left or what to study next.";
public string $display = 'progress';
public function handle(?string $userId, ?string $courseId, array $args): array
{
// Adapt: your enrollment and course tables.
$rows = db_connect()->table('enrollments e')
->select('c.id, c.title, e.progress_percent, e.completed_lessons, c.lessons_count, e.last_activity_at')
->join('courses c', 'c.id = e.course_id')
->where('e.user_id', $userId)
->get()
->getResultArray();
return [
'courses' => array_map(fn (array $row) => [
'name' => $row['title'],
'percent' => (int) round((float) $row['progress_percent']),
'completed_lessons' => (int) $row['completed_lessons'],
'total_lessons' => (int) $row['lessons_count'],
'last_activity' => $this->iso($row['last_activity_at']),
'url' => site_url('courses/' . $row['id']),
], $rows),
];
}
}
```
`app/Libraries/AiChat/Tools/UpcomingExams.php` (display `exam_list`, with an argument):
```php
<?php
namespace App\Libraries\AiChat\Tools;
use App\Libraries\AiChat\AiTool;
class UpcomingExams extends AiTool
{
public string $name = 'list_upcoming_exams';
public string $title = 'Your exams';
public string $description = 'Upcoming and live exams the learner can take, soonest first, with date, time and duration. Use when they ask about exams, tests, exam dates or their schedule.';
public array $parameters = [
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 10, 'description' => 'How many exams to return'],
];
public int $cacheSeconds = 300;
public string $display = 'exam_list';
public function handle(?string $userId, ?string $courseId, array $args): array
{
$limit = min(10, max(1, (int) ($args['limit'] ?? 5)));
$now = date('Y-m-d H:i:s');
// Adapt: exams of courses this learner is enrolled in that haven't ended.
$rows = db_connect()->table('exams x')
->select('x.id, x.title, x.starts_at, x.duration_minutes, c.title AS course')
->join('courses c', 'c.id = x.course_id')
->join('enrollments e', 'e.course_id = x.course_id')
->where('e.user_id', $userId)
->where('x.ends_at >', $now)
->orderBy('x.starts_at')
->limit($limit)
->get()
->getResultArray();
return [
'exams' => array_map(fn (array $row) => [
'title' => $row['title'],
'course' => $row['course'],
'starts_at' => $this->iso($row['starts_at']),
'duration_minutes' => (int) $row['duration_minutes'],
'status' => $row['starts_at'] <= $now ? 'live' : 'upcoming',
'url' => site_url('exams/' . $row['id']),
], $rows),
];
}
}
```
`app/Libraries/AiChat/Tools/Packages.php` (display `package_cards`):
```php
<?php
namespace App\Libraries\AiChat\Tools;
use App\Libraries\AiChat\AiTool;
class Packages extends AiTool
{
public string $name = 'get_packages';
public string $title = 'Plans for you';
public string $description = 'Packages the learner can buy, with price, validity, features and checkout link. Use when they ask about prices, plans, upgrading or unlocking content.';
public int $cacheSeconds = 300;
public string $display = 'package_cards';
public function handle(?string $userId, ?string $courseId, array $args): array
{
$db = db_connect();
// Adapt: active packages this learner hasn't bought, priced for them.
$bought = $db->table('purchases')->select('package_id')->where('user_id', $userId);
$rows = $db->table('packages')
->where('is_active', 1)
->whereNotIn('id', $bought)
->orderBy('sort_order')
->get()
->getResultArray();
return [
'packages' => array_map(static fn (array $row) => [
'name' => $row['name'],
'price' => (float) $row['price'],
'currency' => 'INR',
'original_price' => $row['mrp'] ? (float) $row['mrp'] : null,
'validity' => $row['validity_months'] . ' months',
'features' => json_decode($row['features'] ?? '[]', true) ?: [],
'highlight' => (bool) $row['is_featured'],
'checkout_url' => site_url('checkout/' . $row['id']),
], $rows),
];
}
}
```
Links must be absolute `https://` URLs. Make sure `app.baseURL` in `.env` starts with `https://`, so `site_url()` builds them that way.
## 4. Controller and routes
`app/Controllers/AiChatTools.php`:
```php
<?php
namespace App\Controllers;
use App\Libraries\AiChat\AiTool;
use App\Libraries\AiChat\AiToolException;
use App\Libraries\AiChat\ToolRegistry;
use CodeIgniter\HTTP\ResponseInterface;
use Throwable;
class AiChatTools extends BaseController
{
public function manifest(): ResponseInterface
{
$tools = array_map(static fn (AiTool $tool) => $tool->definition(), (new ToolRegistry())->all());
return $this->response->setJSON(['version' => 1, 'tools' => $tools]);
}
public function tool(string $name): ResponseInterface
{
$tool = (new ToolRegistry())->find($name);
if ($tool === null) {
return $this->error('not_found', 'Unknown tool', 404);
}
$input = json_decode((string) $this->request->getBody(), true);
if (! is_array($input)) {
return $this->error('invalid_arguments', 'The body must be a JSON object', 422);
}
$userId = isset($input['user_id']) ? (string) $input['user_id'] : null;
$courseId = isset($input['course_id']) ? (string) $input['course_id'] : null;
$args = is_array($input['args'] ?? null) ? $input['args'] : [];
if ($tool->personal && $userId === null) {
return $this->error('forbidden', 'This tool needs a learner', 403);
}
try {
return $this->response->setJSON(['ok' => true, 'data' => $tool->handle($userId, $courseId, $args)]);
} catch (AiToolException $exception) {
return $this->error($exception->errorCode, $exception->getMessage(), $exception->status);
} catch (Throwable $exception) {
log_message('error', 'AI chat tool {name} failed: {message}', [
'name' => $name, 'message' => $exception->getMessage(),
]);
return $this->error('unavailable', 'Temporarily unavailable', 503);
}
}
private function error(string $code, string $message, int $status): ResponseInterface
{
return $this->response
->setStatusCode($status)
->setJSON(['ok' => false, 'error' => ['code' => $code, 'message' => $message]]);
}
}
```
In `app/Config/Routes.php`:
```php
$routes->group('ai', ['filter' => 'aichat'], static function ($routes) {
$routes->get('manifest', 'AiChatTools::manifest');
$routes->post('tools/(:segment)', 'AiChatTools::tool/$1');
});
```
Your **base URL** for the admin is your site's base URL, such as `https://your-lms.example.com`. Include `/index.php` if your URLs still use it.
## 5. Opening the chat
`app/Libraries/AiChat/AiChatClient.php`:
```php
<?php
namespace App\Libraries\AiChat;
use CodeIgniter\HTTP\CURLRequest;
use Config\Services;
use RuntimeException;
class AiChatClient
{
/** Opens a chat session for a learner and returns the URL to send them to. */
public function chatUrl(string $userId, ?string $courseId = null, array $claims = []): string
{
$body = ['user_id' => $userId, 'ttl_seconds' => 43200];
if ($courseId !== null) {
$body['course_id'] = $courseId;
}
if ($claims !== []) {
$body['claims'] = $claims; // Leave out when empty: [] is not a JSON object.
}
$response = $this->http()->post('chat/sessions', ['json' => $body]);
if ($response->getStatusCode() !== 201) {
throw new RuntimeException('AI chat session failed: HTTP ' . $response->getStatusCode());
}
return json_decode($response->getBody(), true)['chat_url'];
}
/** Signs a learner out of every chat session. */
public function revoke(string $userId): void
{
$this->http()->delete('chat/sessions?' . http_build_query(['user_id' => $userId]));
}
private function http(): CURLRequest
{
$config = config('AiChat');
// A new (unshared) client each time, so options never leak between requests.
return Services::curlrequest([
'baseURI' => rtrim($config->url, '/') . '/',
'headers' => ['X-API-Key' => $config->apiKey, 'Accept' => 'application/json'],
'timeout' => 10,
'http_errors' => false,
], null, null, false);
}
}
```
`app/Controllers/AiTutor.php`:
```php
<?php
namespace App\Controllers;
use App\Libraries\AiChat\AiChatClient;
use CodeIgniter\HTTP\RedirectResponse;
class AiTutor extends BaseController
{
public function open(?string $course = null): RedirectResponse
{
// Adapt: auth()->id() with Shield, or however your app stores the signed-in learner.
$userId = (string) session('user_id');
if ($userId === '') {
return redirect()->to('login');
}
$url = (new AiChatClient())->chatUrl($userId, $course, [
// Adapt: how your LMS knows a learner has paid.
'plan' => model('PurchaseModel')->hasActive($userId) ? 'paid' : 'free',
]);
return redirect()->to($url);
}
}
```
In `app/Config/Routes.php`, behind your login filter:
```php
$routes->get('ai-tutor', 'AiTutor::open', ['filter' => 'session']);
$routes->get('ai-tutor/(:segment)', 'AiTutor::open/$1', ['filter' => 'session']);
```
Replace `session` with the name of your own login filter. Link to `site_url('ai-tutor/' . $courseId)` from lessons. The course must exist in the AI chat project under that ID; ask the admin to create courses with your IDs.
**Mobile apps:** add an authenticated API route that returns `['chat_url' => ...]` as JSON. The app then opens that URL in a webview. The chat page can't be embedded in an iframe.
## 6. Sign learners out with the LMS
Call `revoke()` when a learner logs out. Never let a chat failure block logging out.
```php
try {
(new \App\Libraries\AiChat\AiChatClient())->revoke((string) $userId);
} catch (\Throwable $exception) {
log_message('error', 'AI chat sign-out failed: ' . $exception->getMessage());
}
```
With **Shield**, run it from the `logout` event in `app/Config/Events.php`:
```php
Events::on('logout', static function ($user) {
try {
(new \App\Libraries\AiChat\AiChatClient())->revoke((string) $user->id);
} catch (\Throwable $exception) {
log_message('error', 'AI chat sign-out failed: ' . $exception->getMessage());
}
});
```
## 7. Tests
`tests/feature/AiChatToolsTest.php`:
```php
<?php
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\TestResponse;
final class AiChatToolsTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected function setUp(): void
{
parent::setUp();
config('AiChat')->signingSecret = 'test-secret';
}
/** Sends a request signed exactly as the chat signs it. */
private function signed(string $method, string $path, ?array $payload = null, ?int $timestamp = null): TestResponse
{
$body = $payload === null ? '' : json_encode($payload);
$timestamp = (string) ($timestamp ?? time());
return $this->withHeaders([
'X-AI-Chat-Timestamp' => $timestamp,
'X-AI-Chat-Signature' => 'v1=' . hash_hmac('sha256', $timestamp . '.' . $body, 'test-secret'),
'Content-Type' => 'application/json',
])->withBody($body)->call($method, $path);
}
public function testManifestListsTheTools(): void
{
$result = $this->signed('get', 'ai/manifest');
$result->assertOK();
$result->assertJSONFragment(['version' => 1]);
}
public function testUnsignedAndStaleRequestsAreRejected(): void
{
$this->call('get', 'ai/manifest')->assertStatus(401);
$this->signed('get', 'ai/manifest', null, time() - 600)->assertStatus(401);
}
public function testUnknownToolsReturnNotFound(): void
{
$result = $this->signed('post', 'ai/tools/nope', ['user_id' => '1', 'course_id' => null, 'args' => []]);
$result->assertStatus(404);
$result->assertJSONFragment(['error' => ['code' => 'not_found', 'message' => 'Unknown tool']]);
}
}
```
Add a test per tool, with `DatabaseTestTrait` and seeded learners, proving that one learner never sees another learner's records.
## 8. Go live
1. Deploy, and set the three `aichat.*` values in the server's `.env`.
2. Give the AI chat admin your base URL, such as `https://your-lms.example.com`.
3. The admin presses *Sync tools*, enables the tools, and uses *Test* with a real learner ID.
4. Open the chat through `/ai-tutor` as a test learner, and ask "How am I doing?" and "When is my next exam?".
If something fails, the admin panel's call log shows the error code. The [Troubleshooting](https://aichat.tutorpro.in/developers/lms#9-troubleshooting) table explains each one.
## Prompt for Claude Code
```text
Read https://aichat.tutorpro.in/developers/lms/codeigniter-4.md (and https://aichat.tutorpro.in/developers/lms.md for the
full contract) and implement the AI Tutor chat integration in this CodeIgniter 4 app:
1. Config\AiChat with url, apiKey and signingSecret from .env (aichat.*).
2. The AiChatSignature filter exactly as in the guide, registered as "aichat", with ai/* excluded
from CSRF.
3. AiTool, AiToolException and ToolRegistry, and tools get_course_progress, list_upcoming_exams,
get_exam_results (display "table") and get_packages, using OUR tables and models. Inspect
the codebase to find them; only ever return data for the given user_id.
4. The AiChatTools controller and the ai/* routes.
5. AiChatClient, an "AI Tutor" route that opens the chat with claims {"plan": "free"|"paid"},
and revoking chat sessions on logout.
6. Feature tests from the guide, plus one per tool proving learners can't see each other's data.
Keep every tool read-only. Ask me before guessing business rules, such as who counts as "paid".
```