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