- **Plan Mode** — AI analyzes input (document or prompt) and proposes a course structure. The teacher reviews, edits, and approves before anything is created.
- **Agent Mode** — AI executes approved actions: creating sections, lessons, exercises, and populating content. Shows real-time progress.
The client already has course content loaded in Svelte stores when the teacher is on a page. The agent:
- **Reads context from the client** — current lesson text, exercise questions, course structure are passed with each message. No extra API calls to "read" content.
- **Writes through the API** — all mutations route through existing service functions with the full middleware stack (`authMiddleware` -> `orgMemberMiddleware` -> `courseTeamMemberMiddleware`). Permission enforcement, single source of truth, and auditability for free.
- **Previews before bulk changes** — Plan Mode shows the full proposed structure before creating anything. For individual edits, the agent presents changes for review before applying.
The LLM sees a set of **tools** (mapped to existing service functions) and decides which to call based on the teacher's message. The agent API executes the tool with full permission checks, then feeds the result back to the LLM for a human-friendly summary.
A typical plan with 4 sections and 12 lessons needs ~40+ tool calls (create section + create lesson + write content + create exercise for each). This exceeds what a single `streamText()` call can handle in one pass. Plan execution uses a **sequential multi-round approach**:
2. The first `streamText()` round creates all sections and the first few lessons (within `maxSteps: 15`)
3. When `maxSteps` is reached, the `onFinish` callback automatically sends a continuation message: "Continue implementing the plan from where you left off"
4. The agent sees the conversation history (including tool results from prior rounds) and picks up where it stopped
5. This repeats until the plan is fully executed
6. Each round checks the token balance before starting — if tokens run out mid-plan, the agent stops cleanly and reports what was completed
Within each round, the agent:
- Creates sections first (in order), capturing the returned section IDs
- Creates lessons within each section (in order), capturing lesson IDs
- For each lesson, generates full content based on the source document + lesson description, then calls `update_lesson_content` to populate it
- Optionally creates exercises with generated questions for lessons marked `hasExercise`
**Progress tracking**: During plan execution, the agent emits structured progress events that the UI renders as a checklist. This persists across multi-round execution:
Plan execution can fail partway through — tokens run out, LLM rate limit, network error, or teacher hits Stop. When this happens:
- **Already-created content is kept.** Sections and lessons created before the failure exist in the database and are visible in the course content tree.
- **The progress card shows what was completed vs. what remains.** Completed items have checkmarks; the failed/stopped item shows an error or stop icon; remaining items show empty circles.
- **The teacher can resume.** After the failure, the chat shows a "Resume" button. Clicking it sends a message: "Continue implementing the plan." The agent calls `get_course_structure` to see what already exists, compares it against the plan, and picks up from the first uncreated item.
- **Token exhaustion is explicit.** If the failure was due to running out of tokens, the progress card shows: "Paused — token limit reached. [Buy more credits] or wait for reset on [date]." The plan state is preserved in the conversation — the teacher can resume after purchasing credits or next month.
### Existing Course Content
Before executing a plan, the agent checks if the course already has content via `get_course_structure`. If sections or lessons already exist:
- The agent reports what it found: "This course already has 3 sections and 8 lessons."
- It asks the teacher how to proceed: **"Should I add the new content after the existing sections, or would you like to start fresh?"**
- **Append** (default): new sections are created with `order` values starting after the last existing section. Existing content is untouched.
- **Start fresh**: the agent warns that this is destructive ("This will not delete existing content, but the new sections will be added alongside it. You can remove old content manually."). In v1, the agent cannot delete content — it can only append. Deletion tools are added in Phase 4.
### Token Cost Estimation
Before executing a plan, the agent estimates the token cost and warns the teacher:
- The estimate is based on plan size: `(sectionCount * ~500) + (lessonCount * ~3,000) + (exerciseCount * ~1,500)` tokens per item (rough heuristic based on typical generation lengths + tool call overhead)
- Displayed in the plan card before "Implement Plan": *"Estimated cost: ~45,000 tokens (9% of your monthly allowance)"*
- If the estimate exceeds 50% of remaining tokens: stronger warning — *"This plan will use a significant portion of your remaining tokens (estimated ~45,000 of 62,000 remaining). Proceed?"*
- This is a best-effort estimate, not a guarantee. Actual usage varies based on content complexity.
Document upload is a premium feature — only available to orgs on the **EARLY_ADOPTER** plan or higher. BASIC (free) plan users cannot upload documents.
- The attachment icon in the chat input is visible to all teachers (so they discover the feature)
- When a BASIC plan user clicks it, the file picker does NOT open. Instead, the **upgrade modal** is triggered immediately (same `openUpgradeModal()` pattern used by certificates, team invites, etc.)
- For EARLY_ADOPTER+ users, the file picker opens normally
- Server validates file size before parsing. Files over 5MB are rejected with `413 { error: "file_too_large", maxSize: 5242880 }`
- Client validates before upload as well (check `file.size` on selection) and shows an inline error: "File is too large. Maximum size is 5MB."
- 5MB is sufficient for most textbook chapters, syllabi, and course outlines. Full textbook PDFs (50MB+) are out of scope — teachers should upload individual chapters.
Only one document can be uploaded per conversation. If the teacher uploads a second file, it replaces the first — the previous `documentId` in Redis is deleted and the new one takes its place. This keeps the context simple and avoids confusion about which document the agent is referencing. If a teacher needs to combine multiple sources, they should merge them into one document before uploading.
- **Truncates** extracted text to a maximum of 100,000 characters (~75K tokens) to stay within LLM context limits. If truncated, the response includes `truncated: true` so the UI can show: "Document was large — only the first portion was analyzed."
**PDF**: Use `pdf-parse` (lightweight, pure JS, no native dependencies). Extracts text content from all pages. Handles multi-column layouts reasonably well for structured content like textbooks/syllabi.
**DOCX**: Use `mammoth` (pure JS). Extracts text content preserving heading structure (H1, H2, etc.) which helps the LLM understand document hierarchy for course structure generation.
**PPTX**: Use `pptx-parser` (pure JS). Extracts text from slides, preserving slide order as a natural section boundary. Slide titles become potential lesson titles; slide body text becomes content context. This covers the common case of teachers converting existing slide decks into courses.
The extracted text is NOT returned in the upload response (it can be very large). It lives in Redis and is fetched internally by the agent chat endpoint when `documentId` is provided in the chat message.
A thin, backend-focused package consumed by `apps/api`. No UI code. The heavy lifting is done by the Vercel AI SDK; this package provides project-specific configuration on top.
- **Provider factory** — creates the right AI SDK `LanguageModel` for a given provider (`createModel`); `getProviderConfigForProvider(provider)` reads the per-provider key (`OPENAI_API_KEY` / `GOOGLE_API_KEY` / `ANTHROPIC_API_KEY`); `pickAnyConfiguredProvider()` returns the first provider with a key set, used by the status route and title generation
- **Role-aware tool registry** — `getToolSchemas(role)` returns Zod schemas per role
- **Role-aware system prompt** — `buildSystemPrompt(context)` generates different LLM instructions for teachers vs students, including Plan Mode vs Agent Mode behavior
- **Course plan types** — `CoursePlan`, `CoursePlanSection`, `CoursePlanLesson` types used by both API and dashboard
The agent loop itself is NOT in this package — `streamText()` from `ai` core handles the full loop (prompt -> LLM -> tool call -> execute -> respond -> repeat). The Hono route assembles tools (schema from package + execute from services) and calls `streamText`.
-`'teacher'` — can read AND mutate course content (create sections, update lessons, generate questions, execute plans)
-`'student'` — can only read and discuss content (explain concepts, answer questions, quiz on material). Gated off in v1.
### Role detection — server-side, automatic
- API route checks user's course membership via existing `isCourseTeamMemberOrOrgAdmin`
- Team members (ADMIN/TUTOR role in `groupmember`) get `role: 'teacher'`
- Regular members (STUDENT role) get `role: 'student'`
- In v1, `role: 'student'` returns 403
### Tool registry — `getToolSchemas(role)`
-`shared` tools (read-only): available to both roles
-`teacher` tools (mutations): teacher only — includes section/lesson creation, content updates, plan execution
-`student` tools (v2): student only, stubs for now
Teachers get `shared + teacher`. Students get `shared + student`. Students can never access mutation tools, even via prompt injection.
### Single API endpoint
`/agent/chat` serves both roles. Role is detected server-side (not passed by the client, preventing spoofing). The endpoint loads tools and prompt based on the detected role.
| `create_exercise` | write | Create exercise with questions and options | `createExercise({ courseId, lessonId, sectionId, title, questions })` |
| `add_questions` | write | Add questions to an existing exercise | `updateExerciseService` (diff-based) |
| `generate_course_plan` | agent | Analyze text and return structured CoursePlan JSON | No service call — LLM generates the plan structure |
**Note on `generate_course_plan`**: This is a special tool. Unlike other tools that call services, this tool's "execution" is the LLM itself generating a structured JSON response conforming to the `CoursePlan` schema. The tool schema defines the output format so the LLM returns valid, parseable JSON rather than free-form text. The chat UI detects this tool's result and renders the plan view component.
- Cannot manage course members, invitations, or roles
- Cannot grade submissions or assign marks
- Cannot handle payments or attendance
- Cannot upload files, images, videos, or documents to lessons (the agent can receive documents for analysis, but cannot attach media to course content)
- Cannot delete sections, lessons, or exercises (v1 — added in Phase 2)
- Cannot reorder content (v1 — added in Phase 2)
- Cannot manage org settings, members, or billing
- Cannot access data from other courses or organizations
- Cannot browse the internet or fetch external URLs
`upsertLessonLanguageService` requires a `locale` parameter. The agent determines the locale as follows:
1.**Check existing course content first.** If the course already has lessons with content in a specific locale (e.g., lessons with `lesson_language` rows in `fr`), use that locale. This ensures consistency with existing content.
2.**If the course is empty**, default to `en`.
3.**For document uploads**, the agent does NOT auto-detect the document's language. The content is generated in the course's locale regardless of the source document language. If a teacher uploads a French textbook but the course is in English, the agent generates English content based on the French source material. This is a feature, not a bug — teachers often want to create courses in a different language than their source material.
4.**The teacher can override.** If they say "Write the content in Spanish," the agent uses `es`. The system prompt instructs: "Default to the course's existing locale. If the teacher requests a specific language, use that locale instead."
Supported locales match the existing `lesson_language.locale` enum: `en`, `hi`, `fr`, `pt`, `de`, `vi`, `ru`, `es`, `pl`, `da`.
---
## Lesson Content Format
The lesson editor uses **TipTap** (ProseMirror-based) and expects `HTMLContent` — standard HTML that TipTap can parse. The agent must generate content that renders correctly in the editor.
### Allowed HTML Tags
The system prompt includes explicit formatting guidance:
```
When generating lesson content, use only these HTML elements:
- <h2>, <h3> for section headings (never <h1> — that's the lesson title)
- <p> for paragraphs
- <ul><li> for unordered lists, <ol><li> for ordered lists
- <strong> for bold, <em> for italic
- <blockquote> for callouts or important notes
- <code> for inline code, <pre><code> for code blocks
- <a href="..."> for links (only if referencing a specific URL the teacher provided)
Do NOT use: <div>, <span>, <table>, <img>, <iframe>, <script>, <style>,
or any custom elements. Content is sanitized before storage — unsupported
tags will be stripped.
```
This aligns with TipTap's default schema and the existing `sanitizeHtml()` / `sanitizeOptionalHtml()` functions used throughout the codebase.
### Content Length Guidance
The system prompt includes guidance on lesson length:
```
When writing lesson content:
- Aim for 800-2,000 words per lesson (roughly 3-8 minutes reading time)
- Use headings to break content into scannable sections
- Include practical examples where relevant
- For plan execution: match the depth to the lesson description — a lesson
described as "brief overview" should be shorter than one described as
| **BASIC** | 0 tokens | Not available | Not available — triggers upgrade modal |
| **EARLY_ADOPTER** | 500K tokens/month (~50-100 conversations) | Available | Available (max 5MB per file) |
| **ENTERPRISE** | 2M tokens/month (or unlimited) | Available | Available (max 5MB per file) |
Allowance resets on the 1st of each month.
Document upload is gated by plan, not by token balance. Even if an EARLY_ADOPTER has tokens remaining, a BASIC user still cannot upload. This is a feature gate, not a usage gate.
### Extra Credit Packs
- One-time purchases, do NOT expire, do NOT reset monthly
- Deduction order: monthly allowance first, then credits
2.**Tool-level permission checks** — tool set IS the security boundary. Students cannot call mutation tools. Each tool's execute function re-verifies resource ownership.
3.**Token budget enforcement** — every LLM call checks remaining balance. 402 if exhausted.
Agent actions are tracked via **Tinybird** — a real-time analytics backend for high-volume event data. This keeps observability data out of the main Postgres database and provides instant SQL-based querying for debugging and analytics.
### Why Tinybird (not Postgres)
- Agent events are **append-only, high-volume** — every tool call, every conversation turn generates an event. This is analytics workload, not transactional.
- The main Postgres DB should not carry this load. Token usage tracking (`ai_token_usage` table) stays in Postgres because it's used for balance enforcement (transactional). Observability events go to Tinybird.
- Tinybird provides **real-time ingestion** via HTTP API and **instant SQL querying** — investigate issues within seconds of them happening.
- Dashboards and alerts can be built on top without custom infrastructure.
Events are sent to Tinybird via its **Events API** (`POST https://api.tinybird.co/v0/events`). The API fires events asynchronously (fire-and-forget) — agent performance is never blocked by analytics.
}).catch(()=>{});// Fire-and-forget, never block the agent
}
```
### Environment Variables
-`TINYBIRD_TOKEN` — Tinybird API token for event ingestion
-`TINYBIRD_BASE_URL` — Tinybird API base URL (default: `https://api.tinybird.co`)
When not configured (self-hosted, or dev environments), events are silently skipped. No errors, no impact on functionality.
### What This Enables
- **Debugging**: "Teacher X says the AI created wrong content" -> query `agent.tool.called` and `agent.tool.completed` events for that user + course to see exactly which tools fired and what arguments were passed
- **Usage analytics**: aggregate tool call frequency, popular actions, average conversation length per org
- **Error monitoring**: track `agent.chat.error` and `agent.tool.failed` rates by model, tool, or org
- **Plan execution monitoring**: track `agent.plan.executed` to see completion rates, average plan sizes, how often teachers modify vs. accept plans as-is
- **Cost analysis**: correlate token usage with tool call patterns to understand what drives cost
- **Self-hosted**: AI assistant **disabled by default**. To enable, admin sets at least one of `OPENAI_API_KEY`, `GOOGLE_API_KEY`, or `ANTHROPIC_API_KEY`. The dashboard model picker exposes Gemini 2.5 Flash (Google) and GPT-4o (OpenAI); each option requires its own provider key. Anthropic is supported in code but not exposed in the picker. The assistant button is hidden when no provider key is set. No token metering for self-hosted (user's own API key, their cost).
"AI Assistant" button in the course sidebar navigation. In v1, only visible to teachers/admins. Hidden when self-hosted and no AI key configured.
### Panel
Right sidebar using `@cio/ui` Sheet (`side="right"`, ~420px). Does NOT replace the left sidebar — both can be open simultaneously.
### Chat Interface
- Message list with user/assistant turns
- Text input at bottom with send button and **attachment icon** (for PDF/DOCX upload)
- Streaming token display (ChatGPT-like typing)
- Quick-action chips (context-dependent)
- Stop button while streaming
### File Upload in Chat
- Attachment icon next to the text input (paperclip icon)
- **BASIC plan users**: clicking the attachment icon triggers the upgrade modal (`openUpgradeModal()`). File picker does not open. A subtle tooltip or label can hint "Upgrade to upload documents".
- **Client-side size check**: if selected file exceeds 5MB, show inline error below the input: "File is too large. Maximum size is 5MB." File is not uploaded.
- Selected file shows as a chip above the input: `[Biology-Textbook.pdf (24 pages)] [x]`
- On send, file is uploaded first (`POST /agent/upload`), then `documentId` is included in the chat message
- While uploading: chip shows spinner. While parsing: "Analyzing document..."
- If server returns 403 (`document_upload_requires_upgrade`): trigger upgrade modal
- After parsing: agent enters Plan Mode and proposes a course structure
### Plan View Component
When the agent returns a `generate_course_plan` tool result, the chat renders a **plan card** instead of plain text:
- "Ask for Changes" button -> focuses the text input with a prompt to describe changes
- Teacher can also just type feedback naturally: "Add a section on ecology"
### Agent Mode Progress
During plan execution, a progress card replaces the plan card:
```
+------------------------------------------+
| Implementing Course Plan |
| |
| [x] Section: Cell Biology |
| [x] Lesson: Cell Structure |
| [x] Content written (1,850 words) |
| [x] Exercise: 5 MCQ questions |
| [~] Lesson: Cell Division |
| [ ] Content |
| [ ] Exercise |
| [ ] Section: Genetics |
| [ ] Lesson: DNA and Gene Expression |
| [ ] Lesson: Heredity |
| |
| Creating lesson content... |
| [Stop] |
+------------------------------------------+
```
- Checkmarks for completed steps
- Spinner for in-progress step
- Empty circles for pending steps
- Stop button to halt execution (already-created content is kept)
### Role-Aware Quick Actions
Teacher (v1):
- "Upload a document to create a course"
- "Draft a lesson"
- "Generate questions from this lesson"
- "Improve this lesson's text"
Student (v2):
- "Explain this lesson"
- "Quiz me on this topic"
- "Help me with this exercise"
### Context Awareness
Client passes `courseId`, current `lessonId` or `exerciseId`. The role is NOT sent from the client — detected server-side. The system prompt includes: "The teacher is currently viewing lesson/exercise ID: {id}".
### Store Refresh
`onFinish` callback fires after the agent completes. Refreshes the relevant store:
- After plan execution: full course content tree refresh
- After lesson update: `lessonApi.get()` reload
- After exercise creation/update: exercise data reload
---
## Streaming
The Vercel AI SDK provides structured SSE via `toUIMessageStreamResponse()`:
- Text chunks (tokens stream one by one)
- Tool call events (when LLM decides to call a tool)
- Tool results (when tool execution completes)
- Finish signals
On the frontend, the `Chat` class from `@ai-sdk/svelte` consumes this protocol automatically. No manual SSE parsing.
---
## API Routes
New route domain: `/agent`
| Method | Route | Description | Auth |
|--------|-------|-------------|------|
| `GET` | `/agent/status` | Feature flag + role + usage info | auth + orgMember |
-`OPENAI_API_KEY` — enables GPT-4o (and `gpt-4o-mini` for title generation)
-`GOOGLE_API_KEY` — enables Gemini 2.5 Flash
-`ANTHROPIC_API_KEY` — enables Claude (Sonnet 4.5 / Haiku for titles); supported in code, not in the picker UI
-`TINYBIRD_TOKEN` — Tinybird API token for event ingestion (optional; events silently skipped when not set)
-`TINYBIRD_BASE_URL` — Tinybird API base URL (default: `https://api.tinybird.co`)
The dashboard model picker (above the chat input) is persisted per browser in `localStorage` under `classroomio-ai-chat-model`. Each chat request sends a `model` field; `apps/api/src/routes/agent/chat.ts` looks it up in `AGENT_MODELS` (`packages/utils/src/agent-models`) to resolve provider + backend model id, then 503s with `AI_NOT_CONFIGURED` if that provider's key is missing. Anthropic stays in `AGENT_MODELS` so backend code keeps working; it's gated out of the UI by the `UI_PICKER_MODEL_IDS` subset.