AI Tutor Developers
Markdown
# 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".
```