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

AreaBefore (pre-v2.7.0)Now (3.6.x)
Generate request shapeTop-level num_questions, question_type, difficulty, question_type_counts, allowed_typesOptional question_config object — omit it for server-side auto planning
reasoning defaultfalse (instant)true (reasoning path); send reasoning: false to keep instant
"mixed" keywordAllowed as a type and as a difficultyRemoved in both places
StreamingPOST /generate-questions/stream and POST /generate-questions/stream-by-questionPOST /generate-questions with "streaming": true
Follow-up request typesPOST /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 inputNot supportedOptional image_urls on both endpoints
File attachmentsNot supportedattachments (images and documents) on both endpoints — preferred over image_urls
Continuation handleprevious_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 objectsUnified shape with possible null fieldsType-scoped — only the keys relevant to type are present; server adds id and created_at
Unknown JSON keysSilently ignoredRejected on both endpoints (extra="forbid"422)
Web researchNot supportedOptional web_search: true on both endpoints; outcome in web_research
Output languageFollows the source material onlySame, plus optional ui_language ("en" | "vi") for language-ambiguous input

Endpoint Changes

Removed

  • POST /generate-questions/stream-by-questiondeleted. Use POST /generate-questions with "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; use POST /generate-questions with "streaming": true.

Added

  • POST /follow-up — the endpoint existed in the legacy contract (more-questions only). It now serves two text request types selected via request_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-questions with response_id; request_type: "more-questions" is no longer accepted on /follow-up (returns 422).
  • attachments on POST /generate-questions and POST /follow-up — send files (images and documents like PDF/Word/text) as { "url", "mime_type", "name"? } with public HTTPS URLs. Preferred over legacy image_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_questions
  • question_type
  • question_type_counts
  • difficulty (now lives inside question_config)
  • allowed_types

The "mixed" concept is gone:

  • No "mixed" key inside question_config.type_counts
  • No "mixed" value for difficulty (use null or omit)
  • No implicit "open mix" default. Omitting question_config now 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 in generation_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 return 422

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_types and max_questions fields.
  • Manual type_counts must contain at least one concrete question type after 0 values 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_plan on the response, and as the plan SSE event when streaming.

Rejected with 422

PayloadWhy
"question_config": nullAn 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: 0Must be a positive integer when provided
A type_counts key outside allowed_question_typesThe allowlist constrains manual maps too
A type_counts minimum that exceeds max_questionsEach positive count contributes its value; each null contributes 1
Any unknown top-level keyextra="forbid"

Migrating Common Old Patterns

Old patternNew pattern
Omit question_config for open mixKeep 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-questionPOST /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 reasoning explicitly? Nothing changes — an explicit true or false is honored exactly as before.
  • Relying on the old implicit false? Add reasoning: false to keep the instant contract (no reasoning tokens, no reasoning_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 type are present. There are no null placeholders for unused fields (e.g. a multiple_choice response no longer carries matchPairs: null or codeBlock: null). Type your client's question model with optional keys, not nullable ones.
  • id (UUID) and created_at are added server-side on every question. created_at is ISO 8601 in UTC offset form with microseconds ("2026-04-22T17:00:00.481920+00:00"), not a Z-suffixed millisecond timestamp.
  • reorder, matching, and fill-in-the-blank have no answer field at all — the canonical answer comes from choices order, positional matchPairs, or choices[].label respectively.
  • For multiple_choice questions, server-side enrichment shuffles choices (Fisher-Yates) and re-keys them to c1, c2, … in the emitted order, then remaps answer.value to the new key. Other types' choices (reorder, fill-in-the-blank, true_false) are emitted in canonical order. Clients that previously cached a multiple_choice choice key from a prior turn cannot reuse it across turns.
  • response_id is the continuation handle. Send it back as response_id — to POST /generate-questions to continue the question chain, or to POST /follow-up for an inline-explanation or chat turn.
  • New optional top-level keys: generation_plan (the effective type/count plan) and web_research (when the request set web_search: true), alongside the existing validation, agent_metadata, and reasoning_summary. On the batch JSON response these keys are always present and carry null when they do not apply — the type-scoping rule above applies to the question objects, not to this envelope. On the SSE done event they are genuinely omitted. Questions from a web-search generation may also carry source_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": true on POST /generate-questions to receive per-question SSE.
  • The same flag applies to POST /follow-up for inline-explanation. chat is always streamed regardless of the flag.
  • SSE event taxonomy by stream type:
    • Question streams (/generate-questions): plan (first event — carries generation_plan), question (per question), done (terminal — carries response_id, total_questions, and optional generation_plan / validation / agent_metadata / question_history), error.
    • /follow-up inline-explanation stream: explanation_text (incremental delta), explanation (full text emitted once at the end), done (carries response_id and explanation), error.
    • /follow-up chat stream (always streamed): text (incremental delta), done (carries response_id and full message), error.
    • All streams with reasoning: true may also emit reasoning_summary_part ({ "index": N } — start of a new part) and reasoning_summary ({ "text": "<delta>", "index": N } — incremental text).
    • All streams with web_search: true emit four research events first: web_search_started, web_research_progress, web_research_delta, and web_research (the authoritative payload — done does not repeat it).
  • Ignore event names you do not recognize; new event types are added additively.
  • The legacy POST /generate-questions/stream-by-question path is gone.
  • The legacy raw-chunk POST /generate-questions/stream has 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 in input (added in v2.8.0)
  • chat — always streamed (added in v2.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.