# 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".
```