MakeQuestions API by Karson AI
Migration Guide
Move clients to the current Karson AI API contract: optional question_config with auto planning, supported question types, file attachments, and optional web research.
This guide covers moving an existing client from the legacy server contract (any 2.x line that predates v2.7.0, the earliest release that already had today's consolidated streaming endpoint) to the current server contract (3.6.x). It collects every breaking change a caller can hit and shows a concrete before/after for each.
Callers already on a more recent 2.x build can skip rows that already match their version — see the Changelog for which release introduced each change.
What changed at a glance
| Area | Before (pre-v2.7.0) | Now (3.6.x) |
|---|---|---|
| Generate request shape | Top-level num_questions, question_type, difficulty, question_type_counts, allowed_types | Optional question_config object — omit it for server-side auto planning |
reasoning default | false (instant) | true (reasoning path); send reasoning: false to keep instant |
"mixed" keyword | Allowed as a type and as a difficulty | Removed in both places |
| Streaming | POST /generate-questions/stream and POST /generate-questions/stream-by-question | POST /generate-questions with "streaming": true |
| Follow-up request types | POST /follow-up supported only more-questions (using question_type_counts) | POST /follow-up serves inline-explanation and chat; question continuations moved to POST /generate-questions with response_id |
| Vision input | Not supported | Optional image_urls on both endpoints |
| File attachments | Not supported | attachments (images and documents) on both endpoints — preferred over image_urls |
| Continuation handle | previous_response_id (request body field) | response_id returned by every turn; send it back as response_id — to /generate-questions for more questions, to /follow-up for a text turn |
| Question objects | Unified shape with possible null fields | Type-scoped — only the keys relevant to type are present; server adds id and created_at |
| Unknown JSON keys | Silently ignored | Rejected on both endpoints (extra="forbid" → 422) |
| Web research | Not supported | Optional web_search: true on both endpoints; outcome in web_research |
| Output language | Follows the source material only | Same, plus optional ui_language ("en" | "vi") for language-ambiguous input |
Endpoint Changes
Removed
POST /generate-questions/stream-by-question— deleted. UsePOST /generate-questionswith"streaming": true.POST /generate-questions/stream(raw model JSON chunks) — deleted. It was previously mounted only in dev mode and never part of the public contract; usePOST /generate-questionswith"streaming": true.
Added
POST /follow-up— the endpoint existed in the legacy contract (more-questionsonly). It now serves two text request types selected viarequest_type:inline-explanation— produce a short explanation for a single question.chat— free-form tutoring chat over the thread; always streamed via SSE.- Generating more questions in a thread moved to
POST /generate-questionswithresponse_id;request_type: "more-questions"is no longer accepted on/follow-up(returns422).
attachmentsonPOST /generate-questionsandPOST /follow-up— send files (images and documents like PDF/Word/text) as{ "url", "mime_type", "name"? }with public HTTPS URLs. Preferred over legacyimage_urls; up to 1,500 images and 50 documents per request.
Breaking Payload Changes on POST /generate-questions
These legacy top-level fields are removed from the public contract:
num_questionsquestion_typequestion_type_countsdifficulty(now lives insidequestion_config)allowed_types
The "mixed" concept is gone:
- No
"mixed"key insidequestion_config.type_counts - No
"mixed"value fordifficulty(usenullor omit) - No implicit "open mix" default. Omitting
question_confignow hands the decision to the auto planner, which infers types and counts from your source material — a different behavior from the old open mix, and one that reports what it chose ingeneration_plan.
POST /follow-up renamed its continuation handle:
- Old request field:
previous_response_id - New request field:
response_id(matches the field name in every response) - Unknown keys are rejected (
extra="forbid"), so old payloads return422
Use question_config instead of the legacy fields:
{
"context": "Your learning text...",
"question_config": {
"type_counts": {
"multiple_choice": 3,
"true_false": 2
},
"difficulty": "easy"
}
}…or drop it entirely and let the server plan:
{
"context": "Your learning text..."
}Source Material Rule
Every origin turn on POST /generate-questions must carry at least one of context, attachments, or image_urls. Continuation turns (those with response_id) may omit source material — the chain carries the prior context.
input is steering only and never counts as source material; neither does question_history. A request with none of the four returns 422.
question_config Rules
The field is optional. Three shapes are accepted:
// 1. Omit the key entirely → auto planning (the default)
// 2. Explicit auto planning
{
"mode": "auto",
"difficulty": "easy" | "medium" | "hard" | null // optional
}
// 3. Manual control
{
"type_counts": { [concrete_type]: number | null, ... }, // non-empty
"difficulty": "easy" | "medium" | "hard" | null // optional
}- Auto planning infers question types and counts from your source material. Constrain it with the top-level
allowed_question_typesandmax_questionsfields. - Manual
type_countsmust contain at least one concrete question type after0values are stripped. - Keys are concrete types only:
multiple_choice,true_false,short_answer,exact_answer,reorder,code-output,matching,fill-in-the-blank. - Values:
- Positive integer → exact target count for that type
null→ model-decided quantity, with a guaranteed minimum of 1 of that type (agentic fill backstops zero-generation outcomes). This is not auto planning — the type set stays exactly what you listed.0→ treated as "exclude this type"; the key is silently dropped (equivalent to omitting it)
difficulty: null(or omitted) means mixed difficulty. Do not send the string"mixed".- Whichever shape you use, the effective plan comes back as
generation_planon the response, and as theplanSSE event when streaming.
Rejected with 422
| Payload | Why |
|---|---|
"question_config": null | An explicit null is rejected — omit the key to get auto planning |
{ "mode": "auto", "type_counts": { … } } | mode: "auto" forbids type_counts |
type_counts: {} | Must be non-empty |
type_counts: { "multiple_choice": 0, "true_false": 0 } | After stripping 0 values, the map is empty |
type_counts: { "multiple_choice": -1 } | Negative or non-integer counts |
type_counts: { "mixed": 5 } | No "mixed" key — use concrete types |
type_counts: { "unknown_type": 3 } | Unknown key |
allowed_question_types: [] | Must be non-empty when provided |
max_questions: 0 | Must be a positive integer when provided |
A type_counts key outside allowed_question_types | The allowlist constrains manual maps too |
A type_counts minimum that exceeds max_questions | Each positive count contributes its value; each null contributes 1 |
| Any unknown top-level key | extra="forbid" |
Migrating Common Old Patterns
| Old pattern | New pattern |
|---|---|
Omit question_config for open mix | Keep omitting it — you now get auto planning, and generation_plan tells you what the server chose. To pin the type set instead, send {"type_counts": {"multiple_choice": null}} |
{"num_questions": 6, "question_type": "multiple_choice"} | {"question_config": {"type_counts": {"multiple_choice": 6}}} |
{"question_type_counts": {"mixed": 10}} | Pick concrete types: {"type_counts": {"multiple_choice": 5, "true_false": 5}} (or use null values to let the model decide each count) |
{"question_type_counts": {"multiple_choice": 5, "true_false": 0}} | Either omit true_false or keep the 0 (it's silently dropped): {"type_counts": {"multiple_choice": 5}} |
Top-level "difficulty": "easy" | question_config.difficulty: "easy" |
Top-level "difficulty": "mixed" | Omit difficulty or set to null |
POST /generate-questions/stream-by-question | POST /generate-questions with "streaming": true |
POST /generate-questions/stream (prod) | POST /generate-questions with "streaming": true |
{ "previous_response_id": "resp_abc123" } on follow-up | { "response_id": "resp_abc123" } |
Reasoning Default (v3.4.0)
reasoning now defaults to true on POST /generate-questions and POST /follow-up. A request that omits the field runs the reasoning path: the response may include a reasoning_summary (plus reasoning_summary_part / reasoning_summary SSE events when streaming), and the call bills reasoning tokens.
- Already send
reasoningexplicitly? Nothing changes — an explicittrueorfalseis honored exactly as before. - Relying on the old implicit
false? Addreasoning: falseto keep the instant contract (no reasoning tokens, noreasoning_summary):
{
"context": "Your learning text...",
"question_config": { "type_counts": { "multiple_choice": 3 } },
"reasoning": false
}All generation now runs on a single model: reasoning: true uses low reasoning effort, reasoning: false uses zero effort (truly instant — no reasoning tokens). multiple_choice responses are guaranteed at least 2 choices.
Response Shape Changes
Every successful generate or follow-up turn now returns a continuation handle and per-question metadata:
{
"success": true,
"response_id": "resp_abc123",
"total_questions": 1,
"questions": [
{
"type": "multiple_choice",
"difficulty": "medium",
"question": "What do plants convert into energy?",
"choices": [
{ "key": "c1", "label": "Sunlight" },
{ "key": "c2", "label": "Water" }
],
"answer": { "value": "c1" },
"id": "8b2f0a5c-6e5e-4a1b-9b5e-9a9f1c4e6a7d",
"created_at": "2026-04-22T17:00:00.481920+00:00"
}
],
"generation_plan": {
"source": "auto_default",
"type_counts": { "multiple_choice": 1 },
"difficulty": null
}
}- Each question is type-scoped: only the keys meaningful to its
typeare present. There are nonullplaceholders for unused fields (e.g. amultiple_choiceresponse no longer carriesmatchPairs: nullorcodeBlock: null). Type your client's question model with optional keys, not nullable ones. id(UUID) andcreated_atare added server-side on every question.created_atis ISO 8601 in UTC offset form with microseconds ("2026-04-22T17:00:00.481920+00:00"), not aZ-suffixed millisecond timestamp.reorder,matching, andfill-in-the-blankhave noanswerfield at all — the canonical answer comes fromchoicesorder, positionalmatchPairs, orchoices[].labelrespectively.- For
multiple_choicequestions, server-side enrichment shuffleschoices(Fisher-Yates) and re-keys them toc1, c2, …in the emitted order, then remapsanswer.valueto the new key. Other types'choices(reorder,fill-in-the-blank,true_false) are emitted in canonical order. Clients that previously cached amultiple_choicechoice key from a prior turn cannot reuse it across turns. response_idis the continuation handle. Send it back asresponse_id— toPOST /generate-questionsto continue the question chain, or toPOST /follow-upfor aninline-explanationorchatturn.- New optional top-level keys:
generation_plan(the effective type/count plan) andweb_research(when the request setweb_search: true), alongside the existingvalidation,agent_metadata, andreasoning_summary. On the batch JSON response these keys are always present and carrynullwhen they do not apply — the type-scoping rule above applies to the question objects, not to this envelope. On the SSEdoneevent they are genuinely omitted. Questions from a web-search generation may also carrysource_ids. - All human-readable strings (
question,choices[].label,answer.value,answer.rubric[],matchPairs.*[].label) are Markdown and may contain KaTeX inline math in$…$. Display math ($$…$$) is prompt-forbidden and should not appear, and currency symbols are rejected server-side — monetary amounts come back as codes or words (5 USD). Render them accordingly.
Vision Input (image_urls)
You can add turn-scoped visual context on either endpoint:
{
"context": "Use both text and image context.",
"question_config": { "type_counts": { "multiple_choice": 3 } },
"image_urls": [
"https://cdn.example.com/diagram-1.png"
]
}Rules:
- HTTPS only
- max 1,500 URLs
- each URL max length 8,192 chars
- duplicates are removed server-side
You do not need to resend image_urls (or attachments) on later turns of a chain — the model retains earlier file inputs through the response chain. Send only newly added files.
New clients should prefer attachments over image_urls — it accepts both images and documents (PDF/Word/text). See the Changelog and API Reference.
Streaming Migration Notes
- The two prod streaming endpoints have collapsed into a single flag. Send
"streaming": trueonPOST /generate-questionsto receive per-question SSE. - The same flag applies to
POST /follow-upforinline-explanation.chatis always streamed regardless of the flag. - SSE event taxonomy by stream type:
- Question streams (
/generate-questions):plan(first event — carriesgeneration_plan),question(per question),done(terminal — carriesresponse_id,total_questions, and optionalgeneration_plan/validation/agent_metadata/question_history),error. /follow-upinline-explanationstream:explanation_text(incremental delta),explanation(full text emitted once at the end),done(carriesresponse_idandexplanation),error./follow-upchatstream (always streamed):text(incremental delta),done(carriesresponse_idand fullmessage),error.- All streams with
reasoning: truemay also emitreasoning_summary_part({ "index": N }— start of a new part) andreasoning_summary({ "text": "<delta>", "index": N }— incremental text). - All streams with
web_search: trueemit four research events first:web_search_started,web_research_progress,web_research_delta, andweb_research(the authoritative payload —donedoes not repeat it).
- Question streams (
- Ignore event names you do not recognize; new event types are added additively.
- The legacy
POST /generate-questions/stream-by-questionpath is gone. - The legacy raw-chunk
POST /generate-questions/streamhas been removed (it was previously dev-only).
Follow-up Migration Notes
The legacy contract exposed POST /follow-up only for more-questions. That intent has since been removed from /follow-up (it returns 422) — question continuations now go to POST /generate-questions with response_id. /follow-up today serves two text request types (selected via request_type), both using the renamed response_id continuation handle:
inline-explanation— put the question text ininput(added inv2.8.0)chat— always streamed (added inv2.8.0)
The continuation handle is response_id (renamed in v3.2.0 from the previous request field previous_response_id, which had been live since v2.5.0). Which endpoint you send it back to depends on what you want next: /generate-questions for more questions, /follow-up for an explanation or a chat turn.
To continue a question chain, send response_id to /generate-questions instead of /follow-up:
- POST /follow-up
+ POST /generate-questions
{
- "previous_response_id": "resp_abc123",
+ "response_id": "resp_abc123",
- "request_type": "more-questions",
"question_config": { "type_counts": { "true_false": 2 } }
}Turn request models reject unknown fields (extra="forbid"), so legacy or misspelled keys return 422 — including request_type: "more-questions", or question_config / allowed_question_types / max_questions / question_history sent to /follow-up.
One legacy capability has no replacement: more-questions allowed starting a fresh chain from question_config alone, with no source material. /generate-questions requires real source material on an origin turn, so that call must now supply context, attachments, or image_urls.
Files sent on an earlier turn do not need to be re-sent: the model retains them through the response chain. Send only newly added attachments / image_urls.
Error Handling Migration
Every error response uses the envelope { "detail": …, "request_id": "…" }, and the same id is echoed in the X-Request-ID response header.
The trap: detail is not always a string. On a 422 it is an array of Pydantic validation objects:
{
"detail": [
{
"type": "value_error",
"loc": ["body"],
"msg": "Value error, Provide 'response_id', 'context' text, at least one attachment, or at least one image_url.",
"input": {},
"ctx": { "error": {} }
}
],
"request_id": "b6f0c2de-1f8a-4a3e-9c11-0d4a2f6e7b83"
}Items may also carry input (the rejected value) and ctx (validator context) — diagnostic extras, not a stable contract; read msg.
Note that loc is often just ["body"] — whole-model rules (the source-material check, the question_config shape checks) are raised at model level, not against a single field. Clients that render detail directly will print [object Object] for every validation failure. Handle both shapes, and tolerate a field-less loc:
const { detail, request_id } = await response.json();
const message = Array.isArray(detail)
? detail
.map((d) => {
const field = (d.loc ?? []).slice(1).join(".");
return field ? `${field}: ${d.msg}` : d.msg;
})
.join("; ")
: detail;Note also that GET /health returns 200 even when the service is impaired — read the status field ("healthy" or "degraded") rather than treating any 2xx as healthy.
Built by Karson AI, Inc.