# CodeIgniter 3: LMS integration

A step-by-step guide to connecting a **CodeIgniter 3** LMS (3.1.x, PHP 7.0+) to the AI Tutor chat. The code avoids newer PHP features, so it also runs on the PHP versions CodeIgniter 3 projects commonly use.

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-3.md` together with the prompt at the end of this guide.

## What you'll build

| File | Purpose |
| --- | --- |
| `application/config/ai_chat.php` | The chat's URL, your project API key and the signing secret |
| `application/controllers/Ai.php` | Checks every request's signature, and serves `GET ai/manifest` and `POST ai/tools/{name}` |
| `application/libraries/Ai_tools.php` | The tools: their manifest entries and one method each |
| `application/libraries/Ai_chat_client.php` | Opens chat sessions and signs learners out |
| `application/controllers/Ai_tutor.php` | The "AI Tutor" link learners tap |
| `application/config/routes.php`, `config.php` | Routes, and a CSRF exclusion |

## 1. Configuration

`application/config/ai_chat.php`. Read the values from the environment, so secrets stay out of your repository:

```php
<?php
defined('BASEPATH') OR exit('No direct script access allowed');

$config['ai_chat_url'] = getenv('AI_CHAT_URL') ?: 'https://aichat.tutorpro.in';
$config['ai_chat_api_key'] = getenv('AI_CHAT_API_KEY') ?: '';                // Project chat key: opens chat sessions
$config['ai_chat_signing_secret'] = getenv('AI_CHAT_SIGNING_SECRET') ?: '';  // Verifies requests from the chat
```

Set `AI_CHAT_API_KEY` and `AI_CHAT_SIGNING_SECRET` on the server, using the values the AI chat admin gives you. For example, with Apache: `SetEnv AI_CHAT_SIGNING_SECRET lms_...`. If your project keeps secrets in config files instead, put the values in this file and keep it out of version control.

## 2. Routes and CSRF

In `application/config/routes.php`:

```php
$route['ai/manifest']['GET'] = 'ai/manifest';
$route['ai/tools/(:any)']['POST'] = 'ai/tool/$1';

$route['ai-tutor'] = 'ai_tutor/open';
$route['ai-tutor/(:any)'] = 'ai_tutor/open/$1';
```

If `$config['csrf_protection']` is `TRUE` in `application/config/config.php`, exclude the chat's routes. The chat can't send a CSRF token; the signature protects these routes instead.

```php
$config['csrf_exclude_uris'] = array('ai/.*');
```

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.

## 3. The tools library

The tools are methods of one library. `definitions()` is the manifest, and each tool `x` has a method `tool_x()` that returns the `data` for the response.

`application/libraries/Ai_tools.php`:

```php
<?php
defined('BASEPATH') OR exit('No direct script access allowed');

/** An expected failure, sent to the chat as {"ok": false, "error": {"code": ...}}. */
class Ai_tool_exception extends Exception
{
    public $error_code;   // not_found, forbidden, invalid_arguments or unavailable
    public $status;

    public function __construct($error_code, $message = '', $status = 400)
    {
        parent::__construct($message);
        $this->error_code = $error_code;
        $this->status = $status;
    }
}

class Ai_tools
{
    protected $CI;

    public function __construct()
    {
        $this->CI =& get_instance();
        $this->CI->load->database();
    }

    /**
     * Every tool the chat may see; the admin still chooses which ones it uses. Leave "parameters"
     * out for tools without arguments: PHP would encode an empty array as a JSON list.
     */
    public function definitions()
    {
        return array(
            array(
                'name' => 'get_course_progress',
                'title' => 'Your progress',
                '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.",
                'personal' => TRUE,
                'cache_seconds' => 0,
                'display' => 'progress',
            ),
            array(
                'name' => 'list_upcoming_exams',
                'title' => 'Your exams',
                '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.',
                'parameters' => array(
                    'type' => 'object',
                    'properties' => array(
                        'limit' => array('type' => 'integer', 'minimum' => 1, 'maximum' => 10, 'description' => 'How many exams to return'),
                    ),
                ),
                'personal' => TRUE,
                'cache_seconds' => 300,
                'display' => 'exam_list',
            ),
            array(
                'name' => 'get_packages',
                'title' => 'Plans for you',
                'description' => 'Packages the learner can buy, with price, validity, features and checkout link. Use when they ask about prices, plans, upgrading or unlocking content.',
                'personal' => TRUE,
                'cache_seconds' => 300,
                'display' => 'package_cards',
            ),
        );
    }

    /** Runs a tool. $user_id is the learner from the chat session: only return their data. */
    public function call($name, $user_id, $course_id, $args)
    {
        foreach ($this->definitions() as $tool)
        {
            if ($tool['name'] !== $name)
            {
                continue;
            }
            if ($tool['personal'] && $user_id === NULL)
            {
                throw new Ai_tool_exception('forbidden', 'This tool needs a learner', 403);
            }
            return $this->{'tool_'.$name}($user_id, $course_id, $args);
        }
        throw new Ai_tool_exception('not_found', 'Unknown tool', 404);
    }

    // Example tools. Adapt the queries to your tables; keep the returned shapes.

    protected function tool_get_course_progress($user_id, $course_id, $args)
    {
        $rows = $this->CI->db
            ->select('c.id, c.title, e.progress_percent, e.completed_lessons, c.lessons_count, e.last_activity_at')
            ->from('enrollments e')
            ->join('courses c', 'c.id = e.course_id')
            ->where('e.user_id', $user_id)
            ->get()
            ->result_array();

        $courses = array();
        foreach ($rows as $row)
        {
            $courses[] = array(
                'name' => $row['title'],
                'percent' => (int) round($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']),
            );
        }
        return array('courses' => $courses);
    }

    protected function tool_list_upcoming_exams($user_id, $course_id, $args)
    {
        $limit = isset($args['limit']) ? min(10, max(1, (int) $args['limit'])) : 5;
        $now = date('Y-m-d H:i:s');

        $rows = $this->CI->db
            ->select('x.id, x.title, x.starts_at, x.duration_minutes, c.title AS course')
            ->from('exams x')
            ->join('courses c', 'c.id = x.course_id')
            ->join('enrollments e', 'e.course_id = x.course_id')
            ->where('e.user_id', $user_id)
            ->where('x.ends_at >', $now)
            ->order_by('x.starts_at', 'ASC')
            ->limit($limit)
            ->get()
            ->result_array();

        $exams = array();
        foreach ($rows as $row)
        {
            $exams[] = array(
                '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']),
            );
        }
        return array('exams' => $exams);
    }

    protected function tool_get_packages($user_id, $course_id, $args)
    {
        // Adapt: active packages this learner hasn't bought, priced for them.
        $rows = $this->CI->db
            ->from('packages')
            ->where('is_active', 1)
            ->where('id NOT IN (SELECT package_id FROM purchases WHERE user_id = '.$this->CI->db->escape($user_id).')', NULL, FALSE)
            ->order_by('sort_order', 'ASC')
            ->get()
            ->result_array();

        $packages = array();
        foreach ($rows as $row)
        {
            $features = json_decode((string) $row['features'], TRUE);
            $packages[] = array(
                'name' => $row['name'],
                'price' => (float) $row['price'],
                'currency' => 'INR',
                'original_price' => $row['mrp'] ? (float) $row['mrp'] : NULL,
                'validity' => $row['validity_months'].' months',
                'features' => is_array($features) ? $features : array(),
                'highlight' => (bool) $row['is_featured'],
                'checkout_url' => site_url('checkout/'.$row['id']),
            );
        }
        return array('packages' => $packages);
    }

    /** A database DATETIME as ISO 8601 with offset, as the chat expects. */
    protected function iso($value)
    {
        return $value ? date(DATE_ATOM, strtotime($value)) : NULL;
    }
}
```

`site_url()` needs the URL helper (autoload `url`). Links must be absolute `https://` URLs, so set `$config['base_url']` to your `https://` address.

## 4. The controller

The constructor checks the signature before any method runs. The chat signs each request with HMAC-SHA256 over `"<timestamp>.<raw body>"`, and anything unsigned, signed with the wrong secret, or older than 5 minutes is rejected.

`application/controllers/Ai.php`:

```php
<?php
defined('BASEPATH') OR exit('No direct script access allowed');

/** The AI Tutor chat's tools: GET ai/manifest and POST ai/tools/{name}. */
class Ai extends CI_Controller
{
    public function __construct()
    {
        parent::__construct();
        $this->config->load('ai_chat');

        if ( ! $this->_valid_signature())
        {
            $this->_json(array('ok' => FALSE, 'error' => array('code' => 'forbidden')), 401);
            $this->output->_display();
            exit;
        }
        $this->load->library('ai_tools');
    }

    public function manifest()
    {
        $this->_json(array('version' => 1, 'tools' => $this->ai_tools->definitions()));
    }

    public function tool($name)
    {
        $input = json_decode($this->input->raw_input_stream, TRUE);
        if ( ! is_array($input))
        {
            return $this->_error('invalid_arguments', 'The body must be a JSON object', 422);
        }
        $user_id = isset($input['user_id']) ? (string) $input['user_id'] : NULL;
        $course_id = isset($input['course_id']) ? (string) $input['course_id'] : NULL;
        $args = (isset($input['args']) && is_array($input['args'])) ? $input['args'] : array();

        try
        {
            $data = $this->ai_tools->call($name, $user_id, $course_id, $args);
            $this->_json(array('ok' => TRUE, 'data' => $data));
        }
        catch (Ai_tool_exception $e)
        {
            $this->_error($e->error_code, $e->getMessage(), $e->status);
        }
        catch (Throwable $e)
        {
            log_message('error', 'AI chat tool '.$name.' failed: '.$e->getMessage());
            $this->_error('unavailable', 'Temporarily unavailable', 503);
        }
    }

    private function _valid_signature()
    {
        $secret = (string) $this->config->item('ai_chat_signing_secret');
        $timestamp = (string) $this->input->get_request_header('X-AI-Chat-Timestamp');
        $signature = (string) $this->input->get_request_header('X-AI-Chat-Signature');
        // Sign the raw bytes: never re-encode the parsed JSON.
        $expected = 'v1='.hash_hmac('sha256', $timestamp.'.'.$this->input->raw_input_stream, $secret);

        return $secret !== ''
            && ctype_digit($timestamp)
            && abs(time() - (int) $timestamp) <= 300
            && hash_equals($expected, $signature);
    }

    private function _error($code, $message, $status)
    {
        $this->_json(array('ok' => FALSE, 'error' => array('code' => $code, 'message' => $message)), $status);
    }

    private function _json($data, $status = 200)
    {
        $this->output
            ->set_status_header($status)
            ->set_content_type('application/json', 'utf-8')
            ->set_output(json_encode($data));
    }
}
```

## 5. Opening the chat

`application/libraries/Ai_chat_client.php`:

```php
<?php
defined('BASEPATH') OR exit('No direct script access allowed');

class Ai_chat_client
{
    protected $CI;

    public function __construct()
    {
        $this->CI =& get_instance();
        $this->CI->config->load('ai_chat');
    }

    /** Opens a chat session for a learner and returns the URL to send them to. */
    public function chat_url($user_id, $course_id = NULL, $claims = array())
    {
        $body = array('user_id' => (string) $user_id, 'ttl_seconds' => 43200);
        if ($course_id !== NULL)
        {
            $body['course_id'] = (string) $course_id;
        }
        if ( ! empty($claims))
        {
            $body['claims'] = $claims;   // Leave out when empty: array() is not a JSON object.
        }

        list($status, $data) = $this->_request('POST', '/chat/sessions', $body);
        if ($status !== 201 || empty($data['chat_url']))
        {
            throw new RuntimeException('AI chat session failed: HTTP '.$status);
        }
        return $data['chat_url'];
    }

    /** Signs a learner out of every chat session. */
    public function revoke($user_id)
    {
        $this->_request('DELETE', '/chat/sessions?'.http_build_query(array('user_id' => (string) $user_id)));
    }

    private function _request($method, $path, $body = NULL)
    {
        $curl = curl_init(rtrim($this->CI->config->item('ai_chat_url'), '/').$path);
        $headers = array(
            'X-API-Key: '.$this->CI->config->item('ai_chat_api_key'),
            'Accept: application/json',
        );
        if ($body !== NULL)
        {
            $headers[] = 'Content-Type: application/json';
            curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($body));
        }
        curl_setopt_array($curl, array(
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => TRUE,
            CURLOPT_TIMEOUT => 10,
        ));

        $response = curl_exec($curl);
        if ($response === FALSE)
        {
            $error = curl_error($curl);
            curl_close($curl);
            throw new RuntimeException('AI chat unreachable: '.$error);
        }
        $status = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
        curl_close($curl);
        return array($status, json_decode($response, TRUE));
    }
}
```

`application/controllers/Ai_tutor.php`:

```php
<?php
defined('BASEPATH') OR exit('No direct script access allowed');

/** The "AI Tutor" link: opens the chat for the signed-in learner. */
class Ai_tutor extends CI_Controller
{
    public function open($course_id = NULL)
    {
        // Adapt: however your app stores the signed-in learner.
        $user_id = $this->session->userdata('user_id');
        if ( ! $user_id)
        {
            redirect('login');
        }

        $this->load->library('ai_chat_client');
        $url = $this->ai_chat_client->chat_url($user_id, $course_id, array(
            // Adapt: how your LMS knows a learner has paid.
            'plan' => $this->_has_paid($user_id) ? 'paid' : 'free',
        ));
        redirect($url);
    }

    private function _has_paid($user_id)
    {
        return $this->db->where('user_id', $user_id)->where('status', 'active')
            ->count_all_results('purchases') > 0;
    }
}
```

This needs the `session` library and `database` loaded or autoloaded. Link to `site_url('ai-tutor/'.$course_id)` 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 method 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

In your logout method, before destroying the session. Never let a chat failure block logging out:

```php
$user_id = $this->session->userdata('user_id');
if ($user_id)
{
    try
    {
        $this->load->library('ai_chat_client');
        $this->ai_chat_client->revoke($user_id);
    }
    catch (Throwable $e)
    {
        log_message('error', 'AI chat sign-out failed: '.$e->getMessage());
    }
}
```

## 7. Test it

CodeIgniter 3 has no built-in HTTP test client, so test with `curl` against your development server. Set `SECRET` to your signing secret and `LMS` to your base URL:

```bash
SECRET='lms_...'; LMS='http://localhost/your-lms'

sign() { printf '%s.%s' "$1" "$2" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //'; }

# 1. The manifest, signed: expect 200 and your tools.
TS=$(date +%s)
curl -s "$LMS/ai/manifest" -H "X-AI-Chat-Timestamp: $TS" -H "X-AI-Chat-Signature: v1=$(sign "$TS" '')"

# 2. Unsigned: expect 401.
curl -s -o /dev/null -w '%{http_code}\n' "$LMS/ai/manifest"

# 3. A tool, for learner 8812: expect {"ok":true,"data":{"courses":[...]}}.
TS=$(date +%s); BODY='{"user_id":"8812","course_id":null,"args":{}}'
curl -s "$LMS/ai/tools/get_course_progress" -H 'Content-Type: application/json' \
  -H "X-AI-Chat-Timestamp: $TS" -H "X-AI-Chat-Signature: v1=$(sign "$TS" "$BODY")" -d "$BODY"
```

Check each tool with two different learners, and confirm neither sees the other's records.

## 8. Go live

1. Deploy, and set `AI_CHAT_API_KEY` and `AI_CHAT_SIGNING_SECRET` on the server.
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-3.md (and https://aichat.tutorpro.in/developers/lms.md for the
full contract) and implement the AI Tutor chat integration in this CodeIgniter 3 app:
1. application/config/ai_chat.php reading AI_CHAT_URL, AI_CHAT_API_KEY, AI_CHAT_SIGNING_SECRET.
2. The Ai controller exactly as in the guide (signature check in the constructor), the ai/*
   routes, and ai/.* excluded from CSRF.
3. The Ai_tools library with 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 Ai_chat_client library, the Ai_tutor controller that opens the chat with claims
   {"plan": "free"|"paid"}, and revoking chat sessions in our logout method.
5. Match our PHP version and coding style (check composer.json and existing controllers).
Keep every tool read-only. Ask me before guessing business rules, such as who counts as "paid".
```
