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": "" }`