<!--
  Published from backend/lambda-mcp/CHATBOT_KIT.md by backend/lambda-mcp/publish-dev-docs.mjs.
  Do not edit this copy — edit the source and re-run the publisher.
  `node publish-dev-docs.mjs --check` fails when this file drifts.
-->

# Scolavo Chatbot Kit — turn a generic chatbot into a teacher

This is the drop-in kit for putting Scolavo curriculum **inside a chatbot**: four tool
definitions, a system prompt, and ~100 lines of adapter. The schemas are stable, vendor-neutral,
and identical across every bot you build.

If you are building a **pipeline** — syncing the corpus, exporting subjects, minting media URLs —
you want [`REST_API.md`](./REST_API.md) instead. Same API, different job. §0 below explains why the
two need different surfaces.

---

## 0. Read this before you paste anything

The obvious way to wire a chatbot to a REST API is to hand the model the whole OpenAPI document.
**Do not do that here.** Our schema is ~115 KB, and tool definitions are re-sent to the model on
*every turn of every conversation*:

| What you give the model | Per turn | A 20-turn lesson |
|---|---:|---:|
| The full OpenAPI document (18 routes) | **41,064 tok** | 821,280 tok |
| This kit (4 tools + system prompt) | **2,039 tok** | 40,780 tok |

**20× smaller, for a surface that can do everything a teaching conversation needs.** At Sonnet's
$2/M input that is the difference between roughly $1.64 and $0.08 of pure overhead per
conversation, before the model has read a single lesson — and prompt caching only helps if the
definitions are byte-stable, which a hand-maintained paste is not.

The other fourteen routes are not missing by accident. A model handed an export tool *will* reach
for it, and one `export_subject` can drop a 41-class subject into the context window — outweighing
the entire rest of the conversation. Bulk belongs in your pipeline, where your code decides what to
fetch and nothing is re-billed per turn.

---

## 1. What's in the box

```
chatbot-kit/
  tools.anthropic.json   4 tool definitions, Anthropic `tools` shape
  tools.openai.json      the same 4, OpenAI `functions` shape
  routing.json           tool name → HTTP method + path (never sent to the model)
  errors.json            the API's error taxonomy + what the model should DO about each
  system-prompt.md       the teacher persona, grounding rules, and attribution
  adapter.mjs            reference implementation — copy and adapt
```

Everything except `adapter.mjs` is **generated** from the live API schema by
`node gen-chatbot-kit.mjs`. It refuses to build if it names an operation, parameter or error code
the API does not actually have, so the definitions cannot quietly drift from the service.

---

## 2. The four tools

| Tool | Arguments | What it is for |
|---|---|---|
| `find_lessons` | `q`, `tier?` | Search the licensed corpus. Compact hits: `lessonId`, `title`, `snippet`. |
| `get_lesson` | `lessonId`, `detail?` | Read one class-lesson. This is where the teaching text comes from. |
| `get_quiz` | `lessonId` | End-of-class quiz with answer keys, plus mid-lesson checks with hints. |
| `list_subjects` | `tier?`, `detail?` | What this licence covers. Answers "what can you teach me?" |

### The one design decision worth knowing about

`get_lesson` and `get_quiz` take a **single opaque `lessonId`** — `"high/hs-bio-101/7"` — not
separate `tier` / `subjectSlug` / `classNum` arguments, even though the REST path takes three
segments.

That is deliberate. In production traffic, **379 errors were `UNKNOWN_SUBJECT`**: a model inferring
a slug from a display name ("High School Biology" → `high-school-biology`, which does not exist).
A search hit already carries the exact id, so the tool asks the model to *copy a string* rather
than *construct an address*. The adapter splits it before the request. The error class disappears,
and the tool costs two fewer parameters.

The same principle runs through the system prompt: **never assemble a lessonId, always copy one.**

---

## 3. Install (5 minutes)

### 3.1 Get a token

Machine-to-machine `client_credentials`, exactly as in [`REST_API.md` §1](./REST_API.md). One token
per deployment, not per learner — the chatbot reads content, and content is licensed to your
organisation.

```bash
export SCOLAVO_TOKEN="$(curl -s "https://scolavo-auth.auth.us-east-1.amazoncognito.com/oauth2/token" \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  --data-urlencode 'grant_type=client_credentials' \
  --data-urlencode 'scope=scolavo-mcp/content.read' | jq -r .access_token)"
```

`content.read` is the only scope the chatbot surface needs. Do not grant the bot
`content.export` or `media.download`.

### 3.2 Wire it up — Anthropic

```js
import Anthropic from '@anthropic-ai/sdk'
import { createClient } from './chatbot-kit/adapter.mjs'

const scolavo = createClient({
  baseUrl: 'https://mcp.scolavo.com',
  token: process.env.SCOLAVO_TOKEN,
})
const anthropic = new Anthropic()

async function chat(messages) {
  for (;;) {
    const res = await anthropic.messages.create({
      model: 'claude-sonnet-5',
      max_tokens: 2048,
      system: [{ type: 'text', text: scolavo.systemPrompt, cache_control: { type: 'ephemeral' } }],
      tools: scolavo.tools,
      messages,
    })
    messages.push({ role: 'assistant', content: res.content })

    const calls = res.content.filter((c) => c.type === 'tool_use')
    if (!calls.length) return res

    const results = await Promise.all(calls.map(async (c) => {
      const out = await scolavo.call(c.name, c.input)
      return { type: 'tool_result', tool_use_id: c.id, content: out.text, is_error: out.isError }
    }))
    messages.push({ role: 'user', content: results })
  }
}
```

The `cache_control` marker matters: it makes the system prompt a cached prefix, so the ~2k tokens
of overhead are billed at 0.1× on every turn after the first. Keep the prompt byte-stable across
requests or the cache misses.

### 3.3 Wire it up — OpenAI

```js
import OpenAI from 'openai'
import { createClient } from './chatbot-kit/adapter.mjs'

const scolavo = createClient({ baseUrl: 'https://mcp.scolavo.com', token: process.env.SCOLAVO_TOKEN })
const openai = new OpenAI()

async function chat(messages) {
  for (;;) {
    const res = await openai.chat.completions.create({
      model: 'gpt-4.1',
      messages: [{ role: 'system', content: scolavo.systemPrompt }, ...messages],
      tools: scolavo.toolsOpenAI,
    })
    const msg = res.choices[0].message
    messages.push(msg)
    if (!msg.tool_calls?.length) return msg

    for (const c of msg.tool_calls) {
      const out = await scolavo.call(c.function.name, JSON.parse(c.function.arguments))
      messages.push({ role: 'tool', tool_call_id: c.id, content: out.text })
    }
  }
}
```

### 3.4 Not using JavaScript?

The kit is four JSON files. Load `tools.*.json` into your framework's tool registry, and port
`adapter.mjs` — it is one function. `toRequest(name, args)`:

1. Look up `routing.json[name]` → `{ method, path, pathParams, splitLessonId }`.
2. If `splitLessonId`, split the `lessonId` argument on `/` and fill the path segments in order.
   Reject a malformed id **before** sending; the message is more useful to the model than a 404.
3. Every remaining argument becomes a query parameter.
4. `Authorization: Bearer <token>`.

---

## 4. The system prompt

`system-prompt.md` is what turns a generic assistant into a teacher. It carries five things:

- **What you offer** — the five tiers, the class structure, and the instruction to answer "what can
  you teach me?" from `list_subjects` rather than from the model's own memory.
- **How to teach** — find before teaching, one concept at a time, check understanding, quiz after
  rather than before, correct-and-retry instead of marking wrong.
- **Where it comes from** — name the class the material came from. This is a **term of the content
  licence**, not a courtesy. Do not remove it.
- **Limits** — it is an AI, it knows nothing about the learner it was not told, and it hands off
  distress/safeguarding/medical/legal rather than improvising.
- **Working efficiently** — one search then one lesson, copy ids, prefer `detail:"brief"` while
  orienting, never re-fetch what is already in the conversation.

### Customising it safely

Append your own persona, tone, language, or house rules **after** the supplied text. Two sections
must survive editing:

- the **grounding** rules ("teach only what the lesson contains", "never invent curriculum") — they
  are what stops a confident bot inventing a syllabus and attributing it to us;
- the **attribution** paragraph — licence term.

If you change the tone, keep changes byte-stable per deployment. A prompt rebuilt per request
never hits the cache and you pay full price on every turn.

---

## 5. Errors

`adapter.mjs` hands the model a short, actionable sentence — never a raw body:

```
TIER_NOT_LICENSED: No grant covers this content tier.
This licence does not cover that tier. Tell the learner it is outside what you can teach,
and offer something list_subjects does cover.
```

The imperative half is the point. A model that does not understand a failure **retries it**, and
the loop costs far more than the original error. `QUOTA_EXCEEDED` and `RATE_LIMITED` both say *do
not retry* in as many words.

Codes and meanings come from the API's published taxonomy in `errors.json`; unknown codes still
arrive as a sentence rather than a bare identifier. Full reference: [`REST_API.md` §6](./REST_API.md).

---

## 6. What each call costs you

Two separate bills, and it is worth keeping them apart:

**Your LLM provider** — the model re-reads every tool result on every later turn, so an oversized
result is a tax on the rest of the session, not a one-off. The adapter therefore:

- caps any result at **24,000 characters** (~8,500 tokens) and *says so in the text*, because
  silent truncation reads to the model as a complete answer;
- strips media URLs and other bytes the model cannot act on, using a denylist so new API fields are
  never silently swallowed.

**Your Scolavo quota** — see [`REST_API.md` §4](./REST_API.md). Briefly: every call costs one
`calls` unit; `get_lesson` and `get_quiz` also cost a `lessonReads` unit. `find_lessons` and
`list_subjects` do not. A well-behaved teaching conversation is a handful of units, which is why
the prompt pushes one-search-then-one-lesson.

Typical lesson conversation: 1 × `list_subjects`, 1–2 × `find_lessons`, 1–3 × `get_lesson`,
1 × `get_quiz`.

---

## 7. Changing the surface

Edit `SURFACE` in `gen-chatbot-kit.mjs`, then:

```bash
node gen-chatbot-kit.mjs          # regenerate
node --test test/chatbot-kit.test.mjs   # 15 tests, end-to-end against the real router
```

The generator throws if you name an operation or parameter the API does not have, and **fails the
build if the kit grows past 6,400 characters** — that ceiling exists because tool definitions are
the one cost that recurs on every single turn, and budgets that are only reported get ignored.

Before adding a tool, ask what a model will do with it on a turn where it is *slightly* confused.
That question is why `export_subject` is not in this kit.

---

## 8. Support

`REST_API.md` for the full API. Include the `requestId` from any error body when you get in touch —
it resolves questions immediately.
