# LMS integration guide

Connect your LMS to the AI Tutor chat, so learners can ask about their own records, such as course progress, exam dates or the packages they can buy, and get answers inside the chat.

> **Using Claude Code or another AI assistant?** Give it this page's Markdown version: `https://aichat.tutorpro.in/developers/lms.md`. The prompt in [Implement it with Claude Code](#implement-it-with-claude-code) does the rest.

> **Using Laravel or CodeIgniter?** Follow the step-by-step guide for your framework, with ready-to-use code: [Laravel](https://aichat.tutorpro.in/developers/lms/laravel), [CodeIgniter 4](https://aichat.tutorpro.in/developers/lms/codeigniter-4) or [CodeIgniter 3](https://aichat.tutorpro.in/developers/lms/codeigniter-3). This page stays the reference for the exact rules.

## Introduction

### What the AI Tutor chat is

The AI Tutor chat is the AI teacher your learners open from inside your LMS. Each LMS project has its own space in the chat, with its own courses, study material, branding and API keys. Your LMS already opens it for a learner by creating a **chat session** with the project API key. Until now, the chat could only answer questions about subject matter, from the course material uploaded to it.

### What this integration adds

Learners also ask about **themselves**, and only your LMS knows those answers. With this integration, the chat can answer them from your LMS's live data:

| A learner asks | The chat shows |
| --- | --- |
| "How much of Physics have I finished?" | Their progress per course, with the next lesson |
| "When is my next exam?" | Their upcoming exams, with dates and times |
| "How did I do in the last test?" | Their recent scores |
| "How can I unlock all the courses?" | The packages they can buy, with prices and a checkout link |

Each answer is a short, friendly reply with **cards** below it. The cards show your data exactly as you sent it. Subject questions such as "Explain Newton's third law" are still answered from the course material, as before.

### Who does what

| Role | Responsibilities |
| --- | --- |
| **LMS developers** (you) | Add two read-only endpoints that return learner data as JSON, verify that requests really come from the chat, and send learner facts such as the plan when opening a chat session. |
| **AI chat admin** | Connects your LMS to the project, gives you the signing secret, chooses which of your tools the chat may use, and tests them. |
| **AI chat** | Decides when a question needs your data, calls your endpoints for the learner in the session, draws the cards and writes the reply. |

Every LMS project uses the same contract, so this guide applies whatever your stack is. Projects differ only in which tools they publish: start with the data you have, and add tools later.

### What you need

- Access to your LMS backend code, and a way to look up a learner's data by the same `user_id` you send when you open chat sessions.
- An HTTPS address for the LMS that the chat server can reach.
- From the AI chat admin: the **signing secret** for your project, and a **project chat key** for opening chat sessions.

The build itself is small: one middleware, one manifest endpoint and one handler per tool.

### Framework guides

This page defines the contract for any stack. For these frameworks there is also a step-by-step guide with complete code, from configuration to tests:

| Framework | Guide | For AI assistants |
| --- | --- | --- |
| Laravel 10, 11, 12 | [Laravel guide](https://aichat.tutorpro.in/developers/lms/laravel) | `https://aichat.tutorpro.in/developers/lms/laravel.md` |
| CodeIgniter 4.4+ | [CodeIgniter 4 guide](https://aichat.tutorpro.in/developers/lms/codeigniter-4) | `https://aichat.tutorpro.in/developers/lms/codeigniter-4.md` |
| CodeIgniter 3.1 | [CodeIgniter 3 guide](https://aichat.tutorpro.in/developers/lms/codeigniter-3) | `https://aichat.tutorpro.in/developers/lms/codeigniter-3.md` |

## How it works

```
Learner asks "When is my next exam?"
   │
   ▼
AI chat ── 1. The planner picks a tool from your manifest
   │       2. It calls your LMS, signed, with the learner's user_id from the session
   ▼
POST {your LMS}/ai/tools/list_upcoming_exams  →  {"ok": true, "data": {"exams": [...]}}
   │
   ▼
Exam cards drawn from your JSON + a short reply checked against it
```

- **You decide what data exists.** Your manifest lists the tools; projects with more data publish more tools.
- **The model never chooses whose data is read.** `user_id` and `course_id` come from the chat session your server opened. They are never tool arguments.
- **Facts are shown as you sent them.** Prices, dates and links in cards come straight from your JSON; the model only writes the sentence around them.
- **Read-only.** Tools must never change data. Purchases happen on your checkout page.

## Checklist

1. Get a **signing secret** from the AI chat admin (project → *LMS integration* → *Generate secret*). Store it in your LMS config, for example `AI_CHAT_SIGNING_SECRET`.
2. Add a middleware that [verifies the signature](#1-verify-every-request) on every `/ai/*` request.
3. Serve [`GET /ai/manifest`](#2-the-manifest) listing your tools.
4. Serve [`POST /ai/tools/{name}`](#3-tool-endpoints) for each tool, returning one of the [display shapes](#4-display-types-and-data-shapes).
5. Open the chat with a [session token](#5-opening-the-chat-session-tokens) created by your server, including [claims](#claims) such as `{"plan": "free"}`.
6. Give the admin your **base URL** (the part before `/ai/…`, such as `https://lms.example.com/api`). They save it, press *Sync tools*, enable the tools and test them.

## 1. Verify every request

Every request from the chat carries these headers:

| Header | Value |
| --- | --- |
| `X-AI-Chat-Timestamp` | Unix time in seconds when the request was signed |
| `X-AI-Chat-Signature` | `v1=` followed by the hex HMAC-SHA256 of `"<timestamp>.<raw body>"`, keyed with your signing secret |
| `X-AI-Chat-Project` | The AI chat project ID, for logging |
| `Content-Type` | `application/json` on POST requests |

`GET /ai/manifest` has an empty body, so its signed message is `"<timestamp>."`.

Reject the request with **401** unless all of these hold:

1. The signature matches. Compute it over the **raw request bytes**, before any JSON parsing, and compare in constant time.
2. The timestamp is within **5 minutes** of your server's clock.

Only after this check may you trust `user_id` and `course_id` in the body.

**PHP (Laravel middleware)**

```php
public function handle(Request $request, Closure $next)
{
    $secret = config('services.ai_chat.signing_secret');
    $timestamp = $request->header('X-AI-Chat-Timestamp', '');
    $expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $request->getContent(), $secret);

    if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300
        || !hash_equals($expected, (string) $request->header('X-AI-Chat-Signature'))) {
        return response()->json(['ok' => false, 'error' => ['code' => 'forbidden']], 401);
    }
    return $next($request);
}
```

**Node.js (Express)**

```js
const crypto = require("crypto");
app.use("/ai", express.raw({ type: "*/*" }), (req, res, next) => {
  const timestamp = req.get("X-AI-Chat-Timestamp") || "";
  const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
  const expected = "v1=" + crypto.createHmac("sha256", process.env.AI_CHAT_SIGNING_SECRET)
    .update(`${timestamp}.`).update(body).digest("hex");
  const given = req.get("X-AI-Chat-Signature") || "";
  const fresh = /^\d+$/.test(timestamp) && Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
  if (!fresh || given.length !== expected.length
      || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return res.status(401).json({ ok: false, error: { code: "forbidden" } });
  }
  req.aiChat = body.length ? JSON.parse(body) : {};
  next();
});
```

**Python**

```python
import hashlib, hmac, time


def verify(secret: str, timestamp: str, signature: str, raw_body: bytes) -> bool:
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False
    mac = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256)
    return hmac.compare_digest("v1=" + mac.hexdigest(), signature)
```

When the admin rotates the secret, calls signed with the old one fail until you update your config.

## 2. The manifest

`GET {base_url}/ai/manifest` returns the tools the chat may call:

```json
{
  "version": 1,
  "tools": [
    {
      "name": "list_upcoming_exams",
      "title": "Upcoming exams",
      "description": "Published exams the learner can take, with date, time and duration, soonest first. Use when they ask about exams, tests, exam dates or their schedule.",
      "parameters": {
        "type": "object",
        "properties": {
          "limit": { "type": "integer", "minimum": 1, "maximum": 10, "description": "How many exams to return" }
        }
      },
      "personal": true,
      "cache_seconds": 300,
      "display": "exam_list"
    }
  ]
}
```

| Field | Rules |
| --- | --- |
| `version` | Always `1`. |
| `name` | Lowercase snake_case, starting with a letter, at most 64 characters, unique. It becomes the URL: `/ai/tools/{name}`. |
| `title` | Shown on the card and in the admin panel. At most 100 characters. |
| `description` | What the model reads to decide when to call the tool. At most 1000 characters. See [Writing good tools](#6-writing-good-tools). |
| `parameters` | Optional. A JSON Schema **subset**: `type: "object"`, up to 10 `properties`, optional `required`. Each property has `type` (`string`, `integer`, `number` or `boolean`) and optionally `description`, `enum`, `minimum`, `maximum`, `maxLength`, `format` (`date` or `date-time`). No nested objects or arrays. |
| `personal` | Default `true`: the result depends on the learner. Personal tools only run in a chat session with a learner. Set `false` for data that is the same for everyone, such as a public price list. |
| `cache_seconds` | 0–3600, default 0. How long the chat may reuse a result for the same learner, course and arguments. |
| `display` | `text`, `progress`, `exam_list`, `package_cards` or `table`. Decides how the card is drawn; see the [shapes](#4-display-types-and-data-shapes). |

**PHP and empty objects:** PHP encodes an empty array as `[]`, a JSON list, so `"properties": []` is rejected. Leave `parameters` out for tools without arguments, or use `(object) []`. The same applies to `claims` when you open a session.

**Reserved names:** `user_id`, `course_id` and `project_id` may not be parameters. The chat sends the first two with every call, taken from the session. A manifest that declares them is rejected.

The admin sees the manifest only when they press *Sync tools*. New tools start disabled until the admin enables them, so publishing a tool is safe.

## 3. Tool endpoints

`POST {base_url}/ai/tools/{name}`, signed as above, with this body:

```json
{ "user_id": "8812", "course_id": "physics-12", "args": { "limit": 5 } }
```

| Field | Meaning |
| --- | --- |
| `user_id` | The learner from the chat session your server created. `null` for non-personal tools called without a learner. |
| `course_id` | The course the learner is chatting in, or `null` for project-wide chat. |
| `args` | Arguments for your parameters, already checked against your schema. May be `{}`. |

Look up data **for this `user_id` only**, the same way your own app would for that logged-in learner.

**Success**, with HTTP 200:

```json
{ "ok": true, "data": { "exams": [ ... ] } }
```

**Failure**:

```json
{ "ok": false, "error": { "code": "not_found", "message": "No such course" } }
```

| `error.code` | Use when |
| --- | --- |
| `not_found` | The learner, course or record doesn't exist. |
| `forbidden` | The signature failed, or the learner may not see this. |
| `invalid_arguments` | The arguments make no sense for your data. |
| `unavailable` | Anything temporary. The chat tells the learner to try again later. |

Limits: answer within **5 seconds**, keep responses under **50 KB**, and don't redirect. Return times in ISO 8601 with an offset, such as `2026-10-12T10:00:00+05:30`.

## 4. Display types and data shapes

The chat draws each tool's `data` as a card. Use the shape for your tool's `display`. Fields marked *optional* may be left out, and extra fields are ignored. Links must be `https://` and open in a new tab. At most 20 items are shown.

### `progress`

```json
{
  "courses": [
    {
      "name": "Physics",
      "percent": 64,
      "completed_lessons": 18,
      "total_lessons": 28,
      "next_lesson": "Chapter 7: Waves",
      "last_activity": "2026-10-07T18:30:00+05:30",
      "url": "https://lms.example.com/courses/physics"
    }
  ]
}
```

`name` and `percent` (0–100) are required; the rest are optional.

### `exam_list`

```json
{
  "exams": [
    {
      "title": "Unit test 3",
      "course": "Physics",
      "starts_at": "2026-10-12T10:00:00+05:30",
      "duration_minutes": 60,
      "status": "upcoming",
      "score": "42/50",
      "url": "https://lms.example.com/exams/551"
    }
  ]
}
```

`title` is required. `status` is one of `upcoming`, `live`, `completed` or `missed`. The rest are optional.

### `package_cards`

```json
{
  "packages": [
    {
      "name": "Full year",
      "price": 4999,
      "currency": "INR",
      "original_price": 6999,
      "validity": "12 months",
      "features": ["All courses", "Mock exams", "Doubt clearing"],
      "highlight": true,
      "checkout_url": "https://lms.example.com/checkout/full-year"
    }
  ]
}
```

`name`, `price` and `currency` (ISO 4217) are required. `highlight: true` marks the recommended package. Price the package for this learner, including any discount they are entitled to.

### `table`

```json
{ "columns": ["Subject", "Score", "Rank"], "rows": [["Physics", "42/50", 3], ["Chemistry", "38/50", 9]] }
```

### `text`

```json
{ "message": "Your certificate is ready.", "items": [{ "label": "Issued", "value": "4 Oct 2026" }] }
```

Any other JSON object also works with `text`. It is shown as a list of labels and values.

## 5. Opening the chat: session tokens

The chat never sees your learners' passwords or your project API key. Instead, **your server** creates a short **session token** for the signed-in learner, and the learner's browser or app opens the chat with it:

```
Learner taps "AI Tutor" in your LMS
   │
   ▼
Your server ── POST https://aichat.tutorpro.in/chat/sessions (project API key, user_id, claims)
   │
   ▼
{"session": "cs_…", "chat_url": "https://aichat.tutorpro.in/app/chat?session=cs_…"}
   │
   ▼
Your app opens chat_url in a webview or a new browser tab
```

The token identifies your project, the course and the learner, so the chat knows whose data to request from your tools. The browser can't change any of them.

### The project API key

Ask the AI chat admin for a **project chat key**. They create it with *Project chat key* on your project. It starts with `ak_`, is shown once, and only allows chat, so it can't upload or change course material.

Keep it on your server, like a password. Never send it to browsers or mobile apps.

### Create a session

`POST https://aichat.tutorpro.in/chat/sessions`

| Header | Value |
| --- | --- |
| `X-API-Key` | Your project chat key, `ak_…` |
| `Content-Type` | `application/json` |

| Body field | Required | Meaning |
| --- | --- | --- |
| `user_id` | yes | The learner's ID in your LMS: 1–128 characters of letters, digits and `_ . @ : -`. Use the same ID your tools look learners up by. It only needs to be unique within your project. |
| `course_id` | no | The course the learner opened the chat from, matching a course ID in the AI chat project. Leave it out for project-wide chat. |
| `ttl_seconds` | no | Lifetime in seconds, at least 300. Without it, the session lasts until revoked (unless the server sets a default). |
| `claims` | no | Facts about the learner that you vouch for, such as `{"plan": "free"}`. See [Claims](#claims). |

```http
POST https://aichat.tutorpro.in/chat/sessions
X-API-Key: ak_…
Content-Type: application/json

{
  "user_id": "8812",
  "course_id": "physics-12",
  "ttl_seconds": 43200,
  "claims": { "plan": "free", "batch": "2026-neet" }
}
```

**Response**, `201 Created`:

```json
{
  "session": "cs_Xr3…",
  "expires_at": 1791489600.0,
  "chat_url": "https://aichat.tutorpro.in/app/chat?session=cs_Xr3…"
}
```

| Field | Meaning |
| --- | --- |
| `session` | The session token. The chat page sends it as the `X-Chat-Session` header. |
| `expires_at` | Unix time when the session ends, or `null` if it never expires. |
| `chat_url` | The page to open for the learner, with the token included. |

**Errors:**

| Status | Meaning |
| --- | --- |
| `401` | The API key is missing, wrong or revoked. |
| `403` | The key is not allowed to chat. |
| `404` | `course_id` is not a course in this project. Ask the admin to create it, using your course ID. |
| `422` | A field is invalid, for example a `user_id` with spaces or an unknown claim format. `detail` lists each problem. |

### Examples

**PHP (Laravel)**

```php
$response = Http::withHeaders(['X-API-Key' => config('services.ai_chat.api_key')])
    ->timeout(10)
    ->post(config('services.ai_chat.url') . '/chat/sessions', [
        'user_id' => (string) $user->id,
        'course_id' => $course?->ai_chat_course_id,
        'ttl_seconds' => 43200,
        'claims' => ['plan' => $user->hasActivePurchase() ? 'paid' : 'free'],
    ])
    ->throw();

return redirect()->away($response->json('chat_url'));
```

**Node.js**

```js
const response = await fetch(`${process.env.AI_CHAT_URL}/chat/sessions`, {
  method: "POST",
  headers: { "X-API-Key": process.env.AI_CHAT_API_KEY, "Content-Type": "application/json" },
  body: JSON.stringify({
    user_id: String(user.id),
    course_id: course?.aiChatCourseId,
    ttl_seconds: 43200,
    claims: { plan: user.isPaid ? "paid" : "free" },
  }),
});
if (!response.ok) throw new Error(`AI chat session failed: ${response.status}`);
const { chat_url } = await response.json();
```

**curl**

```bash
curl -s https://aichat.tutorpro.in/chat/sessions \
  -H "X-API-Key: $AI_CHAT_API_KEY" -H "Content-Type: application/json" \
  -d '{"user_id": "8812", "course_id": "physics-12", "claims": {"plan": "free"}}'
```

### Opening the chat

- Open `chat_url` in your app's **webview**, or in a **new browser tab** on the web. The chat page can't be embedded in an `<iframe>`.
- Create the session on your server **when the learner opens the chat**, and send the learner straight to `chat_url`. Don't create sessions in the browser, and don't log the URL: it contains the token.
- The learner's saved chats belong to the learner, not the session. A new session for the same `user_id` shows all their earlier conversations.
- New chats start in the session's `course_id`. The learner can also reopen earlier chats from other courses, but can't start chats in courses you never opened for them.

### Session lifetime and signing out

- A new session per visit, with `ttl_seconds` of a few hours (for example `43200`, 12 hours), keeps links from working forever and keeps claims up to date.
- Sessions without `ttl_seconds` last until you revoke them. Use these only if you store and reuse the link.
- When the learner signs out of your LMS, or a link may have leaked, revoke all of that learner's sessions:

```http
DELETE https://aichat.tutorpro.in/chat/sessions?user_id=8812
X-API-Key: ak_…
```

This returns `204 No Content`. A chat page that is still open asks the learner to reopen the chat from your LMS the next time they use it.

### Claims

`claims` tell the chat about the learner:

- `plan` is the claim the chat acts on. When it is `"free"` and the admin has set a daily question limit, the learner's questions stop at the limit, and the chat shows your packages instead. It uses the admin's chosen *upsell tool*, normally your `package_cards` tool.
- Other claims are passed to the model as background facts, which helps it choose tools.
- Keys are lowercase snake_case. There are at most 20 keys, and values are strings (at most 200 characters), numbers, booleans or `null`. Never put secrets or personal data such as phone numbers in claims.
- Claims are fixed for the session's lifetime. When they change, for example after a purchase, open a new session and revoke the old ones.

## 6. Writing good tools

The model chooses tools from their **descriptions** alone. Write them like this:

- **Say what it returns and when to use it.** For example: "The learner's completion percentage, lessons done and next lesson for each enrolled course. Use when they ask about progress, how much is left or what to study next."
- **Keep tools distinct.** Two tools with similar descriptions confuse the planner. Prefer a few broad tools to many narrow ones.
- **Keep parameters few and optional,** with clear descriptions and `enum` wherever the values are fixed. The model fills them only with what the learner said.
- **Return only what the learner may see,** in the learner's language where you have translations.
- **Cache shared data:** set `cache_seconds` for data that changes slowly, such as packages or exam timetables.

The admin can override a description without a release, which is useful for tuning.

### A starter set

Most projects can start with these four tools:

| Tool | Display | Personal | Description to start from |
| --- | --- | --- | --- |
| `get_course_progress` | `progress` | yes | The learner's completion %, lessons done and next lesson per enrolled course. Use for questions about progress or what to study next. |
| `list_upcoming_exams` | `exam_list` | yes | Upcoming and live exams the learner can take, soonest first, with date, time and duration. Use for questions about exams, tests or the schedule. |
| `get_exam_results` | `table` | yes | The learner's recent exam scores and ranks. Use for questions about marks, results or performance. |
| `get_packages` | `package_cards` | yes | Packages the learner can buy, with price, validity, features and checkout link. Use for questions about prices, plans, upgrading or unlocking content. |

## 7. Testing

1. **Locally:** the chat refuses `http://` and private addresses unless the chat server runs with `LMS_ALLOW_PRIVATE_URLS=true`. Use that only on a development server, with your LMS at, for example, `http://localhost:8000/api`.
2. **In the admin panel** (project → *LMS integration*): save the base URL, generate the secret, then press *Sync tools*. Fix any manifest errors it reports, then enable the tools.
3. Use **Test** on a tool to call it as any `user_id` and see your raw response.
4. Open a test chat as a learner (*Open test chat*, with an optional free plan) and ask a question in your learners' words.
5. The **call log** lists every call, with its status and latency, for 30 days.

To call your endpoint by hand, sign the request yourself:

```bash
SECRET='lms_…'; TS=$(date +%s); BODY='{"user_id":"8812","course_id":null,"args":{}}'
SIG="v1=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')"
curl -s https://lms.example.com/api/ai/tools/get_course_progress \
  -H "Content-Type: application/json" -H "X-AI-Chat-Timestamp: $TS" \
  -H "X-AI-Chat-Signature: $SIG" -d "$BODY"
```

## 8. Security checklist

- [ ] Every `/ai/*` route verifies the signature and the 5-minute window before doing anything else.
- [ ] Data is looked up for the request's `user_id` only. No parameter can select another learner.
- [ ] Every tool is read-only: no writes, no emails, no purchases.
- [ ] Responses contain only what the learner may see in your app. No internal notes, other learners, tokens or keys.
- [ ] The signing secret is stored like a password and never sent to browsers or apps.
- [ ] The routes are rate limited like the rest of your API.

## 9. Troubleshooting

The admin panel shows these codes when a sync or a test fails:

| Code | Meaning |
| --- | --- |
| `http_401` | Your signature check rejected the request. Check the secret and that you sign the **raw** body. |
| `http_404` | The base URL or tool path is wrong. The chat calls `{base_url}/ai/manifest` and `{base_url}/ai/tools/{name}`. |
| `http_3xx` | You redirected. Save the final URL as the base URL. |
| `invalid_manifest` | The manifest broke a rule; the message names the field. |
| `invalid_json` / `invalid_envelope` | The response wasn't JSON, or wasn't `{"ok": true, "data": …}`. |
| `timeout` | No answer within 5 seconds. |
| `response_too_large` | The response was over 50 KB. Return fewer items. |
| `private_address` | The URL resolves to a private network address (allowed on development servers only). |

## Implement it with Claude Code

Paste this into Claude Code (or another assistant) in your LMS repository:

```text
Read https://aichat.tutorpro.in/developers/lms.md, the AI Tutor chat's LMS integration guide, and implement
it in this codebase:
1. A middleware for all /ai/* routes that verifies X-AI-Chat-Signature exactly as specified
   (HMAC-SHA256 over "<timestamp>.<raw body>", 5-minute window, constant-time compare), with the
   secret read from config (AI_CHAT_SIGNING_SECRET).
2. GET /ai/manifest and POST /ai/tools/{name} for these tools: get_course_progress,
   list_upcoming_exams, get_exam_results and get_packages, using the display shapes in the guide
   and our existing models and queries. Look up data only for the request's user_id.
3. Where learners open the AI Tutor, create a chat session on the server as in section 5 of the
   guide (with the project API key from config AI_CHAT_API_KEY, ttl_seconds 43200 and
   "claims": {"plan": "free" | "paid"}), open its chat_url, and revoke sessions on sign-out.
4. Tests: a bad signature and a stale timestamp get 401; each tool returns the documented shape;
   a learner never sees another learner's data.
Follow the existing code style. Keep every tool read-only. Ask me before guessing at business
rules, such as which learners count as "free".
```
