This is the full developer documentation for Itera lesson-authoring docs
# Itera Docs
> Author lessons in Itera from an AI assistant. One MCP endpoint, an author-role token, and the whole Program → Unit → Lesson → Activity surface.
## What is Itera authoring?
[Section titled “What is Itera authoring?”](#what-is-itera-authoring)
**Itera** is a lesson platform. This site documents how **whoever creates the lessons** — an internal DFL teacher, a partner company’s instructor, or an automated content pipeline — authors them.
Authoring happens through **`itera-mcp`**, a [Model Context Protocol](https://modelcontextprotocol.io) server live at `https://mcp.iterahq.dev/mcp`. An AI assistant (Claude Code, Cursor, codex, the Anthropic SDK, or any MCP client) drives **21 tools** over the Itera content graph, and every write runs **as you**, under Row-Level Security keyed to your Itera identity and an `author` role. There is **no `service_role`** — the MCP never bypasses your permissions.
One MCP endpoint
`https://mcp.iterahq.dev/mcp` — a Streamable-HTTP MCP. Add it to any client with an `Authorization: Bearer ` header and the 21 authoring tools appear.
Author-role, RLS-scoped
You present an **Itera Supabase user-JWT** with an `author` membership. Writes succeed only in tenants where you hold an authoring role — enforced in Postgres, not in the app.
Program → Unit → Lesson → Activity
A four-level content graph. Ten activity kinds (diagram, document, quiz, code, concept, single/multi choice, match, order, video). `spec` + `rubric` are free-form JSON.
LLM-native docs
This whole site is published as [llms.txt / llms-full.txt](/llms/) — point an LLM at one URL and it learns the entire authoring surface.
## The model in one picture
[Section titled “The model in one picture”](#the-model-in-one-picture)
```
flowchart LR
client["MCP client
Claude Code · Cursor · codex · SDK"]
subgraph auth["Auth — once"]
jwt["Itera Supabase JWT
author role · RLS-scoped"]
end
mcp["itera-mcp
mcp.iterahq.dev/mcp"]
subgraph contentGraph["Content graph (itera.* schema)"]
direction TB
program["Program"]
unit["Unit"]
lesson["Lesson"]
activity["Activity
kind · spec · rubric"]
program --> unit --> lesson --> activity
end
api["itera-api
api.iterahq.dev · pull-only"]
db[("Itera Supabase
RLS — as YOU")]
client -->|"Bearer <JWT>"| mcp
client -.->|"first run"| jwt
jwt -.->|"token"| client
mcp -->|"create_* / update_* (as you)"| contentGraph
contentGraph --> db
api -->|"read results / progress (tenant key)"| db
```
**Authoring is MCP** (write, user-JWT + `author` RLS). **Reading learners’ results back out** is `itera-api` (pull-only REST, per-tenant API key). There is **no REST write/authoring API** today — authoring in v1 is MCP-only.
## Who this is for
[Section titled “Who this is for”](#who-this-is-for)
[Internal DFL teachers ](/auth/#internal-dfl-teacher)You already have a DFL identity. Federate into an Itera session, then author with the author role.
[DFL as a consumer of Itera ](/reference/results-api/)Read learners' results, progress, and artifacts back with a per-tenant API key; frame the player via embed SSO.
[Third-party instructors ](/auth/#third-party-instructor)Your company gets an Itera tenant + author-role accounts. Author via itera-mcp, RLS-scoped to your tenant.
[Content-injection pipelines ](/recipes/content-injection/)Populate programs/units/lessons/activities programmatically — a use-case of the same author role.
## Where to go next
[Section titled “Where to go next”](#where-to-go-next)
[Getting started (end-to-end) ](/getting-started/)From zero to your first create\_program call: prerequisites → token → client config → first tool call.
[Authentication ](/auth/)Per-audience auth matrix: internal DFL SSO federation, third-party author-role JWTs, and per-tenant API keys.
[The lesson model ](/model/)Program → Unit → Lesson → Activity, the ten activity kinds, and the spec/rubric/title\_i18n shapes.
[Authoring reference ](/reference/)All 21 itera-mcp tools with their real inputs and outputs, grouped by level.
# Authentication
> Per-audience auth for Itera — internal DFL SSO federation, third-party author-role Supabase JWTs, and per-tenant API keys for reading results.
Itera has **two credential types**, and which one you use depends on **who you are** and **what you’re doing**:
* **Authoring** (write lessons) → an **Itera Supabase user-JWT** carrying an `author` membership. Used against **`itera-mcp`**. RLS-scoped to your tenant.
* **Reading results** (pull learners’ activity results / progress / artifacts) → a **per-tenant API key** (`itera__`). Used against **`itera-api`**. **Read-only.**
The MCP never has a “god mode” view. Every authoring call runs **as you** under Row-Level Security — there is no `service_role` in `itera-mcp`.
## The per-audience matrix
[Section titled “The per-audience matrix”](#the-per-audience-matrix)
| Audience / action | Identity | Credential | Surface |
| ------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------ | ------------------- |
| Internal DFL teacher — **author** | DFL SSO → Itera federation | DFL login → `/v1/embed/session` → **Itera Supabase JWT** (`author` role) | `itera-mcp` |
| DFL-as-consumer — **read results** | tenant (`dfl-batch`) | **per-tenant API key** `itera__` | `itera-api` `/v1/*` |
| DFL-as-consumer — **embed player** | DFL fellow (iframe) | DFL access token → `/v1/embed/session` | `itera-api` embed |
| Third-party instructor — **author** | Itera Supabase account (their tenant) | **Itera Supabase JWT** (`author` role) | `itera-mcp` |
| Third-party — **read results** | their tenant | **per-tenant API key** | `itera-api` `/v1/*` |
| Third-party — **inject content only** | Itera account | Itera JWT (`author` role) | `itera-mcp` |
Each row is expanded below. **No credential values ever appear in these docs** — only the shape (`itera__`) and where to request one.
## The authoring auth flow (itera-mcp)
[Section titled “The authoring auth flow (itera-mcp)”](#the-authoring-auth-flow-itera-mcp)
1. **Present a JWT.** Your client sends `Authorization: Bearer ` — an Itera Supabase access token — on every MCP request.
2. **HS256 verification.** The server validates the token’s signature against the **Itera** project’s JWT secret. Wrong-project tokens are rejected (`-32001`); anonymous calls are rejected (missing header).
3. **Per-request, JWT-scoped Supabase client.** The validated JWT is attached to a `@supabase/supabase-js` client, so Postgres sees `auth.uid()` = your token’s `sub`. `itera.current_itera_user_id()` maps that to your `itera.users.id`, and `itera.current_author_tenant_ids()` returns the tenants where you hold an authoring role.
4. **RLS decides writes.** `create_* / update_* / delete_* / instantiate_program` succeed only when the target row’s tenant is in your author-tenant set — enforced by the `*_author_insert/update/delete` policies. Learners get tenant-scoped reads but are **rejected on writes**.
```
flowchart LR
jwt["Itera Supabase JWT
author membership"]
mcp["itera-mcp
HS256 verify → per-request client"]
rls["itera.* RLS
current_author_tenant_ids()"]
db[("Itera Supabase")]
jwt -->|Bearer| mcp --> rls --> db
```
## Internal DFL teacher
[Section titled “Internal DFL teacher”](#internal-dfl-teacher)
You already have a DevFellowship identity. Itera treats **DFL as a federated identity provider**: you exchange a verified DFL session for a freshly minted **Itera** session — no second password, no cross-Supabase foreign key.
1. **Get your DFL access token** (your normal DFL Supabase session — e.g. the one dfl-learn already holds).
2. **Exchange it** for an Itera session:
```bash
curl -X POST https://api.iterahq.dev/v1/embed/session \
-H "Content-Type: application/json" \
-d '{ "dfl_token": "YOUR_DFL_ACCESS_TOKEN", "tenant": "dfl-batch" }'
```
Response:
```json
{
"access_token": "",
"refresh_token": "",
"expires_in": 3600,
"itera_user_id": ""
}
```
3. **Use `access_token`** as your `itera-mcp` bearer (see [Getting started → step 2](/getting-started/#2--configure-your-client)).
Federation, not a shared DB
`/v1/embed/session` verifies your DFL JWT (issuer + expiry), finds-or-creates an Itera user by email, provisions a membership, and mints an **Itera** session. The DFL uid is stored opaquely (`itera.users.dfl_subject`), never as a foreign key. DFL prod is read-only to Itera — it only *verifies* tokens DFL minted.
Membership role
`/v1/embed/session` enrols you as a **`learner`** by default. To **author**, an Itera admin must grant you an `author` (or higher) membership in the target tenant. Confirm with the `whoami` tool — it lists your `author_tenant_ids`.
## Third-party instructor
[Section titled “Third-party instructor”](#third-party-instructor)
Your company gets an **Itera tenant** and **Itera Supabase accounts** with an `author` role for each instructor. This is the **same authoring auth model** as internal DFL teachers — a user-JWT + `author` RLS — just sourced from your own Itera accounts rather than DFL federation.
1. **Your Itera admin provisions** your tenant and creates author-role accounts for your instructors (email + password, or an SSO arrangement).
2. **Sign in** to the Itera Supabase project from your app / a script to get an `access_token` + `refresh_token`:
```bash
curl -X POST "https://kjefmwlopzeaqgqtlbdn.supabase.co/auth/v1/token?grant_type=password" \
-H "apikey: YOUR_ITERA_ANON_KEY" \
-H "Content-Type: application/json" \
-d '{ "email": "instructor@yourco.com", "password": "…" }'
```
(Or use the `@supabase/supabase-js` client with the Itera URL + anon key.)
3. **Use the `access_token`** as your `itera-mcp` bearer. RLS scopes every write to your tenant automatically.
Ask your Itera admin
The **anon key** and account provisioning are handed to you by your Itera admin — they are not published here. Authoring is scoped to your tenant by RLS, so you can only touch your own programs.
## Content-injection only
[Section titled “Content-injection only”](#content-injection-only)
A partner that only wants to **inject content** (populate programs/units/lessons/ activities programmatically, without full instructor seats) uses the **same `author` role** and the same `itera-mcp` `create_* / instantiate_program` tools — content-injection is a **use-case**, not a separate permission. See [Recipes → Content-injection flow](/recipes/content-injection/).
No REST write API (yet)
There is **no REST authoring/write API** today — `itera-api` is **pull-only**. An HTTP-only integrator that cannot run an MCP client has no write path yet; that is a known gap, not a supported v1 surface. Authoring in v1 is **MCP-only**.
## Reading results (per-tenant API key)
[Section titled “Reading results (per-tenant API key)”](#reading-results-per-tenant-api-key)
To **read** learners’ activity results, progress, and artifacts back out of Itera, use a **per-tenant API key** against `itera-api` — not a user-JWT, and **never** for writes.
* Header: `Authorization: Bearer itera__` (or `X-Itera-Api-Key: …`).
* The key is sha256’d and looked up in `itera.api_keys`; its `tenant_id` is the only authorization. Every query is scoped to that tenant. Unknown/revoked → `401`; a cross-tenant id fetch → `404` (no enumeration).
* **To obtain one:** ask your Itera admin — they mint it with `npm run mint-key -- --tenant --name "…"`. The raw key is shown **once** and is unrecoverable (only a sha256 + prefix are stored).
Full endpoint reference: [Reading results (itera-api)](/reference/results-api/).
## How to obtain a token
[Section titled “How to obtain a token”](#how-to-obtain-a-token)
| You are… | Do this |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| An internal DFL teacher | Exchange your DFL session at [`/v1/embed/session`](#internal-dfl-teacher), then have an admin grant you `author`. |
| A third-party instructor | [Sign in to your Itera account](#third-party-instructor) → use the session `access_token`. |
| Reading results (DFL or third-party) | Ask your Itera admin for a [per-tenant API key](#reading-results-per-tenant-api-key). |
Never paste credentials into a repo or these docs
Tokens and API keys are secrets. Keep them in your client’s credential store or an environment variable — never commit them, and never share the raw value. This site only documents credential **shapes**, never values.
***
[Next: the lesson model ](/model/)What you author — Program → Unit → Lesson → Activity, the ten activity kinds, and the spec / rubric / title\_i18n shapes.
# Getting started (end-to-end)
> From nothing to your first lesson-authoring MCP call — prerequisites, an author-role token, per-client config for itera-mcp, and a first create_program.
This is the full path from **zero** to **a working `itera-mcp` connection and your first authoring call**. Budget about five minutes.
## 0 · Prerequisites
[Section titled “0 · Prerequisites”](#0--prerequisites)
You'll need
* **An Itera account with an `author` role** in at least one tenant. How you get one depends on who you are — see [Authentication](/auth/). Internal DFL teachers federate from their DFL login; third-party instructors get an Itera Supabase account provisioned by their Itera admin.
* **An Itera Supabase access token (JWT)** — this is what the MCP authenticates. See [how to obtain a token](/auth/#how-to-obtain-a-token).
* **An MCP-compatible client**: Claude Code, Cursor, VS Code, codex, or the Anthropic SDK. Any one is fine — or just `curl`.
One endpoint, not many
Unlike the DFL MCP (which is split per-domain), Itera authoring is a **single endpoint**: `https://mcp.iterahq.dev/mcp`. Health check (no auth): `https://mcp.iterahq.dev/health`.
## 1 · Get an author-role token
[Section titled “1 · Get an author-role token”](#1--get-an-author-role-token)
The MCP validates an **Itera Supabase user-JWT** (HS256, signed by the Itera project) and reads your `author` memberships from it. A token from any other project (e.g. DFL prod) fails signature verification and is rejected.
* **Internal DFL teacher** → exchange your DFL session for an Itera session via `POST https://api.iterahq.dev/v1/embed/session`, then use the returned `access_token`. See [Authentication → Internal DFL teacher](/auth/#internal-dfl-teacher).
* **Third-party instructor** → sign in to your Itera Supabase account and use that session’s `access_token`. See [Authentication → Third-party instructor](/auth/#third-party-instructor).
Once you have a token, self-check your authoring rights with the `whoami` tool (step 4) — it returns your memberships and the tenant ids you can author in.
## 2 · Configure your client
[Section titled “2 · Configure your client”](#2--configure-your-client)
Pick your client below. Replace `YOUR_ITERA_JWT` with the access token from step 1.
* Claude Code
Add to your project’s `.mcp.json` (or the global `~/.claude/mcp.json`):
.mcp.json
```json
{
"mcpServers": {
"itera": {
"type": "http",
"url": "https://mcp.iterahq.dev/mcp",
"headers": { "Authorization": "Bearer YOUR_ITERA_JWT" }
}
}
}
```
Then restart Claude Code (or run `/mcp` to reconnect). The Itera authoring tools (`create_program`, `create_lesson`, …) appear.
* Cursor
Cursor needs `mcp-remote` as a proxy so the `Authorization` header is passed through. Add to `~/.cursor/mcp.json`:
\~/.cursor/mcp.json
```json
{
"mcpServers": {
"itera": {
"command": "npx",
"args": [
"mcp-remote",
"https://mcp.iterahq.dev/mcp",
"--transport", "http-only",
"--header", "Authorization:${ITERA_TOKEN}"
],
"env": { "ITERA_TOKEN": "Bearer YOUR_ITERA_JWT" }
}
}
}
```
Cursor gotchas
* Keep `--transport http-only` to skip OAuth discovery.
* Put the token in `env` (not inline) to avoid shell-escaping issues.
* **No space** after the colon in `Authorization:${ITERA_TOKEN}`.
* VS Code
VS Code (1.102+) has native MCP support. Add to `.vscode/mcp.json` (or run **MCP: Add Server**):
.vscode/mcp.json
```json
{
"servers": {
"itera": {
"type": "http",
"url": "https://mcp.iterahq.dev/mcp",
"headers": { "Authorization": "Bearer YOUR_ITERA_JWT" }
}
}
}
```
* codex / OpenCode
Add the server to `~/.codex/config.toml`:
\~/.codex/config.toml
```toml
[[mcp_servers]]
name = "itera"
url = "https://mcp.iterahq.dev/mcp"
[mcp_servers.headers]
Authorization = "Bearer YOUR_ITERA_JWT"
```
* Paperclip
Paperclip reads MCP servers from its agent config. Add an HTTP server entry:
mcp servers
```json
{
"itera": {
"type": "http",
"url": "https://mcp.iterahq.dev/mcp",
"headers": { "Authorization": "Bearer YOUR_ITERA_JWT" }
}
}
```
* Anthropic SDK
Pass the endpoint in the `mcp_servers` array:
example.py
```python
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=1024,
mcp_servers=[
{
"type": "url",
"url": "https://mcp.iterahq.dev/mcp",
"name": "itera",
"authorization_token": "YOUR_ITERA_JWT",
},
],
messages=[{"role": "user", "content": "List the Itera tenants I can author in."}],
)
```
* Raw HTTP (curl)
No client needed — initialize a session directly. The transport is Streamable-HTTP; advertise **both** `application/json` and `text/event-stream` in `Accept`:
```bash
curl -X POST https://mcp.iterahq.dev/mcp \
-H "Authorization: Bearer YOUR_ITERA_JWT" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": { "name": "curl-test", "version": "1.0.0" }
}
}'
```
The response carries an `mcp-session-id` header — send it back on subsequent `tools/call` requests.
## 3 · Make your first authoring calls
[Section titled “3 · Make your first authoring calls”](#3--make-your-first-authoring-calls)
Once connected, just ask in natural language — the model picks the tool:
> *“Which Itera tenants can I author in?”* → calls `whoami`
>
> *“Create a program called ‘Intro to Kafka’ in my tenant, then add a first unit and lesson.”* → `create_program` → `create_unit` → `create_lesson`
Every level takes the parent id returned by the previous call. The end-to-end sequence, with real inputs/outputs, is in [Recipes → Author a full course](/recipes/full-course/).
## 4 · Self-check with `whoami`
[Section titled “4 · Self-check with whoami”](#4--self-check-with-whoami)
Before writing, confirm your permissions. `whoami` returns your memberships and the tenant ids where your role is `author` / `instructor` / `tenant_admin` / `owner`:
whoami → result
```json
{
"memberships": [
{ "tenant_id": "8f3c…", "role": "author", "created_at": "2026-07-01T…" }
],
"author_tenant_ids": ["8f3c…"]
}
```
Use a `tenant_id` from `author_tenant_ids` as the `tenant_id` argument to `create_program`. If the list is empty, you don’t yet have an authoring role — see [Authentication](/auth/).
## 5 · Troubleshooting
[Section titled “5 · Troubleshooting”](#5--troubleshooting)
### `-32001` / `401` — token rejected
[Section titled “-32001 / 401 — token rejected”](#-32001--401--token-rejected)
Your JWT is missing, expired, or **from the wrong project**. The MCP only accepts tokens signed by the **Itera** Supabase project. A DFL token will fail signature verification. Re-mint via the flow in [Authentication](/auth/), or refresh (below).
### Writes return “Permission denied” / empty result
[Section titled “Writes return “Permission denied” / empty result”](#writes-return-permission-denied--empty-result)
You’re authenticated but your role lacks authoring rights **for that tenant**. Writes require `author` / `instructor` / `tenant_admin` / `owner` on the target tenant (RLS). Learners can read but not write. Check `whoami`.
### Token refresh
[Section titled “Token refresh”](#token-refresh)
Itera tokens are short-lived. Renew **without** a fresh login by posting your refresh token to the MCP’s refresh proxy (no auth header needed):
```bash
curl -X POST https://mcp.iterahq.dev/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token": "YOUR_REFRESH_TOKEN"}'
```
It returns a new `access_token` + `refresh_token`.
### Rate limited
[Section titled “Rate limited”](#rate-limited)
The endpoint rate-limits (\~100 req/min by default, keyed to your user id). Back off and retry.
***
[Next: the lesson model ](/model/)Program → Unit → Lesson → Activity, the ten activity kinds, and the spec / rubric / title\_i18n shapes you'll pass to the tools.
# llms.txt / llms-full.txt
> This whole docs site is published as machine-readable llms.txt and llms-full.txt so an LLM can learn the entire Itera authoring surface from one fetch.
This site is **LLM-native**. Point a model at one URL and it learns the entire Itera lesson-authoring surface — the 21 tools, the model, the auth, the results API.
[/llms.txt ](/llms.txt)A curated index — section + page titles with links. The map.
[/llms-full.txt ](/llms-full.txt)The full concatenated corpus — every page's content in one plain-text file. The territory.
## What they are
[Section titled “What they are”](#what-they-are)
`llms.txt` is an [emerging convention](https://llmstxt.org/) for exposing a site’s content in a form optimised for LLM ingestion. Itera docs ship two files, both **generated at build time** from the same content you’re reading (via the `starlight-llms-txt` plugin) — so they never drift from the docs.
| File | Shape | Use it when |
| ---------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------- |
| [`/llms.txt`](/llms.txt) | Curated index (titles + links, grouped) | You want the model to know **what exists** and fetch pages on demand. |
| [`/llms-full.txt`](/llms-full.txt) | Full corpus (all page bodies concatenated) | You want the model to learn **everything at once** — paste it into context. |
## Teach an LLM to author
[Section titled “Teach an LLM to author”](#teach-an-llm-to-author)
```bash
# Give a model the whole authoring surface in one shot:
curl -s https://docs.iterahq.dev/llms-full.txt
```
Then ask it to author — it has the tool names, inputs, the Program → Unit → Lesson → Activity model, the ten activity kinds, and the auth flow. A good smoke test: *“Using the Itera authoring tools, emit the `create_program` → `create_unit` → `create_lesson` → `create_activity` call sequence for a two-activity lesson.”* The shapes should match the [reference](/reference/).
## Parity check
[Section titled “Parity check”](#parity-check)
The tool reference is hand-authored, so a CI check keeps it honest. On every build/PR, `scripts/check-docs-sync.mjs` asserts that the **21** canonical `itera-mcp` tool names (the source of truth is `ITERA_TOOL_NAMES` in `iterahq/itera-mcp`, itself count-gated in that repo’s `tests/registration.test.ts`) each have a documented entry under `reference/`, and that no undocumented tool name sneaks in. It exits non-zero (red CI) on any drift.
Why this matters
The `llms-full.txt` corpus is only as trustworthy as the docs. The parity check guarantees a model reading it sees the **real** tool set — not a stale or invented one.
# The lesson model
> The Itera content graph — Program → Unit → Lesson → Activity — the ten activity kinds, and the spec / rubric / title_i18n shapes you pass to the authoring tools.
Everything you author in Itera hangs off a **four-level graph**. Understand these four rows and their shapes and the [21 tools](/reference/) become obvious — each is just create/read/update/delete on one level.
## The graph
[Section titled “The graph”](#the-graph)
```
flowchart TD
tenant["Tenant
your org / batch"]
program["Program
a course template · unique slug per tenant"]
unit["Unit
a section · ordinal"]
lesson["Lesson
a single class · ordinal"]
activity["Activity
kind · spec · rubric · ordinal"]
tenant --> program --> unit --> lesson --> activity
```
| Level | Lives in | Key fields | Parent |
| ------------ | ------------------ | --------------------------------------------------- | ------------ |
| **Program** | `itera.programs` | `slug` (unique per tenant), `title_i18n` | `tenant_id` |
| **Unit** | `itera.units` | `ordinal`, `title_i18n` | `program_id` |
| **Lesson** | `itera.lessons` | `ordinal`, `title_i18n` | `unit_id` |
| **Activity** | `itera.activities` | `kind`, `spec` (jsonb), `rubric` (jsonb), `ordinal` | `lesson_id` |
* Each level is created with the **parent id** returned by the previous `create_*` call.
* `ordinal` is the sort position within its parent (default `0`).
* Deleting a parent **cascades** to its children (deleting a program removes its units, lessons, and activities).
* Everything is **tenant-scoped by RLS**. You only see and touch tenants your identity belongs to.
## `title_i18n` — internationalised titles
[Section titled “title\_i18n — internationalised titles”](#title_i18n--internationalised-titles)
Titles are an **i18n map** (jsonb), e.g. `{ "en": "Intro", "pt": "Introdução" }`.
* You may pass a **bare string** — it is coerced to `{ "en": "" }`.
* Omitting it defaults to `{}`.
```json
// both are valid title_i18n inputs
"Introduction to Kafka"
{ "en": "Introduction to Kafka", "pt": "Introdução ao Kafka" }
```
## Activity kinds
[Section titled “Activity kinds”](#activity-kinds)
`activity.kind` is one of **ten** values. The first four are the original dev/coding kinds; the rest are the general learning-activity taxonomy.
| Kind | Group | What it is |
| --------------- | -------- | ----------------------------------------------------------- |
| `diagram` | judged | Build/annotate a diagram; graded against `spec` + `rubric`. |
| `document` | judged | Write a document/answer; AI-judged against the rubric. |
| `quiz` | judged | A quiz (question set) evaluated against the rubric. |
| `code` | judged | A coding task; the artifact is judged. |
| `concept` | no-judge | A concept / reading / explainer screen (no grading). |
| `single_choice` | no-judge | Pick exactly one option. |
| `multi_choice` | no-judge | Pick one or more options. |
| `match` | no-judge | Match pairs across two columns. |
| `order` | no-judge | Put items in the correct order. |
| `video` | no-judge | Watch a video (optionally gated on completion). |
Judged vs no-judge
The **judged** kinds (`diagram`, `document`, `quiz`, `code`) can be graded by the Itera AI judge (`POST /v1/embed/judge` on itera-api) against the activity’s `spec` + `rubric`. The **no-judge** set is completed by interaction alone. The authoring tools accept any of the ten regardless — the judge/no-judge split is a runtime (player) concern.
## `spec` and `rubric` — free-form JSON
[Section titled “spec and rubric — free-form JSON”](#spec-and-rubric--free-form-json)
Both are **jsonb** and both default to `{}`. They are **kind-specific**:
* **`spec`** — the activity’s configuration/content. For a `single_choice`, the prompt + options; for a `document`, the task statement; for a `video`, the video reference; etc.
* **`rubric`** — the grading criteria (for judged kinds). For a `document` or `code` activity, the rubric drives the AI judge’s verdict.
The authoring tools do **not** enforce a per-kind `spec` schema — they store what you give them. Keep the shape consistent per kind so the player and the judge can read it. Illustrative shapes:
single\_choice · spec
```json
{
"prompt": "Which delivery guarantee does a Kafka consumer group provide by default?",
"options": [
{ "id": "a", "label": "Exactly-once" },
{ "id": "b", "label": "At-least-once" },
{ "id": "c", "label": "At-most-once" }
],
"answer": "b"
}
```
document · spec + rubric
```json
// spec
{ "prompt": "Explain how consumer-group rebalancing works and one way to minimise its impact." }
// rubric
{
"criteria": [
{ "criterion": "Describes partition reassignment on membership change", "weight": 0.5 },
{ "criterion": "Names a mitigation (e.g. static membership / cooperative rebalance)", "weight": 0.5 }
],
"pass_score": 70
}
```
Titles/spec/rubric are yours to shape
Because `spec`/`rubric` are free-form jsonb, you (or your LLM) design the shape that your player renders and your judge grades. Pick a convention per kind and stick to it across a program.
## How reads mirror the graph
[Section titled “How reads mirror the graph”](#how-reads-mirror-the-graph)
Two reads return the graph pre-joined so you don’t have to walk it level by level:
* **`get_program`** returns a program with its full nested graph (`units → lessons → activities`, ordered by `ordinal`).
* **`instantiate_program`** deep-copies that whole graph into a new program.
***
[Next: the authoring reference ](/reference/)All 21 itera-mcp tools, grouped by level, with their real inputs and outputs.
# Content-injection-only flow
> Populate Itera programs/units/lessons/activities programmatically from an external system — a use-case of the same author role, no special permission.
**Content-injection** = a partner (or an internal pipeline) that only wants to **push content into Itera programmatically**, without full instructor seats or a UI. In v1 this is **not a separate permission** — it is a **use-case of the `author` role** using the same `itera-mcp` `create_*` / `instantiate_program` tools.
Same role, narrower use
An injection identity is just an Itera account with an `author` membership in the target tenant, driven by a script instead of a person. RLS scopes it to that tenant. There is no `content_injector` role in v1.
## Pattern
[Section titled “Pattern”](#pattern)
1. **Provision** an Itera account with `author` on the target tenant (ask your Itera admin). Store its refresh token in your pipeline’s secret store.
2. **Mint a fresh access token** at run time (Supabase password grant, or refresh via the MCP’s `/auth/refresh` proxy — see [Getting started → token refresh](/getting-started/#token-refresh)).
3. **Connect to `itera-mcp`** with that token (or call the tools over raw HTTP — see the [raw HTTP tab](/getting-started/#2--configure-your-client)).
4. **Idempotency is on you:** `slug` is unique per tenant, so a re-run of `create_program` with the same slug returns a **conflict** (`23505`). Either catch it, or `list_programs` first and branch to `update_program` when the slug already exists.
## Bulk-load from your source of truth
[Section titled “Bulk-load from your source of truth”](#bulk-load-from-your-source-of-truth)
A typical injector maps rows from your CMS/spreadsheet to the graph:
```text
for each course in source:
program = create_program(tenant_id, slug=course.slug, title_i18n=course.title)
for each section in course.sections:
unit = create_unit(program.id, ordinal=section.i, title_i18n=section.title)
for each class in section.classes:
lesson = create_lesson(unit.id, ordinal=class.i, title_i18n=class.title)
for each step in class.steps:
create_activity(lesson.id, kind=step.kind, ordinal=step.i,
spec=step.spec, rubric=step.rubric)
```
Keep a stable mapping from your source ids → returned Itera ids so re-runs `update_*` instead of duplicating.
## Cloning a canonical template
[Section titled “Cloning a canonical template”](#cloning-a-canonical-template)
If every injected course derives from one master template, author the template once and `instantiate_program` per target — one call copies the whole graph, then `update_*` the copies with the per-course specifics.
HTTP-only integrators (no MCP client)
If your system genuinely cannot speak MCP, note there is **no REST write API** today — `itera-api` is [pull-only](/reference/results-api/). A raw-HTTP MCP call (the JSON-RPC `tools/call` over Streamable-HTTP) is the supported path; a plain REST authoring endpoint is a known gap, not a v1 surface.
# DFL-as-consumer (embed SSO + results)
> How DevFellowship consumes Itera — frame the player via embed SSO (/v1/embed/session) and pull learners' results with a per-tenant API key.
DFL consumes Itera in **two directions**: it **frames the Itera player** for its fellows (embed SSO), and it **reads their results back** for dashboards / LMS admin / Gantt (per-tenant key). Neither direction authors content — that’s [`itera-mcp`](/reference/).
## Direction 1 — embed the player (SSO)
[Section titled “Direction 1 — embed the player (SSO)”](#direction-1--embed-the-player-sso)
A DFL fellow inside a dfl-learn iframe is signed in to Itera with **no re-login**. DFL is the federated IdP; Itera trades a verified DFL token for a fresh Itera session.
1. **The DFL host posts the fellow’s DFL access token** into the iframe.
2. **The embed exchanges it** for an Itera session:
```bash
curl -X POST https://api.iterahq.dev/v1/embed/session \
-H "Content-Type: application/json" \
-d '{ "dfl_token": "", "tenant": "dfl-batch" }'
```
→ `{ "access_token", "refresh_token", "expires_in": 3600, "itera_user_id" }`
3. **The player sets the session** (`supabase.auth.setSession(...)`) → fully authed, RLS-scoped Itera session for that fellow (role `learner`).
Same endpoint mints teacher tokens
`/v1/embed/session` is also how an **internal DFL teacher** gets an Itera token to author — the difference is a `learner` vs `author` membership. See [Authentication → internal DFL teacher](/auth/#internal-dfl-teacher).
## Direction 2 — read results back
[Section titled “Direction 2 — read results back”](#direction-2--read-results-back)
DFL pulls its fellows’ activity results / progress / artifacts with a **per-tenant API key** for the `dfl-batch` tenant:
```bash
curl "https://api.iterahq.dev/v1/progress?limit=200" \
-H "Authorization: Bearer itera__"
```
Everything is scoped to the tenant the key belongs to. Full endpoints + schemas: [Reading results (itera-api)](/reference/results-api/) and the [read-results recipe](/recipes/read-results/).
## The AI judge (optional)
[Section titled “The AI judge (optional)”](#the-ai-judge-optional)
When a fellow submits a judged artifact (`diagram` / `document` / `code` / `quiz`), the player calls `POST /v1/embed/judge` (DFL-JWT) to grade it against the activity’s `spec` + `rubric` and gate progress on a pass. It degrades gracefully to `status: "pending"` (still `200`) when unconfigured — see [results-api](/reference/results-api/#embed-sso--the-ai-judge-learner-facing).
```
flowchart LR
fellow["DFL fellow
dfl-learn iframe"]
session["/v1/embed/session
DFL JWT → Itera session"]
player["Itera player"]
judge["/v1/embed/judge
grade artifact"]
results["/v1/activity-results
/v1/progress
tenant key"]
dashboards["DFL dashboards / LMS admin"]
fellow --> session --> player --> judge
dashboards -->|pull| results
```
# Author a full course with an LLM
> End-to-end recipe — drive itera-mcp from an LLM to build a Program → Unit → Lesson → Activity graph from scratch, with real tool calls in order.
This is the canonical end-to-end flow: from an empty tenant to a playable course. Each step uses the parent id returned by the previous one. An LLM connected to [`itera-mcp`](/getting-started/) can run this from natural language — the tool sequence below is what it emits.
1. **Find your tenant + confirm you can author.**
`whoami` → read an id from `author_tenant_ids`. (Or `list_tenants` → pick the `id` for your slug.)
whoami → result
```json
{ "memberships": [ { "tenant_id": "8f3c…", "role": "author" } ], "author_tenant_ids": ["8f3c…"] }
```
2. **Create the program.**
create\_program → args
```json
{ "tenant_id": "8f3c…", "slug": "intro-kafka", "title_i18n": { "en": "Intro to Kafka" } }
```
→ `{ "success": true, "program": { "id": "P1", … } }`
3. **Add a unit** (parent = the program id `P1`).
create\_unit → args
```json
{ "program_id": "P1", "ordinal": 0, "title_i18n": { "en": "Fundamentals" } }
```
→ `{ "success": true, "unit": { "id": "U1", … } }`
4. **Add a lesson** (parent = the unit id `U1`).
create\_lesson → args
```json
{ "unit_id": "U1", "ordinal": 0, "title_i18n": { "en": "Topics & partitions" } }
```
→ `{ "success": true, "lesson": { "id": "L1", … } }`
5. **Add activities** (parent = the lesson id `L1`). Mix kinds — a `concept` explainer, then a `single_choice` check, then a judged `document` task.
create\_activity → args (concept)
```json
{ "lesson_id": "L1", "kind": "concept", "ordinal": 0,
"spec": { "body": "A topic is split into partitions; each partition is an ordered log." } }
```
create\_activity → args (single\_choice)
```json
{ "lesson_id": "L1", "kind": "single_choice", "ordinal": 1,
"spec": { "prompt": "What guarantees ordering in Kafka?",
"options": [ { "id": "a", "label": "The topic" }, { "id": "b", "label": "The partition" } ],
"answer": "b" } }
```
create\_activity → args (document, judged)
```json
{ "lesson_id": "L1", "kind": "document", "ordinal": 2,
"spec": { "prompt": "Explain when you'd increase a topic's partition count." },
"rubric": { "criteria": [ { "criterion": "Mentions consumer parallelism", "weight": 1 } ], "pass_score": 70 } }
```
6. **Verify the graph** with a single read:
`get_program` with `{ "program_id": "P1" }` → returns the program with `units → lessons → activities` nested and ordered. Confirm it matches what you built.
## Reordering & edits
[Section titled “Reordering & edits”](#reordering--edits)
* Change order with `update_unit` / `update_lesson` / `update_activity` (`ordinal`).
* Fix a title with `update_*` (`title_i18n`).
* Remember: `update_activity` **replaces** `spec`/`rubric` wholesale — read, edit, write back the full object.
## Clone it for the next cohort
[Section titled “Clone it for the next cohort”](#clone-it-for-the-next-cohort)
Once a program is a good template, `instantiate_program` deep-copies the entire graph into a new program in one call:
instantiate\_program → args
```json
{ "source_program_id": "P1", "new_slug": "intro-kafka-2026-q3", "new_title_i18n": { "en": "Intro to Kafka — 2026 Q3" } }
```
→ `{ "success": true, "new_program_id": "P2" }`
Let the model self-check
Have the LLM call `whoami` first and `get_program` last. The first confirms it can write; the last proves the whole graph landed as intended.
# Read learners' results back
> Pull activity results, progress, and artifacts out of Itera with a per-tenant API key via itera-api — pagination, filtering, and the schemas.
Authoring writes content; **reading results** pulls back what learners did with it. That is the [`itera-api`](/reference/results-api/) pull-only REST surface, authenticated with a **per-tenant API key** (never a user-JWT, never for writes).
## 1 · Get a key
[Section titled “1 · Get a key”](#1--get-a-key)
Ask your Itera admin to mint one for your tenant. The raw key (`itera__`) is shown **once** — store it in your secret manager. See [Authentication → reading results](/auth/#reading-results-per-tenant-api-key).
## 2 · List activity results
[Section titled “2 · List activity results”](#2--list-activity-results)
```bash
curl "https://api.iterahq.dev/v1/activity-results?limit=50" \
-H "Authorization: Bearer itera__"
```
response
```json
{
"data": [
{ "id": "…", "activity_id": "…", "lesson_id": "…", "itera_user_id": "…",
"status": "completed", "score": 90, "completed_at": "…", "artifact_ids": ["…"] }
],
"next_cursor": "eyJ…"
}
```
## 3 · Paginate
[Section titled “3 · Paginate”](#3--paginate)
Keyset pagination over `(created_at, id)`. Pass the `next_cursor` back until it’s null:
```bash
curl "https://api.iterahq.dev/v1/activity-results?limit=200&cursor=eyJ…" \
-H "Authorization: Bearer itera__"
```
## 4 · Filter
[Section titled “4 · Filter”](#4--filter)
* **By activity:** `?activity_id=` on `/v1/activity-results` and `/v1/artifacts`.
* **By user:** `?user_id=` on `/v1/artifacts`.
* A single result/artifact by id: `/v1/activity-results/:id`, `/v1/artifacts/:id` (returns `404` if it isn’t your tenant’s — no cross-tenant enumeration).
## 5 · Progress
[Section titled “5 · Progress”](#5--progress)
```bash
curl "https://api.iterahq.dev/v1/progress" \
-H "Authorization: Bearer itera__"
```
response
```json
{
"data": [
{ "itera_user_id": "…", "program_id": "…",
"completed_count": 7, "total_count": 12, "percent": 58, "last_activity_at": "…" }
],
"next_cursor": null
}
```
## Full schema + endpoint list
[Section titled “Full schema + endpoint list”](#full-schema--endpoint-list)
See [Reading results (itera-api)](/reference/results-api/) for every endpoint, query param, and the `ActivityResult` / `Progress` / `Artifact` / `Error` schemas.
Read-only
There is no write endpoint on `itera-api`. To change content, author via [`itera-mcp`](/reference/). To grade an artifact, that’s the learner-facing `/v1/embed/judge` (DFL-JWT), not a tenant-key call.
# Authoring reference
> All 21 itera-mcp authoring tools, grouped by level (tenants, programs, units, lessons, activities) plus the pull-only itera-api results reference.
The **`itera-mcp`** server (`https://mcp.iterahq.dev/mcp`) exposes **21 tools** over the [lesson model](/model/). They are grouped by level.
Accurate to the live server
Tool **names** and inputs on these pages are taken directly from the `iterahq/itera-mcp` source (`src/tools/*.ts`). The set is **count-gated** in the server’s `tests/registration.test.ts` (CI fails on drift), and this docs site runs a [parity check](/llms/#parity-check) asserting all 21 names are documented.
## The 21 tools at a glance
[Section titled “The 21 tools at a glance”](#the-21-tools-at-a-glance)
| Level | Tools | Count |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ----- |
| [Tenants & whoami](/reference/tenants/) | `list_tenants`, `whoami` | 2 |
| [Programs](/reference/programs/) | `list_programs`, `get_program`, `create_program`, `update_program`, `delete_program`, `instantiate_program` | 6 |
| [Units](/reference/units/) | `list_units`, `create_unit`, `update_unit`, `delete_unit` | 4 |
| [Lessons](/reference/lessons/) | `list_lessons`, `create_lesson`, `update_lesson`, `delete_lesson` | 4 |
| [Activities](/reference/activities/) | `list_activities`, `get_activity`, `create_activity`, `update_activity`, `delete_activity` | 5 |
## How to read the tool tables
[Section titled “How to read the tool tables”](#how-to-read-the-tool-tables)
* **Inputs** are the tool’s real arguments. `?` marks optional ones.
* Ids are UUIDs. Each level takes the **parent id** from the previous `create_*`.
* **Auth:** every tool runs as **you** under RLS. Reads are tenant-scoped; writes (`create_* / update_* / delete_* / instantiate_program`) require an `author` / `instructor` / `tenant_admin` / `owner` role on the target tenant.
* **Results** are JSON. Write tools return `{ "success": true, "": {…} }`; deletes return `{ "success": true, "deleted": "" }`. Errors come back as `{ "error": "" }` (a caller-visible result, not a thrown exception).
Common error messages
* `Conflict: a row with that unique key already exists.` — duplicate `slug` in a tenant (`23505`).
* `Foreign-key violation: …parent row does not exist or is not visible to you.` — bad/invisible parent id (`23503`).
* `Permission denied: your role lacks authoring rights for this tenant …` — you’re not an author there (`42501`).
* `… not found or you lack authoring rights.` — the row doesn’t exist or RLS hides it from you.
[Tenants & whoami ](/reference/tenants/)Resolve a tenant\_id and self-check your authoring rights.
[Programs ](/reference/programs/)The course template: list / get (deep graph) / create / update / delete / instantiate (deep-copy).
[Units ](/reference/units/)Sections within a program.
[Lessons ](/reference/lessons/)Classes within a unit.
[Activities ](/reference/activities/)The ten activity kinds, with spec + rubric.
[Reading results (itera-api) ](/reference/results-api/)Pull-only REST for activity results, progress, and artifacts — per-tenant API key.
# Activities
> itera-mcp activity tools — list_activities, get_activity, create_activity, update_activity, delete_activity. The ten activity kinds and their spec + rubric.
An **activity** is a learner-facing step inside a lesson. It has a `kind`, a free-form `spec` (jsonb config), a `rubric` (jsonb grading), and an `ordinal`.
`kind` is one of **ten**: `diagram`, `document`, `quiz`, `code`, `concept`, `single_choice`, `multi_choice`, `match`, `order`, `video`. See [the lesson model → activity kinds](/model/#activity-kinds) for what each means.
## `list_activities`
[Section titled “list\_activities”](#list_activities)
List the activities of a lesson, ordered by `ordinal`. RLS-scoped.
**Inputs:**
| Arg | Type | Notes |
| ----------- | ---- | -------- |
| `lesson_id` | uuid | required |
**Result:** `{ "activities": [ { "id", "lesson_id", "ordinal", "kind", "spec", "rubric" }, … ] }`
## `get_activity`
[Section titled “get\_activity”](#get_activity)
Fetch one activity (`kind`, `spec`, `rubric`). RLS-scoped.
**Inputs:**
| Arg | Type | Notes |
| ------------- | ---- | -------- |
| `activity_id` | uuid | required |
**Result:** `{ "activity": { "id", "lesson_id", "ordinal", "kind", "spec", "rubric" } }`
## `create_activity`
[Section titled “create\_activity”](#create_activity)
Add an activity to a lesson. **Author-role only.**
**Inputs:**
| Arg | Type | Notes |
| ----------- | ------- | ------------------------------------ |
| `lesson_id` | uuid | Parent lesson. |
| `kind` | enum | One of the ten kinds above. |
| `ordinal?` | int ≥ 0 | Sort position (default 0). |
| `spec?` | jsonb | Kind-specific config (default `{}`). |
| `rubric?` | jsonb | Grading rubric (default `{}`). |
**Result:** `{ "success": true, "activity": { "id", "lesson_id", "kind", "ordinal", "spec", "rubric" } }`
create\_activity → args (single\_choice)
```json
{
"lesson_id": "…",
"kind": "single_choice",
"ordinal": 0,
"spec": {
"prompt": "Default Kafka consumer-group delivery guarantee?",
"options": [
{ "id": "a", "label": "Exactly-once" },
{ "id": "b", "label": "At-least-once" }
],
"answer": "b"
}
}
```
## `update_activity`
[Section titled “update\_activity”](#update_activity)
Update an activity’s `kind` / `ordinal` / `spec` / `rubric`. **Author-role only.** Only provided fields change.
**Inputs:**
| Arg | Type | Notes |
| ------------- | ------- | ----------------------------- |
| `activity_id` | uuid | required |
| `kind?` | enum | One of the ten kinds. |
| `ordinal?` | int ≥ 0 | |
| `spec?` | jsonb | Replaces the stored `spec`. |
| `rubric?` | jsonb | Replaces the stored `rubric`. |
**Result:** `{ "success": true, "activity": {…} }`
spec / rubric are replaced, not merged
Passing `spec` (or `rubric`) to `update_activity` **replaces** the whole jsonb value. To tweak one field, read the current value with `get_activity`, edit it client-side, and pass the full object back.
## `delete_activity`
[Section titled “delete\_activity”](#delete_activity)
Delete an activity. **Author-role only. Irreversible.**
**Inputs:**
| Arg | Type | Notes |
| ------------- | ---- | -------- |
| `activity_id` | uuid | required |
**Result:** `{ "success": true, "deleted": "" }`
# Lessons
> itera-mcp lesson tools — list_lessons, create_lesson, update_lesson, delete_lesson. A lesson is a single class within a unit.
A **lesson** is a single class within a unit, sorted by `ordinal`. Its activities are the actual learner-facing steps.
## `list_lessons`
[Section titled “list\_lessons”](#list_lessons)
List the lessons of a unit, ordered by `ordinal`. RLS-scoped.
**Inputs:**
| Arg | Type | Notes |
| --------- | ---- | -------- |
| `unit_id` | uuid | required |
**Result:** `{ "lessons": [ { "id", "unit_id", "ordinal", "title_i18n" }, … ] }`
## `create_lesson`
[Section titled “create\_lesson”](#create_lesson)
Add a lesson to a unit. **Author-role only.**
**Inputs:**
| Arg | Type | Notes |
| ------------- | ------------- | -------------------------- |
| `unit_id` | uuid | Parent unit. |
| `ordinal?` | int ≥ 0 | Sort position (default 0). |
| `title_i18n?` | string \| map | i18n map or bare string. |
**Result:** `{ "success": true, "lesson": { "id", "unit_id", "ordinal", "title_i18n" } }`
## `update_lesson`
[Section titled “update\_lesson”](#update_lesson)
Update a lesson’s `ordinal` and/or `title_i18n`. **Author-role only.** Only provided fields change.
**Inputs:**
| Arg | Type | Notes |
| ------------- | ------------- | -------- |
| `lesson_id` | uuid | required |
| `ordinal?` | int ≥ 0 | |
| `title_i18n?` | string \| map | |
**Result:** `{ "success": true, "lesson": {…} }`
## `delete_lesson`
[Section titled “delete\_lesson”](#delete_lesson)
Delete a lesson. **Cascades** to its activities. **Author-role only.**
**Inputs:**
| Arg | Type | Notes |
| ----------- | ---- | -------- |
| `lesson_id` | uuid | required |
**Result:** `{ "success": true, "deleted": "" }`
# Programs
> itera-mcp program tools — list_programs, get_program (deep graph), create_program, update_program, delete_program, instantiate_program (deep-copy).
A **program** is a course template in a tenant. `slug` is unique per tenant.
## `list_programs`
[Section titled “list\_programs”](#list_programs)
List programs visible to you (RLS: tenant-scoped). Optionally filter by tenant.
**Inputs:**
| Arg | Type | Notes |
| ------------ | ---- | ------------------------------ |
| `tenant_id?` | uuid | Filter to a single tenant. |
| `limit?` | int | Max rows (1–200, default 100). |
**Result:** `{ "programs": [ { "id", "tenant_id", "slug", "title_i18n", "created_at" }, … ] }`
## `get_program`
[Section titled “get\_program”](#get_program)
Fetch one program with its **full nested graph**: `units → lessons → activities`, ordered by `ordinal`. RLS-scoped.
**Inputs:**
| Arg | Type | Notes |
| ------------ | ---- | -------- |
| `program_id` | uuid | required |
**Result:**
```json
{
"program": {
"id": "…", "tenant_id": "…", "slug": "intro-kafka",
"title_i18n": { "en": "Intro to Kafka" }, "created_at": "…",
"units": [
{
"id": "…", "ordinal": 0, "title_i18n": { "en": "Basics" },
"lessons": [
{
"id": "…", "ordinal": 0, "title_i18n": { "en": "Topics & partitions" },
"activities": [
{ "id": "…", "ordinal": 0, "kind": "concept", "spec": {…}, "rubric": {} }
]
}
]
}
]
}
}
```
Returns an error if the program is not found or not visible to you.
## `create_program`
[Section titled “create\_program”](#create_program)
Create a new program (template) in a tenant. **Author-role only** (RLS enforces `author` / `instructor` / `tenant_admin` / `owner` on the tenant). `slug` must be unique within the tenant.
**Inputs:**
| Arg | Type | Notes |
| ------------- | ------------- | ----------------------------------------------------------------------------------------------------------------- |
| `tenant_id` | uuid | Owning tenant (from [`list_tenants`](/reference/tenants/#list_tenants) / [`whoami`](/reference/tenants/#whoami)). |
| `slug` | string | URL-safe, unique within the tenant. |
| `title_i18n?` | string \| map | i18n map or a bare string (→ `{ "en": … }`). |
**Result:** `{ "success": true, "program": { "id", "tenant_id", "slug", "title_i18n", "created_at" } }`
create\_program → args
```json
{ "tenant_id": "8f3c…", "slug": "intro-kafka", "title_i18n": { "en": "Intro to Kafka", "pt": "Introdução ao Kafka" } }
```
## `update_program`
[Section titled “update\_program”](#update_program)
Update a program’s `slug` and/or `title_i18n`. **Author-role only.** Only provided fields change; passing neither returns `"Nothing to update."`.
**Inputs:**
| Arg | Type | Notes |
| ------------- | ------------- | --------------- |
| `program_id` | uuid | required |
| `slug?` | string | new unique slug |
| `title_i18n?` | string \| map | |
**Result:** `{ "success": true, "program": {…} }`
## `delete_program`
[Section titled “delete\_program”](#delete_program)
Delete a program. **Cascades** to its units, lessons, and activities. **Author-role only. Irreversible.**
**Inputs:**
| Arg | Type | Notes |
| ------------ | ---- | -------- |
| `program_id` | uuid | required |
**Result:** `{ "success": true, "deleted": "" }`
## `instantiate_program`
[Section titled “instantiate\_program”](#instantiate_program)
**Deep-copy** an existing program graph (`units → lessons → activities`) into a **new** program in the **same tenant** under a new slug. **Author-role only.** Returns the new program id.
**Inputs:**
| Arg | Type | Notes |
| ------------------- | ------------- | --------------------------------------------- |
| `source_program_id` | uuid | Program to copy from. |
| `new_slug` | string | Slug for the new program (unique per tenant). |
| `new_title_i18n?` | string \| map | Defaults to the source program’s title. |
**Result:** `{ "success": true, "new_program_id": "" }`
Use it to clone a template
`instantiate_program` is the fastest way to spin up a new cohort’s course from a canonical template — one call copies the entire graph. To build from scratch instead, use `create_program` then add units/lessons/activities.
# Reading results (itera-api)
> The pull-only itera-api REST reference — activity results, progress, and artifacts, scoped by a per-tenant API key. Plus embed SSO and the AI judge.
**`itera-api`** (`https://api.iterahq.dev`) is the **pull-only** REST surface for reading learners’ **activity results, progress, and artifacts** back out of Itera. It is how **DFL (or any tenant) consumes Itera** — dashboards, LMS admin, Gantt, etc.
Pull-only — there is NO write/authoring API
`itera-api` **reads**. It has **no endpoint to create or modify** programs/units/ lessons/activities. Authoring is **MCP-only** in v1 — see [the authoring reference](/reference/). Don’t look for a REST “create lesson” here; use [`create_lesson`](/reference/lessons/#create_lesson) on `itera-mcp`.
## Auth — per-tenant API key
[Section titled “Auth — per-tenant API key”](#auth--per-tenant-api-key)
```plaintext
Authorization: Bearer itera__
```
(or `X-Itera-Api-Key: itera__`).
* The key is sha256’d and looked up in `itera.api_keys`; its `tenant_id` is the **only** authorization. Every query is scoped to that tenant.
* Unknown / revoked → `401`. A cross-tenant id fetch → `404` (no enumeration).
* **To obtain a key:** ask your Itera admin — see [Authentication → reading results](/auth/#reading-results-per-tenant-api-key).
## `/v1` endpoints
[Section titled “/v1 endpoints”](#v1-endpoints)
| Method | Path | Auth | Returns |
| ------ | -------------------------- | ---------- | ----------------------------------------- |
| GET | `/v1/health` | none | `{ status, service, version }` |
| GET | `/v1/activity-results` | tenant key | `{ data: ActivityResult[], next_cursor }` |
| GET | `/v1/activity-results/:id` | tenant key | `ActivityResult` (404 if not your tenant) |
| GET | `/v1/progress` | tenant key | `{ data: Progress[], next_cursor }` |
| GET | `/v1/artifacts` | tenant key | `{ data: Artifact[], next_cursor }` |
| GET | `/v1/artifacts/:id` | tenant key | `Artifact` |
**Query params:** `?limit=N` (default 50, max 200), `?cursor=`, `?activity_id=` (results/artifacts), `?user_id=` (artifacts). Pagination is keyset over `(created_at, id)`; `next_cursor` is an opaque base64url cursor, stable under inserts.
list activity results
```bash
curl https://api.iterahq.dev/v1/activity-results?limit=50 \
-H "Authorization: Bearer itera__"
```
## Schemas
[Section titled “Schemas”](#schemas)
**ActivityResult**
```json
{
"id": "…", "activity_id": "…", "lesson_id": "…", "itera_user_id": "…",
"status": "completed | in_progress | failed",
"score": 90,
"completed_at": "…", "created_at": "…",
"artifact_ids": ["…"]
}
```
**Progress**
```json
{
"itera_user_id": "…", "program_id": "…",
"completed_count": 7, "total_count": 12, "percent": 58,
"last_activity_at": "…"
}
```
**Artifact**
```json
{
"id": "…", "result_id": "…",
"kind": "diagram | document | site | repo",
"ref_url": "…", "repo_url": "…", "verification": {…}, "created_at": "…"
}
```
**Error** — `{ "error": { "code", "message" } }`, `code ∈ { unauthorized, not_found, invalid_request, rate_limited, internal_error }`.
## Embed SSO & the AI judge (learner-facing)
[Section titled “Embed SSO & the AI judge (learner-facing)”](#embed-sso--the-ai-judge-learner-facing)
Two endpoints on `itera-api` are **not** tenant-key auth — they take a **DFL JWT** (the embed boundary) and serve the learner/player flow. A teacher rarely calls them directly, but they explain the DFL↔Itera handoff:
| Method | Path | Auth | Purpose |
| ------ | ------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| POST | `/v1/embed/session` | DFL JWT | Trade a verified DFL access token for a fresh **Itera** session (`{ access_token, refresh_token, expires_in, itera_user_id }`). This is how an internal DFL teacher gets an Itera token — see [Authentication](/auth/#internal-dfl-teacher). |
| POST | `/v1/embed/judge` | DFL JWT | Grade a learner’s artifact against an activity’s `spec` + `rubric` (a free OpenRouter model), persist the verdict, and gate progress on a pass. Degrades to `status: "pending"` (still `200`) if unconfigured. |
Billing & PLG live here too
`itera-api` also hosts `/billing/*` (Stripe per-seat, tenant-key auth) and `/plg/*` (self-serve subscribe, user-JWT auth). Those are commerce surfaces, not part of lesson authoring — see the `itera-api` README in the repo for details.
# Tenants & whoami
> itera-mcp tenant tools — list_tenants and whoami — to resolve a tenant_id and self-check your authoring rights before writing.
Two read-only helpers to orient yourself before authoring.
## `list_tenants`
[Section titled “list\_tenants”](#list_tenants)
List Itera tenants (id, slug, name, branding). **Read-only.** RLS allows reading all tenants; authoring is still gated per-tenant on write. Use the returned `id` as the `tenant_id` argument to [`create_program`](/reference/programs/#create_program).
**Inputs:** *none*
**Result:**
```json
{
"tenants": [
{
"id": "8f3c…",
"slug": "dfl-batch",
"name": "DevFellowship Batch",
"branding": { "…": "…" },
"created_at": "2026-06-01T…"
}
]
}
```
## `whoami`
[Section titled “whoami”](#whoami)
Return the caller’s Itera memberships (`tenant_id` + `role`) and which tenants they can author in. Use it to **self-check before writes**.
**Inputs:** *none*
**Result:**
```json
{
"memberships": [
{ "tenant_id": "8f3c…", "role": "author", "created_at": "2026-07-01T…" }
],
"author_tenant_ids": ["8f3c…"]
}
```
* `author_tenant_ids` lists the tenants where your role is one of `author`, `instructor`, `tenant_admin`, or `owner` — i.e. where writes will succeed.
* If it’s empty, you don’t have an authoring role yet — see [Authentication](/auth/).
# Units
> itera-mcp unit tools — list_units, create_unit, update_unit, delete_unit. A unit is a section within a program.
A **unit** is a section within a program, sorted by `ordinal`.
## `list_units`
[Section titled “list\_units”](#list_units)
List the units of a program, ordered by `ordinal`. RLS-scoped.
**Inputs:**
| Arg | Type | Notes |
| ------------ | ---- | -------- |
| `program_id` | uuid | required |
**Result:** `{ "units": [ { "id", "program_id", "ordinal", "title_i18n" }, … ] }`
## `create_unit`
[Section titled “create\_unit”](#create_unit)
Add a unit to a program. **Author-role only.**
**Inputs:**
| Arg | Type | Notes |
| ------------- | ------------- | -------------------------- |
| `program_id` | uuid | Parent program. |
| `ordinal?` | int ≥ 0 | Sort position (default 0). |
| `title_i18n?` | string \| map | i18n map or bare string. |
**Result:** `{ "success": true, "unit": { "id", "program_id", "ordinal", "title_i18n" } }`
## `update_unit`
[Section titled “update\_unit”](#update_unit)
Update a unit’s `ordinal` and/or `title_i18n`. **Author-role only.** Only provided fields change.
**Inputs:**
| Arg | Type | Notes |
| ------------- | ------------- | -------- |
| `unit_id` | uuid | required |
| `ordinal?` | int ≥ 0 | |
| `title_i18n?` | string \| map | |
**Result:** `{ "success": true, "unit": {…} }`
## `delete_unit`
[Section titled “delete\_unit”](#delete_unit)
Delete a unit. **Cascades** to its lessons and activities. **Author-role only.**
**Inputs:**
| Arg | Type | Notes |
| --------- | ---- | -------- |
| `unit_id` | uuid | required |
**Result:** `{ "success": true, "deleted": "" }`