npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@atzentis/edu-sdk

v0.2.0

Published

Atzentis Edu SDK — TypeScript client for edu.atzentis.io

Downloads

18

Readme

@atzentis/edu-sdk

TypeScript SDK for the Atzentis EDU API at edu.atzentis.io.

Install

pnpm add @atzentis/edu-sdk

Quick 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-ID so the server skips already-delivered tokens.
  • reconnecting events let you show a UI indicator between attempts.
  • After all retries are exhausted, StreamError is 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 days

Templates

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-stream

Reconnect 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 averages

Mission 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 familiar
  • 2 — incorrect but the correct answer seemed easy to recall
  • 3 — correct with serious difficulty
  • 4 — correct after hesitation
  • 5 — 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.intervalDays

You 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:

  1. Call requestResponseUploadUrl to obtain { uploadUrl, responseId, expiresAt }.
  2. PUT the audio file directly to uploadUrl (no SDK credentials needed).
  3. Call completeResponseUpload to 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 min

Session 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. 15

Exam 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: empty content or examTypeId throws ValidationError
  • updatePrompt: empty content patch throws ValidationError
  • Invalid stageType throws ValidationError (must match P08 StageType)

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:

  • examTypeId is required for listAvailableSlots — throws ValidationError if empty
  • Slot capacity conflicts return 409 from the server
  • All times are stored and returned as Unix milliseconds (UTC); timezone is 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