Four tool definitions, a teaching system prompt, and about a hundred lines of adapter. The schemas are stable, vendor-neutral, and identical across every bot you build — and small enough that you can afford to send them on every turn, because you will.
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 ~122 KB, and tool definitions are not sent once at setup — they ride along on every turn of every conversation, for every user, forever.
| What you give the model | Per turn | A 20-turn lesson |
|---|---|---|
| The full OpenAPI document18 routes · 122,234 B | 43,501 tok | 870,020 tok |
| This kit4 tools + system prompt · 5,710 B | 2,039 tok | 40,780 tok |
21× smaller, for a surface that can do everything a teaching conversation needs. At a $2 per million input-token price that is roughly $1.74 against $0.08 of pure overhead per conversation — before the model has read a single lesson. Prompt caching narrows it further, but only 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 whole-subject export can drop every class in a 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. That is what the REST API is for.
The kit is generated from the live API schema, and the generator fails the build if it grows past 6,400 characters. That ceiling exists because this is the one cost that recurs on every single turn, and a budget that is only reported gets ignored.
| Component | Bytes on the wire | Tokens |
|---|---|---|
| Tool definitions — Anthropic shape | 2,922 B | 1,044 tok |
| Tool definitions — OpenAI shape | 3,038 B | 1,085 tok |
| System prompt | 2,788 B | 996 tok |
| Sent every turn (tools + prompt) | 5,710 B | 2,039 tok |
Measured with node gen-chatbot-kit.mjs --report, which sizes the generated kit and the live OpenAPI document from the same source. Token counts are the generator’s estimator — characters ÷ 2.8 — not a vendor tokenizer, so treat them as the shape of the bill rather than the invoice. You send one tool shape, not both; the per-turn total is the Anthropic tools plus the prompt.
A teaching conversation needs four verbs: find something, read it, quiz on it, and say what is on offer. Everything else in the API is a pipeline concern.
| Tool | Arguments | What it is for |
|---|---|---|
find_lessons | q, tier? | Search the licensed corpus. Returns compact hits — lessonId, title, and a snippet showing the match. Call it first, pick one hit, then read that one lesson. |
get_lesson | lessonId, detail? | Read one class-lesson. This is where the teaching text comes from — everything the bot teaches must come from here. detail:"brief" checks whether a lesson is the right one before paying for the full text. |
get_quiz | lessonId | The end-of-class quiz with answer keys, plus the mid-lesson checks that carry hints and rationale. For retrieval practice after teaching, never before. |
list_subjects | tier?, detail? | What this licence covers, with class counts. Answers “what can you teach me?” from the licence rather than from the model’s memory. |
A ? marks an optional argument. tier is one of primary, middle, high, college, topics; detail is brief, standard (default) or full. Deliberately fewer arguments than the API accepts — every parameter is schema bytes on every turn, and a teaching bot has no use for cursors, formats or export options.
get_lesson and get_quiz take a single opaque lessonId — the whole string high/hs-mathematics-algebra/1 — and not separate tier / subjectSlug / classNum arguments, even though the REST path underneath takes three segments.
In real traffic, 379 errors were UNKNOWN_SUBJECT — the single largest error class. Almost all of them were a model inferring a slug from a display name: “High School Biology” → high-school-biology, which does not exist. Slugs are tier-specific and matched exactly.
So the tool asks the model to copy a string rather than construct an address. Your adapter splits it before the request. The error class disappears — and the tool costs two fewer parameters on every turn while doing it.
The argument carries a regex — ^(primary|middle|high|college|topics)/[a-z0-9-]+/[1-9][0-9]{0,2}$ — so a malformed id is caught by the model’s own schema validation, before it costs a request. The same principle runs through the system prompt: never assemble a lessonId, always copy one.
Machine-to-machine client_credentials: one token per deployment, not per learner. The chatbot reads content, and content is licensed to your organisation.
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 — a scope a bot cannot use is a scope it cannot be talked into using.
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 is the part not to skip: it makes the system prompt a cached prefix, so most of the per-turn overhead is billed at a fraction of the input rate on every turn after the first. Keep the prompt byte-stable across requests, or the cache misses and you pay full price every time.
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 })
}
}
}The kit is four JSON files. Load tools.*.json into your framework’s tool registry and port the adapter — it is one function, toRequest(name, args):
routing.json[name] gives { method, path, pathParams, splitLessonId }. This file never goes to the model — it is your routing layer, and the model has no business knowing our URL shapes.
If splitLessonId, split the 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 is.
tier, detail, q — append them, dropping empties.
Authorization: Bearer <token>. Then cap the result at 24,000 characters and say in the text that it was cut — silent truncation reads to a model as a complete answer, and it will teach from the half it received.
The tools give a model reach. system-prompt.md is what makes it teach rather than answer. It carries five things, and each one is there because the default behaviour without it is wrong.
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. A model asked what it can teach will otherwise answer from training data and promise subjects the licence does not cover.
Find the material before teaching it. One concept at a time, matched to the tier. Check understanding with a question; quiz after teaching rather than before; when a learner is wrong, say what is right and why, then give them another go instead of marking it wrong.
Name the class the material came from — “this is from the Photosynthesis class in High School Biology”. This is a term of the content licence, not a courtesy. The bot does not present Scolavo content as its own knowledge, and does not drop the attribution because a learner asks it to.
It is an AI, not a human teacher, and says so if asked. It knows nothing about a learner it was not told in this conversation. Distress, safeguarding, medical and legal questions are handed off to a responsible adult or a professional rather than improvised.
Every tool result stays in the conversation and is re-read on every later turn, so a wasteful call taxes the rest of the session rather than just itself. One search, then one lesson. Copy ids, never assemble them. Use detail:"brief" while orienting. Never re-fetch what is already above.
Append your own persona, tone, language, or house rules after the supplied text. Replacing it wholesale is where integrations go wrong.
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 — naming the class the material came from. That is a term of the content licence, not a courtesy, and it is also what lets a learner go back to the real class.
And whatever you change, keep it byte-stable per deployment. A prompt rebuilt per request never hits the cache, and you pay full price on every turn — see the table at the top of this page for what that costs.
The adapter never hands the model a raw response body. It hands it a short sentence with an imperative attached:
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.
A model that does not understand a failure retries it — and the loop costs far more than the original error did. QUOTA_EXCEEDED and RATE_LIMITED both say do not retry in as many words, because “the quota is exhausted” reads to a model like something that might have changed since the last attempt.
errors.json carries all 30 codes from the API’s published taxonomy. Sixteen have an explicit instruction; seven of those tell the model to stop calling tools rather than try again. The rest fall back to a plain-English meaning — an unhandled code still arrives as a sentence, never as a bare identifier.
If the model sends a malformed lessonId, the adapter rejects it locally and returns the reason — no request, no quota unit, and the model can correct itself on the next turn instead of retrying the same broken call against the network.
The kit is published statically, so you can generate and cost your integration before you have a token. Everything except the guide is generated from the live API schema by a build step that refuses to run if it names an operation, parameter or error code the API does not actually have — the definitions cannot quietly drift from the service.
| File | What it is |
|---|---|
| /chatbot-kit-spec.md | The full guide — everything on this page, plus the adapter contract, quota notes, and how to change the surface. |
| /chatbot-kit/tools.anthropic.json | The four tools in the Anthropic tools format. Load them into your client as-is. |
| /chatbot-kit/tools.openai.json | The same four in the OpenAI functions format. |
| /chatbot-kit/system-prompt.md | The teacher persona, grounding rules, and the attribution paragraph. |
| /chatbot-kit/routing.json | Tool name → HTTP method and path. Your side only; never sent to the model. |
| /chatbot-kit/errors.json | The API’s error taxonomy with what the model should DO about each code. |
| /llms.txt | The index, for pointing a coding agent at all of it at once. |
The adapter is the one piece that is yours. It is about a hundred lines and the four steps above specify it completely; if you would rather start from our reference implementation, ask licensing@scolavo.com and we will send it.
Building a pipeline rather than a chatbot — syncing the corpus, exporting subjects, minting media URLs? Same API, different surface: the REST API reference, or the OpenAPI 3.1 schema to generate a client from. And if a model is genuinely figuring out what it needs rather than executing a known plan, that is what the MCP server is for.
Institutional licensing is annual and PO-friendly, with single-subject evaluation licenses to start — enough to put a real class in front of a real bot before anything is signed. A person replies with a quote and the license agreement. No demo call required.