{"_id":"@atzentis/edu-sdk","name":"@atzentis/edu-sdk","dist-tags":{"latest":"0.2.0"},"versions":{"0.2.0":{"name":"@atzentis/edu-sdk","version":"0.2.0","description":"Atzentis Edu SDK — TypeScript client for edu.atzentis.io","keywords":["atzentis","edu","sdk","client","typescript"],"license":"MIT","author":{"name":"Atzentis","email":"dev@atzentis.com","url":"https://atzentis.com"},"homepage":"https://github.com/atzentis/atzentis-edu-sdk/tree/main/packages/core#readme","repository":{"type":"git","url":"git+https://github.com/atzentis/atzentis-edu-sdk.git","directory":"packages/core"},"type":"module","main":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"import":"./dist/index.js","types":"./dist/index.d.ts"}},"sideEffects":false,"publishConfig":{"access":"public"},"peerDependencies":{"zod":"^3.23.0"},"peerDependenciesMeta":{"zod":{"optional":true}},"devDependencies":{"tsup":"^8.3.0","typescript":"^5.7.0","vitest":"^2.1.0","zod":"^3.23.0"},"scripts":{"build":"tsup","dev":"tsup --watch","test":"vitest run","test:watch":"vitest","type-check":"tsc --noEmit"},"_id":"@atzentis/edu-sdk@0.2.0","bugs":{"url":"https://github.com/atzentis/atzentis-edu-sdk/issues"},"_integrity":"sha512-D9tiAxr3nwo/MoR+T2Y/v8YIEFWCEaZEZHSst5SMR8atEuIVIxipsFaQsXfkGONWZjZKXSH7Mj1mAtD+STtG4w==","_resolved":"/tmp/f25a8a98be7d33d78de885139ae27c1a/atzentis-edu-sdk-0.2.0.tgz","_from":"file:atzentis-edu-sdk-0.2.0.tgz","_nodeVersion":"22.22.3","_npmVersion":"10.9.8","dist":{"integrity":"sha512-D9tiAxr3nwo/MoR+T2Y/v8YIEFWCEaZEZHSst5SMR8atEuIVIxipsFaQsXfkGONWZjZKXSH7Mj1mAtD+STtG4w==","shasum":"49a1e28c74962849efdafee529e1134e50aea7e8","tarball":"https://registry.npmjs.org/@atzentis/edu-sdk/-/edu-sdk-0.2.0.tgz","fileCount":6,"unpackedSize":1192903,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQDAyrHOO6/tEQKMcBoyjIF+wgCwoDLaxrn31gyEA0GLYwIgeU5DRI312dM5UeogUwBw1XY5eBJYGVYa/kTIOS21pEE="}]},"_npmUser":{"name":"vassilibo","email":"vassilibo@outlook.com"},"directories":{},"maintainers":[{"name":"vassilibo","email":"vassilibo@outlook.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/edu-sdk_0.2.0_1781141365764_0.43240064913784093"},"_hasShrinkwrap":false}},"time":{"created":"2026-06-11T01:29:25.583Z","0.2.0":"2026-06-11T01:29:25.982Z","modified":"2026-06-11T01:29:26.239Z"},"maintainers":[{"name":"vassilibo","email":"vassilibo@outlook.com"}],"description":"Atzentis Edu SDK — TypeScript client for edu.atzentis.io","homepage":"https://github.com/atzentis/atzentis-edu-sdk/tree/main/packages/core#readme","keywords":["atzentis","edu","sdk","client","typescript"],"repository":{"type":"git","url":"git+https://github.com/atzentis/atzentis-edu-sdk.git","directory":"packages/core"},"author":{"name":"Atzentis","email":"dev@atzentis.com","url":"https://atzentis.com"},"bugs":{"url":"https://github.com/atzentis/atzentis-edu-sdk/issues"},"license":"MIT","readme":"# @atzentis/edu-sdk\n\nTypeScript SDK for the Atzentis EDU API at `edu.atzentis.io`.\n\n## Install\n\n```bash\npnpm add @atzentis/edu-sdk\n```\n\n## Quick start\n\n```ts\nimport { EduClient } from \"@atzentis/edu-sdk\";\n\nconst edu = new EduClient({\n  apiKey: \"atz_live_xxx\",      // required\n  tenantId: \"school-123\",       // required\n  // baseUrl: \"https://edu.atzentis.io\",  // default\n  // timeoutMs: 30000,\n  // maxRetries: 3,\n});\n\n// Direct request usage\nconst catalog = await edu.get<{ tools: string[] }>(\"/v1/tools/catalog\");\n```\n\n## Building a service\n\nDomain services extend `BaseService` and use the shared transport.\n\n```ts\nimport { BaseService, EduClient, type HttpClient } from \"@atzentis/edu-sdk\";\n\ninterface Tool {\n  id: string;\n  name: string;\n}\n\nclass ToolService extends BaseService {\n  constructor(http: HttpClient) {\n    super(http, \"tools\");\n  }\n\n  getCatalog() {\n    return this._get<{ tools: Tool[] }>(this._path(\"catalog\"));\n  }\n\n  run(toolId: string, input: Record<string, unknown>) {\n    return this._post<{ runId: string }>(this._path(toolId, \"run\"), input);\n  }\n\n  list() {\n    return this._paginate<Tool>(this._path());\n  }\n}\n\nconst edu = new EduClient({ apiKey: \"...\", tenantId: \"...\" });\nconst tools = new ToolService(edu.http);\n\nfor await (const tool of tools.list()) {\n  console.log(tool.id);\n}\n```\n\n## Tools service\n\nThe `tools` service exposes the EDU teacher tool catalog: discovery,\nexecution (sync and async), and ~80 typed wrappers organized by category.\n\n### Catalog discovery\n\n```ts\nimport { EduClient } from \"@atzentis/edu-sdk\";\n\nconst edu = new EduClient({ apiKey: \"atz_live_xxx\", tenantId: \"school-123\" });\n\n// Single page\nconst page = await edu.tools.list({ category: \"math\", limit: 20 });\nfor (const tool of page.items) console.log(tool.key);\n\n// Walk every page lazily\nfor await (const tool of edu.tools.autoPaginate({ category: \"math\" })) {\n  console.log(tool.key);\n}\n\n// Single tool + its JSON schema\nconst tool = await edu.tools.get(\"math.word-problem-generator\");\nconst metadata = await edu.tools.getMetadata(\"math.word-problem-generator\");\n```\n\n### Execution\n\n```ts\nconst result = await edu.tools.execute<{ output: string }>(\n  \"math.word-problem-generator\",\n  { topic: \"fractions\", grade: 5 },\n);\nconsole.log(result.data.output);\n\n// Batch — multiple tools in a single round-trip\nconst batch = await edu.tools.executeBatch([\n  { toolKey: \"math.word-problem-generator\", params: { topic: \"ratios\" } },\n  { toolKey: \"vocab.vocab-quiz\", params: { topic: \"geometry\" } },\n]);\n```\n\n### Typed wrappers\n\n```ts\n// Methods on the ToolService instance\nconst quiz = await edu.tools.vocabQuiz({ topic: \"verbs\", count: 10 });\n\n// Or tree-shakeable named imports\nimport { vocabQuiz } from \"@atzentis/edu-sdk\";\nconst same = await vocabQuiz(edu, { topic: \"verbs\", count: 10 });\n```\n\n### Client-side validation\n\nPass a Zod schema to validate before the request fires. Invalid params throw\n`ValidationError` with field-level details; sensitive paths (apiKey, secret,\npassword, token) are redacted automatically.\n\n```ts\nimport { z } from \"zod\";\nimport { ValidationError } from \"@atzentis/edu-sdk\";\n\nconst schema = z.object({\n  prompt: z.string().min(1),\n  difficulty: z.enum([\"easy\", \"medium\", \"hard\"]),\n});\n\ntry {\n  await edu.tools.execute(\"math.word-problem-generator\", input, { schema });\n} catch (err) {\n  if (err instanceof ValidationError) {\n    for (const fe of err.fieldErrors ?? []) {\n      console.log(`${fe.path}: ${fe.message}`);\n    }\n  }\n}\n```\n\n### Async tool runs\n\n```ts\nconst run = await edu.tools.execute(\"exam.long-grading\", { examId: \"e1\" });\nif (\"runId\" in run && run.status === \"running\") {\n  const completed = await edu.tools.pollRun(run.runId, { intervalMs: 1000 });\n  console.log(completed.status);\n}\n```\n\n## Tutor service\n\nThe `tutor` service wraps `/v1/tutor/*` endpoints and gives students an AI-powered\ntutor with real-time streaming, session memory, and context-aware responses.\n\n### Session lifecycle\n\n```ts\nimport { EduClient } from \"@atzentis/edu-sdk\";\n\nconst edu = new EduClient({ apiKey: \"atz_live_xxx\", tenantId: \"school-123\" });\n\n// Create a session with a student profile\nconst session = await edu.tutor.createSession({\n  context: {\n    studentId: \"student-42\",\n    level: \"B1\",        // CEFR level: A1, A2, B1, B2, C1, C2, or \"custom\"\n    subject: \"math\",\n    language: \"en\",\n    goals: [\"improve algebra\", \"practice fractions\"],\n  },\n});\n\n// Get or list sessions\nconst same = await edu.tutor.getSession(session.id);\nconst page = await edu.tutor.listSessions({ studentId: \"student-42\", limit: 20 });\n\n// Delete a session and all its messages\nawait edu.tutor.deleteSession(session.id);\n```\n\n### Message CRUD\n\n```ts\n// Post a user message\nconst msg = await edu.tutor.createMessage(session.id, {\n  role: \"user\",\n  content: \"Can you explain the Pythagorean theorem?\",\n});\n\n// Retrieve a message or list (oldest first by default)\nconst single = await edu.tutor.getMessage(session.id, msg.id);\nconst messages = await edu.tutor.listMessages(session.id, { direction: \"asc\", limit: 50 });\n```\n\n### Real-time streaming\n\nUse `streamMessage` to stream AI tutor responses token-by-token via Server-Sent Events.\nNo third-party library required — built on native `fetch`.\n\n```ts\nconst ctrl = new AbortController();\n\nfor await (const event of edu.tutor.streamMessage(session.id, \"Explain fractions\", {\n  signal: ctrl.signal,\n})) {\n  if (event.type === \"delta\") {\n    process.stdout.write(event.data);   // token chunk\n  }\n  if (event.type === \"metadata\") {\n    console.log(\"model:\", event.data.model, \"ms:\", event.data.durationMs);\n  }\n  if (event.type === \"reconnecting\") {\n    console.warn(`Reconnecting… attempt ${event.attempt}`);\n  }\n  if (event.type === \"error\") {\n    console.error(event.error.message);\n  }\n  if (event.type === \"done\") break;\n}\n\n// Cancel mid-stream\nctrl.abort();\n```\n\nReconnect behaviour:\n- Transient network drops trigger up to 3 retries with exponential backoff (1 s, 2 s, 4 s).\n- Each reconnect sends `Last-Event-ID` so the server skips already-delivered tokens.\n- `reconnecting` events let you show a UI indicator between attempts.\n- After all retries are exhausted, `StreamError` is thrown.\n- Permanent 4xx responses are not retried.\n\n### Session context\n\n```ts\n// Read the current student profile for a session\nconst ctx = await edu.tutor.getContext(session.id);\n\n// Patch context mid-session — affects subsequent AI responses\nawait edu.tutor.updateContext(session.id, {\n  level: \"B2\",           // upgrade difficulty\n  subject: \"geometry\",\n  customFields: { preferredExamples: \"real-world\" },\n});\n```\n\n`customFields` accepts any `Record<string, unknown>` — use it for product-specific data\nthat the server-side AI can read when composing responses.\n\n### Input validation\n\nClient-side Zod schemas guard against bad inputs before the network round-trip:\n\n```ts\nimport {\n  createSessionParamsSchema,\n  createMessageParamsSchema,\n  sessionContextSchema,\n} from \"@atzentis/edu-sdk\";\n\n// Validate before calling the service\nconst result = createMessageParamsSchema.safeParse({ role: \"user\", content: \"\" });\nif (!result.success) {\n  console.error(result.error.errors); // → content must not be empty\n}\n```\n\n### Error handling\n\n| Condition | Error |\n| --- | --- |\n| Unknown session or message | `NotFoundError` (404) |\n| Invalid CEFR level / empty content | `ZodError` (client-side) |\n| Network drop after max retries | `StreamError` |\n| Permanent 4xx from server | `BaseError` with `statusCode` |\n\n## Spaces service\n\nThe `spaces` service wraps `/v1/spaces/*` endpoints. Spaces are collaborative\nlearning surfaces where students and teachers explore content together. Each\nspace has cards, modules, role-based permissions, and an AI Sidekick that\nstreams real-time suggestions.\n\n### Space lifecycle\n\n```ts\nimport { EduClient } from \"@atzentis/edu-sdk\";\n\nconst edu = new EduClient({ apiKey: \"atz_live_xxx\", tenantId: \"school-123\" });\n\n// Create a space\nconst space = await edu.spaces.createSpace({\n  name: \"Algebra 101\",\n  description: \"Quadratic equations unit\",\n  tags: [\"math\", \"B1\"],\n  subject: \"math\",\n  level: \"B1\",\n  language: \"en\",\n});\n\n// Get or list spaces\nconst same = await edu.spaces.getSpace(space.id);\nconst page = await edu.spaces.listSpaces({ subject: \"math\", limit: 20 });\n\n// Update and delete\nconst updated = await edu.spaces.updateSpace(space.id, { name: \"Algebra 102\" });\nawait edu.spaces.deleteSpace(space.id);  // soft-delete, recoverable for 30 days\n```\n\n### Templates\n\n50+ pre-built educational scaffolds are available server-side.\n\n```ts\n// Browse templates\nconst page = await edu.spaces.listTemplates({ subject: \"math\", level: \"B1\" });\nconst template = await edu.spaces.getTemplate(\"tpl-algebra-starter\");\n\n// Create a space from a template, optionally overriding defaults\nconst space = await edu.spaces.createSpaceFromTemplate(\"tpl-algebra-starter\", {\n  name: \"My Algebra Space\",\n  tags: [\"homework\"],\n});\n```\n\n### Sidekick AI streaming\n\nSidekick is an embedded AI agent that observes the space content and emits\nsuggestions, feedback, and follow-up questions over Server-Sent Events.\n\n```ts\nconst ctrl = new AbortController();\n\nfor await (const event of edu.spaces.streamSidekick(space.id, {\n  signal: ctrl.signal,\n  context: { focus: \"quadratic equations\" },\n})) {\n  if (event.type === \"suggestion\") display(event.data);\n  if (event.type === \"feedback\")   displayFeedback(event.data);\n  if (event.type === \"question\")   displayQuestion(event.data);\n  if (event.type === \"reconnecting\") console.warn(`Reconnecting… attempt ${event.attempt}`);\n  if (event.type === \"error\")      console.error(event.error.message);\n  if (event.type === \"done\")       break;\n}\n\nctrl.abort();  // cancel mid-stream\n```\n\nReconnect behaviour mirrors the Tutor service: up to 3 retries with exponential\nbackoff, `Last-Event-ID` on reconnect, and `StreamError` after exhausting retries.\n\n### Permissions\n\n```ts\n// Invite a user\nconst member = await edu.spaces.invite(space.id, {\n  email: \"alice@school.edu\",\n  role: \"editor\",   // \"owner\" | \"editor\" | \"viewer\"\n});\n\n// List members, change role, remove\nconst members = await edu.spaces.listMembers(space.id);\nawait edu.spaces.updateMemberRole(space.id, member.userId, \"viewer\");\nawait edu.spaces.removeMember(space.id, member.userId);\n```\n\n### Metadata\n\n```ts\nconst meta = await edu.spaces.getMetadata(space.id);\n\nawait edu.spaces.updateMetadata(space.id, {\n  tags: [\"math\", \"B2\"],                      // max 20 tags\n  description: \"Updated description\",        // max 1000 chars\n  customFields: { semester: \"spring-2025\" }, // free-form key/value\n});\n```\n\n### Input validation\n\nClient-side Zod schemas guard against bad inputs before the network round-trip:\n\n```ts\nimport {\n  createSpaceParamsSchema,\n  inviteParamsSchema,\n  spaceMetadataSchema,\n} from \"@atzentis/edu-sdk\";\n\n// Validate before calling the service\nconst result = inviteParamsSchema.safeParse({ role: \"editor\" });\nif (!result.success) {\n  console.error(result.error.errors);  // → Exactly one of email or userId must be provided\n}\n```\n\n### Error handling\n\n| Condition | Error |\n| --- | --- |\n| Unknown space, template, or member | `NotFoundError` (404) |\n| Empty name / invalid tags / bad role | `ZodError` (client-side) |\n| Stream drop after max retries | `StreamError` |\n| Self-revoke as sole owner | `BaseError` with `statusCode: 409` |\n\n## Mission Control\n\n`edu.missionControl` gives teachers a real-time view of student sessions, confusion signals, and intervention alerts.\n\n### Real-time session monitoring\n\nStream live session events via SSE. The iterator yields a discriminated union of `MonitorEvent`:\n\n```ts\nconst ctrl = new AbortController();\n\nfor await (const ev of edu.missionControl.monitorSessions(\n  { classroomId: \"cls-1\", subject: \"math\" },\n  { signal: ctrl.signal },\n)) {\n  if (ev.type === \"session-update\") updateSessionUI(ev.data);\n  if (ev.type === \"confusion-signal\") flagStudent(ev.data.studentId, ev.data.score);\n  if (ev.type === \"intervention-alert\") showAlert(ev.data);\n  if (ev.type === \"reconnecting\") showReconnectBanner(ev.attempt);\n  if (ev.type === \"done\") break;\n}\n\n// Cancel the stream at any time\nctrl.abort();\n```\n\nEvent types: `session-update`, `confusion-signal`, `intervention-alert`, `reconnecting`, `error`, `done`.\n\nReconnect behaviour mirrors P03/P04: up to 3 retries with exponential backoff, `Last-Event-ID` sent on resume.\n\n### Student insights\n\n```ts\n// Fetch insights for one student (last 7 days by default)\nconst insights = await edu.missionControl.getStudentInsights(\"stu-1\");\n// { engagementScore: 82, confusionScore: 15, timeOnTaskMinutes: 45, topicsExplored: [...] }\n\n// Narrow to a date range\nconst ranged = await edu.missionControl.getStudentInsights(\"stu-1\", {\n  dateRange: { from: Date.now() - 30 * 86400_000, to: Date.now() },\n});\n\n// Paginated list for a classroom\nconst page = await edu.missionControl.listStudentInsights({ classroomId: \"cls-1\", limit: 20 });\n```\n\n### Intervention alerts\n\n```ts\n// List active (non-dismissed) alerts by severity\nconst page = await edu.missionControl.listInterventions({ severity: \"high\", dismissed: false });\n\n// Fetch a single alert\nconst alert = await edu.missionControl.getIntervention(\"alert-1\");\n\n// Dismiss an alert\nconst dismissed = await edu.missionControl.dismissIntervention(\"alert-1\");\n\n// Apply a teacher action\nconst resolved = await edu.missionControl.actOnIntervention(\"alert-1\", \"resolve\");\n// action: \"acknowledge\" | \"escalate\" | \"resolve\"\n```\n\n### Teacher dashboard\n\n```ts\n// At least one of classroomId or teacherId is required\nconst dashboard = await edu.missionControl.getDashboard({\n  classroomId: \"cls-1\",\n  subject: \"math\",\n  dateRange: { from: Date.now() - 86400_000, to: Date.now() },\n});\n\n// dashboard.activeSessions   — current sessions with status + engagement\n// dashboard.recentAlerts     — top 10 most recent intervention alerts\n// dashboard.topPerformers    — students with highest engagement\n// dashboard.needsAttention   — students flagged for confusion / low engagement\n// dashboard.classroomMetrics — aggregate counts and averages\n```\n\n### Mission Control error handling\n\n| Condition | Error |\n| --- | --- |\n| Empty `studentId` or `interventionId` | `ValidationError` |\n| Unknown student or alert | `NotFoundError` (404) |\n| Neither `classroomId` nor `teacherId` | `ValidationError` |\n| Stream drop after max retries | `StreamError` |\n\n## SmartModules service\n\n`edu.smartModules` exposes three learning widget namespaces: flashcards (with\nSM-2 spaced repetition), quizzes (with auto-grading), and whiteboards (drawing\nprimitives).\n\n### Flashcards\n\nManage decks, cards, and study sessions. The SM-2 algorithm runs server-side;\nthe SDK submits a quality rating (0–5) and receives the updated scheduling state.\n\n**SM-2 quality scale:**\n- `0` — complete blackout (total failure to recall)\n- `1` — incorrect but the correct answer felt familiar\n- `2` — incorrect but the correct answer seemed easy to recall\n- `3` — correct with serious difficulty\n- `4` — correct after hesitation\n- `5` — perfect response\n\n```ts\nconst edu = new EduClient({ apiKey: \"atz_live_xxx\", tenantId: \"school-1\" });\n\n// Create a deck\nconst deck = await edu.smartModules.flashcards.createDeck({ title: \"Spanish Vocab\" });\n\n// Add a card\nawait edu.smartModules.flashcards.createCard(deck.id, {\n  front: \"hola\",\n  back: \"hello\",\n});\n\n// Start a study session (server selects SM-2 due cards)\nconst session = await edu.smartModules.flashcards.startSession(deck.id);\n\n// Record a review answer (quality 0–5)\nconst state = await edu.smartModules.flashcards.recordAnswer(\n  session.id,\n  session.cards[0].id,\n  { quality: 4 },  // correct after hesitation\n);\n// state.nextReviewAt, state.easeFactor, state.intervalDays\n```\n\nYou can also call flashcard methods directly on `edu.smartModules`:\n\n```ts\nawait edu.smartModules.createDeck({ title: \"French Vocab\" });\nawait edu.smartModules.listDecks({ limit: 20 });\n```\n\n### Quiz\n\nQuiz CRUD, question CRUD, and attempt submission with auto-grading. Objective\nquestion types (`multiple_choice`, `true_false`, `short_answer`) are graded\nimmediately. Essay questions return pending grading IDs to poll.\n\n```ts\n// Create a quiz\nconst quiz = await edu.smartModules.quiz.createQuiz({ title: \"Chapter 1\" });\n\n// Add questions\nawait edu.smartModules.quiz.addQuestion(quiz.id, {\n  type: \"multiple_choice\",\n  text: \"What is the capital of France?\",\n  options: [\"London\", \"Paris\", \"Berlin\"],\n  correctIndex: 1,\n});\n\nawait edu.smartModules.quiz.addQuestion(quiz.id, {\n  type: \"essay\",\n  text: \"Explain photosynthesis in your own words.\",\n});\n\n// Submit an attempt\nconst attempt = await edu.smartModules.quiz.submitAttempt(quiz.id, [\n  { questionId: \"q-mc-1\", value: 1 },          // multiple_choice: index\n  { questionId: \"q-essay-1\", value: \"Plants convert sunlight...\" }, // essay: text\n]);\n\n// Poll for essay grading\nif (attempt.pendingGrading?.length) {\n  const graded = await edu.smartModules.quiz.getAttempt(quiz.id, attempt.id);\n}\n```\n\n### Whiteboard\n\nWhiteboard CRUD and drawing primitive management. All primitives are validated\nclient-side via Zod before the network call. The `WhiteboardPrimitive` type is\na discriminated union on `type`.\n\n```ts\n// Create a whiteboard\nconst board = await edu.smartModules.whiteboard.createWhiteboard({\n  title: \"Cell Division Diagram\",\n});\n\n// Add a line\nawait edu.smartModules.whiteboard.addPrimitive(board.id, {\n  type: \"line\",\n  from: { x: 0, y: 0 },\n  to: { x: 200, y: 100 },\n  color: \"#000\",\n  width: 2,\n});\n\n// Add a rectangle\nawait edu.smartModules.whiteboard.addPrimitive(board.id, {\n  type: \"rectangle\",\n  topLeft: { x: 50, y: 30 },\n  size: { width: 120, height: 80 },\n  fill: \"#e8f4f8\",\n  stroke: \"#2c7be5\",\n});\n\n// Add text\nawait edu.smartModules.whiteboard.addPrimitive(board.id, {\n  type: \"text\",\n  position: { x: 60, y: 65 },\n  content: \"Nucleus\",\n  fontSize: 14,\n  color: \"#333\",\n});\n\n// Add a freehand stroke\nawait edu.smartModules.whiteboard.addPrimitive(board.id, {\n  type: \"stroke\",\n  points: [{ x: 10, y: 10 }, { x: 15, y: 20 }, { x: 25, y: 18 }],\n  color: \"#e63946\",\n  width: 3,\n});\n\n// List all elements\nconst page = await edu.smartModules.whiteboard.listPrimitives(board.id);\n\n// Narrow a primitive in TypeScript\nfor (const el of page.items) {\n  if (el.primitive.type === \"line\") {\n    console.log(el.primitive.from, el.primitive.to); // fully typed\n  }\n}\n```\n\n### SmartModules error handling\n\n| Condition | Error |\n| --- | --- |\n| Empty title / required field | `ValidationError` |\n| SM-2 quality out of range (not 0–5 integer) | `ValidationError` |\n| Empty answers array in `submitAttempt` | `ValidationError` |\n| Invalid whiteboard primitive (e.g., empty stroke points) | `ValidationError` |\n| Unknown deck, quiz, or whiteboard | `NotFoundError` (404) |\n\n---\n\n## Annotations\n\nAccess via `edu.annotations`. Exposes five sub-namespaces for rich annotation on educational content: highlights, comments, voice, video, and sharing.\n\n### Highlights\n\n```ts\n// Create a text highlight\nconst hl = await edu.annotations.highlights.create({\n  target: { type: \"document\", documentId: \"doc-1\" },\n  range: { startOffset: 10, endOffset: 50 },\n  color: \"#ffcc00\",\n  note: \"Important passage\",\n});\n\n// List highlights filtered by target\nconst page = await edu.annotations.highlights.list({\n  targetId: \"doc-1\",\n  targetType: \"document\",\n});\n\n// Update color\nawait edu.annotations.highlights.update(hl.id, { color: \"#00aaff\" });\n\n// Delete\nawait edu.annotations.highlights.delete(hl.id);\n```\n\nColor must be a 7-character hex string (e.g. `#ffcc00`). Range offsets must be\nnon-negative integers.\n\n### Comments (threaded)\n\n```ts\n// Create a top-level comment\nconst cmt = await edu.annotations.comments.create({\n  target: { type: \"document\", documentId: \"doc-1\" },\n  content: \"Great point here!\",\n});\n\n// Reply to a comment — parentId is set automatically\nconst reply = await edu.annotations.comments.reply(cmt.id, {\n  target: { type: \"document\", documentId: \"doc-1\" },\n  content: \"Agreed!\",\n});\n\n// List in nested mode (up to 3 levels deep)\nconst thread = await edu.annotations.comments.list({\n  targetId: \"doc-1\",\n  mode: \"nested\",\n});\n\n// Soft-delete preserves thread structure\nawait edu.annotations.comments.delete(cmt.id);\n```\n\n### Voice annotations\n\nThe voice upload flow is two-step — the SDK does not proxy media:\n\n```ts\n// Step 1: request presigned upload URL\nconst { uploadUrl, voiceId } = await edu.annotations.voice.requestUploadUrl({\n  target: { type: \"space\", spaceId: \"space-1\" },\n  mimeType: \"audio/webm\",  // \"audio/webm\" | \"audio/wav\" | \"audio/mpeg\"\n});\n\n// Step 2: PUT the audio file directly to the presigned URL (caller's responsibility)\nawait fetch(uploadUrl, { method: \"PUT\", body: audioBlob });\n\n// Step 3: finalize the upload\nconst annotation = await edu.annotations.voice.complete(voiceId, { durationMs: 5000 });\n\n// Fetch with playback URL\nconst va = await edu.annotations.voice.get(voiceId);\nconsole.log(va.playbackUrl);\n```\n\n### Video annotations and clip extraction\n\n```ts\n// Create a time-anchored annotation\nconst va = await edu.annotations.video.create({\n  videoId: \"vid-1\",\n  startMs: 5000,\n  endMs: 10000,  // optional — omit for a single timestamp\n  note: \"Key concept explained here\",\n  tags: [\"algebra\"],\n});\n\n// Request server-side clip extraction (async job)\nconst job = await edu.annotations.video.requestClip(va.id);\n\n// Poll until ready\nlet result = await edu.annotations.video.getClip(job.jobId);\nwhile (result.status !== \"ready\") {\n  await new Promise((r) => setTimeout(r, 1000));\n  result = await edu.annotations.video.getClip(job.jobId);\n}\nconsole.log(result.clipUrl);\n```\n\n`endMs` must be greater than `startMs` when both are provided.\n\n### Sharing\n\n```ts\n// Share an annotation with a user by email\nawait edu.annotations.sharing.share(annotationId, {\n  email: \"student@school.edu\",\n  role: \"viewer\",  // \"viewer\" | \"editor\"\n});\n\n// Or by userId\nawait edu.annotations.sharing.share(annotationId, {\n  userId: \"u-2\",\n  role: \"editor\",\n});\n\n// List all shares\nconst shares = await edu.annotations.sharing.listShares(annotationId);\n\n// Update a user's role\nawait edu.annotations.sharing.updateShare(annotationId, \"u-2\", \"viewer\");\n\n// Revoke access (returns void / 204)\nawait edu.annotations.sharing.unshare(annotationId, \"u-2\");\n```\n\nThe annotation owner cannot revoke their own access — the server returns 409.\n\n### Flat API\n\nAll sub-namespace methods are also exposed as flat methods directly on the service:\n\n```ts\n// These are equivalent:\nedu.annotations.highlights.create(...)\nedu.annotations.createHighlight(...)\n\nedu.annotations.comments.reply(commentId, ...)\nedu.annotations.replyToComment(commentId, ...)\n\nedu.annotations.voice.requestUploadUrl(...)\nedu.annotations.createVoiceAnnotation(...)\n\nedu.annotations.video.requestClip(annotationId)\nedu.annotations.extractClip(annotationId)\n\nedu.annotations.sharing.share(annotationId, ...)\nedu.annotations.shareAnnotation(annotationId, ...)\n```\n\n### Annotation targets\n\nAll highlight, comment, and voice annotations attach to a typed target:\n\n```ts\n{ type: \"document\", documentId: string }\n{ type: \"video\",    videoId: string    }\n{ type: \"space\",    spaceId: string    }\n{ type: \"message\",  messageId: string  }\n```\n\n### Annotations error handling\n\n| Condition | Error |\n| --- | --- |\n| Invalid hex color (highlights) | `ValidationError` |\n| Empty comment content | `ValidationError` |\n| Unsupported voice MIME type | `ValidationError` |\n| `endMs <= startMs` (video annotations) | `ValidationError` |\n| Neither `userId` nor `email` in share | `ValidationError` |\n| Invalid sharing role | `ValidationError` |\n| Unknown annotation ID | `NotFoundError` (404) |\n| Owner attempts self-revoke | Conflict (409, server-side) |\n\n---\n\n## Examiner\n\nThe Examiner service powers AI-driven speaking exams. Students progress through configurable stages (intro → questions → conclusion). The AI scoring engine evaluates pronunciation, fluency, and accuracy. Sessions produce a test report with score breakdown and feedback.\n\nAccessed via `edu.examiner`.\n\n### Session lifecycle\n\n```ts\nconst edu = new EduClient({ apiKey: \"atz_live_xxx\", tenantId: \"school-1\" });\n\n// Create a session\nconst session = await edu.examiner.createSession({\n  studentId: \"student-42\",\n  examTypeId: \"cefr-b2\",\n  language: \"en\",\n  level: \"B2\",\n  context: \"Preparing for university admission\",\n});\n\n// List and filter sessions\nconst sessions = await edu.examiner.listSessions({\n  studentId: \"student-42\",\n  status: \"scheduled\",\n});\n\n// Fetch a session by ID\nconst current = await edu.examiner.getSession(session.id);\n\n// Terminate a session (transitions to \"completed\")\nconst ended = await edu.examiner.endSession(session.id);\n```\n\n### Stage progression\n\n```ts\n// List all ordered stages for a session\nconst stages = await edu.examiner.getStages(session.id);\n\n// Get the currently active stage\nconst stage = await edu.examiner.getCurrentStage(session.id);\n\n// Advance to the next stage\nconst nextStage = await edu.examiner.advanceStage(session.id);\n```\n\n### Audio response upload\n\nAudio responses use a presigned URL flow to keep the API key out of browser\nstorage and to support large files:\n\n1. Call `requestResponseUploadUrl` to obtain `{ uploadUrl, responseId, expiresAt }`.\n2. PUT the audio file directly to `uploadUrl` (no SDK credentials needed).\n3. Call `completeResponseUpload` to finalize and mark the upload done.\n\nSupported MIME types: `audio/webm`, `audio/wav`, `audio/mpeg`, `audio/ogg`.\n\n```ts\nconst { uploadUrl, responseId } = await edu.examiner.requestResponseUploadUrl(\n  session.id,\n  stage.id,\n  \"audio/webm\",\n);\n\n// Caller performs: await fetch(uploadUrl, { method: \"PUT\", body: audioBlob })\n\nconst response = await edu.examiner.completeResponseUpload(\n  session.id,\n  stage.id,\n  responseId,\n);\n```\n\nFor synchronous (non-audio) stage submissions:\n\n```ts\nconst response = await edu.examiner.submitStageResponse(session.id, stage.id, {\n  audioMime: \"audio/webm\",\n  durationMs: 8500,\n});\n```\n\n### AI scoring\n\nScoring is async. `requestScoring` triggers the job; `pollScoring` waits for\nthe result. All scores are in the range **0–100**.\n\n```ts\nconst scoringJob = await edu.examiner.requestScoring(session.id);\n\n// Option 1: poll until done (resolves with ScoringResult)\nconst ctrl = new AbortController();\nconst result = await edu.examiner.pollScoring(scoringJob.jobId, {\n  intervalMs: 3_000,   // default\n  timeoutMs: 300_000,  // default (5 min)\n  signal: ctrl.signal,\n});\n\nconsole.log(`Overall: ${result.overall}/100`);\nconsole.log(result.feedback);\n// result.perStage contains per-stage scores\n\n// Option 2: poll manually\nconst job = await edu.examiner.getScoringResult(scoringJob.jobId);\nif (job.status === \"completed\" && job.result) {\n  // use job.result\n}\n\n// Per-stage scoring (partial, during exam)\nconst stageJob = await edu.examiner.requestStageScoring(session.id, stage.id);\nconst stageScore = await edu.examiner.getStageScore(session.id, stage.id);\n\n// Human-readable summary (pure function)\nconst summary = edu.examiner.formatScoreSummary(result);\n// \"Overall: 82/100 | Pronunciation: 80 | Fluency: 85 | Accuracy: 78\\n\\nFeedback: ...\"\n```\n\n### Report generation\n\nReports are generated asynchronously. Download URLs are valid for **60 minutes**\nfrom job completion.\n\nSupported formats: `pdf`, `html`, `json`.\n\n```ts\nconst reportJob = await edu.examiner.requestReport(session.id, \"pdf\");\n\n// Poll until ready\nconst report = await edu.examiner.pollReport(reportJob.jobId, {\n  intervalMs: 2_000,   // default\n  timeoutMs: 120_000,  // default (2 min)\n});\n\nconsole.log(report.downloadUrl); // valid 60 min\n```\n\n### Session monitoring (SSE)\n\n`monitorSession` returns an `AsyncIterable<ExamMonitorEvent>` over a live SSE\nstream. The stream terminates on a `completed` event or when the caller cancels.\n\n```ts\nconst ctrl = new AbortController();\n\nfor await (const event of edu.examiner.monitorSession(session.id, { signal: ctrl.signal })) {\n  switch (event.type) {\n    case \"stage.advanced\":\n      console.log(\"New stage:\", event.data.stage.type);\n      break;\n    case \"response.received\":\n      console.log(\"Response recorded:\", event.data.responseId);\n      break;\n    case \"scoring.update\":\n      console.log(\"Scoring status:\", event.data.status);\n      break;\n    case \"completed\":\n      console.log(\"Exam complete\");\n      break;\n    case \"error\":\n      console.error(event.error.message);\n      break;\n  }\n}\n```\n\n### Examiner error handling\n\n| Condition | Error |\n| --- | --- |\n| Empty `studentId` / `sessionId` / `jobId` | `ValidationError` |\n| Unsupported audio MIME type | `ValidationError` |\n| Invalid report format | `ValidationError` |\n| Unknown session / job ID | `NotFoundError` (404) |\n| Scoring job failed | Error with `cause` set to `job.error` |\n| Report job failed | Error with `cause` set to `job.error` |\n| `pollScoring` / `pollReport` timeout | Error: `timed out after Nms` |\n| AbortSignal cancelled | `DOMException(\"Polling aborted\", \"AbortError\")` |\n| Advance before current stage complete | Conflict (409, server-side) |\n\n---\n\n## Errors\n\nEvery non-OK response is mapped to a typed `BaseError` subclass:\n\n| Status | Class                  |\n| -----: | ---------------------- |\n|    400 | `ValidationError`      |\n|    401 | `AuthenticationError`  |\n|    403 | `PermissionError`      |\n|    404 | `NotFoundError`        |\n|    429 | `RateLimitError`       |\n|    5xx | `ServerError`          |\n\nNetwork/timeout failures throw `NetworkError`. The transport automatically\nretries `RateLimitError` (honoring the `Retry-After` header) and `ServerError`\nwith exponential backoff up to `maxRetries`.\n\n## Exams service\n\nThe `exams` service wraps `/v1/exams/*` endpoints. It is distinct from the\n`examiner` service (which runs AI-graded sessions); this service manages the\nstatic exam type catalog, the prompt library, and slot scheduling.\n\nAccessed via `client.exams`.\n\n### Exam type catalog\n\n```ts\nimport { EduClient } from \"@atzentis/edu-sdk\";\n\nconst edu = new EduClient({ apiKey: \"atz_live_xxx\", tenantId: \"school-123\" });\n\n// List with filters\nconst page = await edu.exams.listExamTypes({ level: \"B2\", language: \"en\" });\nfor (const type of page.items) {\n  console.log(type.id, type.provider);\n}\n\n// Single exam type\nconst ielts = await edu.exams.getExamType(\"ielts-speaking\");\nconsole.log(ielts.durationMinutes);  // e.g. 15\n```\n\n### Exam prompts\n\n```ts\n// Create a prompt\nconst prompt = await edu.exams.createPrompt({\n  examTypeId: \"cefr-b2\",\n  stageType: \"question\",\n  content: \"Describe your ideal workplace.\",\n  language: \"en\",\n  level: \"B2\",\n  tags: [\"workplace\", \"describe\"],\n});\n\n// List prompts filtered by exam type and stage\nconst prompts = await edu.exams.listPrompts({\n  examTypeId: \"cefr-b2\",\n  stageType: \"question\",\n});\n\n// Update — only the provided fields change; id is preserved\nconst updated = await edu.exams.updatePrompt(prompt.id, { level: \"C1\" });\n\n// Soft-delete — prompt is hidden from list but resolvable by id\nawait edu.exams.deletePrompt(prompt.id);\n```\n\nValidation is applied client-side before the round-trip:\n- `createPrompt`: empty `content` or `examTypeId` throws `ValidationError`\n- `updatePrompt`: empty `content` patch throws `ValidationError`\n- Invalid `stageType` throws `ValidationError` (must match P08 `StageType`)\n\n### Exam scheduling\n\n```ts\n// Find available slots for an exam type\nconst slots = await edu.exams.listAvailableSlots({\n  examTypeId: \"cefr-b2\",\n  from: Date.now(),\n  to: Date.now() + 7 * 24 * 60 * 60 * 1000,  // next 7 days\n  timezone: \"Europe/Berlin\",  // DST hint\n});\n\n// Inspect a single slot\nconst slot = await edu.exams.getSlot(slots.items[0].id);\nconsole.log(`${slot.capacity - slot.bookedCount} seats remaining`);\n\n// Book a slot\nconst booking = await edu.exams.bookSlot(slot.id, { studentId: \"student-42\" });\nconsole.log(booking.status);  // \"pending\" or \"confirmed\"\n\n// List bookings (filterable by slotId, studentId, status)\nconst myBookings = await edu.exams.listBookings({ studentId: \"student-42\" });\n\n// Cancel\nconst cancelled = await edu.exams.cancelBooking(booking.id);\nconsole.log(cancelled.cancelledAt);\n```\n\nScheduling rules:\n- `examTypeId` is required for `listAvailableSlots` — throws `ValidationError` if empty\n- Slot capacity conflicts return 409 from the server\n- All times are stored and returned as Unix milliseconds (UTC); `timezone` is a\n  DST hint for server-side slot filtering\n\n### Error handling\n\n| Condition | Error |\n| --- | --- |\n| Empty ID parameter | `ValidationError` (client-side) |\n| Empty `content` on create/update | `ValidationError` (client-side) |\n| Unknown exam type / prompt / slot / booking | `NotFoundError` (404) |\n| Slot fully booked | `409 Conflict` (server-side) |\n| Past-dated slot booking | `400 Bad Request` (server-side) |\n\n## License\n\nMIT\n","readmeFilename":"README.md","_rev":"1-263d749105d46261934f8b2e9d06dd7b"}