Agent Spec
Copy everything in the block below, fill in the Configuration values at the top, and paste it into your coding agent (see Let an Agent Build It for the workflow). It’s written for the model and contains the real LearnHouse API contract, so the agent builds against the API that actually exists.
This spec mirrors the Do It Yourself build exactly. If you want to understand what the agent is doing, read that page alongside the generated code.
# SPEC: Build a headless learning platform on the LearnHouse API
## Configuration (fill these in before starting)
- API_BASE = http://localhost:1338/api/v1 # your LearnHouse API base URL
- ORG_SLUG = your-org-slug # your organization slug
- TEST_EMAIL = you@example.com # optional, for verifying auth
- TEST_PASSWORD = ******** # optional
## Goal
Build a web learning platform with Next.js (App Router + TypeScript + Tailwind)
that reads courses and content from the LearnHouse REST API and supports user
signup, login, enrollment, and progress tracking. Build it in two stages:
Stage 1 is fully anonymous (no tokens); Stage 2 adds authentication on top
without rewriting Stage 1.
## Hard rules
- LearnHouse access/refresh tokens MUST NEVER reach the browser. Do all
authenticated calls from the server (Route Handlers / Server Actions) and
store tokens in the Next.js app's own httpOnly cookie (a "BFF" pattern).
- Read the API base and org slug from environment variables; never hardcode.
- All API responses must be treated as uncacheable (cache: 'no-store').
- Surface API errors: error bodies are JSON shaped { "detail": "..." }.
## API contract (use these exactly; all paths are relative to API_BASE)
Anonymous reads (no Authorization header needed for public, published content):
- GET /orgs/slug/{ORG_SLUG}
-> organization object incl. numeric `id`.
- GET /courses/org_slug/{ORG_SLUG}/page/{page}/limit/{limit}
-> JSON ARRAY of courses. Each: { course_uuid, name, description,
thumbnail_image, public, published }. `course_uuid` already includes a
"course_" prefix — use it verbatim in URLs.
- GET /courses/{course_uuid}/meta
-> course + { org_id, chapters: [ { chapter_uuid, name, description,
activities: [ { id, activity_uuid, name, activity_type,
activity_sub_type, content, published } ] } ] }.
Each activity has BOTH a numeric `id` and a string `activity_uuid`.
Match progress with the numeric `id` (step.activity_id === activity.id);
use `activity_uuid` for routing/fetching.
- GET /activities/{activity_uuid}
-> a single activity { id, activity_uuid, name, activity_type,
activity_sub_type, content, published }.
activity_type is one of: TYPE_VIDEO, TYPE_DOCUMENT, TYPE_DYNAMIC,
TYPE_ASSIGNMENT, TYPE_CUSTOM, TYPE_SCORM.
Media: served from the backend ROOT (NOT under /api/v1) — derive a media base
by stripping "/api/v1" from API_BASE. Stored media values are BARE filenames,
not paths, so build org/course-scoped URLs:
- course thumbnail: {MEDIA_BASE}/content/orgs/{org_uuid}/courses/{course_uuid}/thumbnails/{thumbnail_image}
- hosted video: {MEDIA_BASE}/content/orgs/{org_uuid}/courses/{course_uuid}/activities/{activity_uuid}/video/{fileId}
- document (pdf): {MEDIA_BASE}/content/orgs/{org_uuid}/courses/{course_uuid}/activities/{activity_uuid}/documentpdf/{fileId}
org_uuid comes from GET /orgs/slug/{ORG_SLUG} or the /meta response (the
course LIST does not include org_uuid). Values starting with http(s) are full
S3 URLs — use as-is.
Auth (Stage 2):
- POST /auth/login Content-Type: application/x-www-form-urlencoded
body: username={email}&password={password}
-> { user: { user_uuid, username, email, ... },
tokens: { access_token, refresh_token, expiry } }
(It also sets httpOnly cookies, but rely on the body for a headless client.)
- POST /users/{org_id} Content-Type: application/json
body: { email, password, username, first_name, last_name } -> creates a user.
(Resolve org_id via GET /orgs/slug/{ORG_SLUG}.)
- GET /auth/refresh
Reads the refresh token ONLY from the `LH_refresh` cookie — NOT a header or
body. To refresh from the server, send header: Cookie: LH_refresh={value}.
-> { access_token, refresh_token, expiry }. The refresh token ROTATES: you
MUST persist the returned refresh_token (and access_token) back into your
session, or the next refresh is rejected as a replay (401).
- DELETE /auth/logout Authorization: Bearer {access_token} -> revokes session.
- Authenticated calls: send header Authorization: Bearer {access_token}.
Enrollment & progress (Stage 2, authenticated):
- A Trail (one per user per org) must EXIST before a course can be added, or
add_course returns 404. Reading the trail creates it lazily, so call
GET /trail/org/{org_id}/trail once before POST /trail/add_course.
- GET /trail/org/{org_id}/trail lazily creates the trail; returns
{ runs: [ { course, steps:[{activity_id, complete, ...}],
course_total_steps, ... } ] } (one run per enrolled course).
- POST /trail/add_course/{course_uuid} enroll the current user (trail must exist).
- POST /trail/add_activity/{activity_uuid} mark an activity done (creates trail/run if needed).
## Required pages / files
- src/lib/learnhouse.ts : API_BASE/ORG_SLUG consts, `lhFetch` (public reads,
optional token), media-URL helpers that build the
backend-root org/course-scoped paths (see Media
above), shared types.
- src/lib/session.ts : httpOnly-cookie session (get/set/clear) + `authedFetch`
that retries once on 401 by refreshing (refresh token
sent as a Cookie: LH_refresh header), persisting the
rotated access+refresh tokens back to the session
before retrying — see contract.
- src/app/page.tsx : course catalogue (anonymous).
- src/app/courses/[uuid]/page.tsx : course detail from /meta (anonymous).
- src/app/courses/[uuid]/[activityUuid]/page.tsx : activity viewer, switch on
activity_type (video / document / dynamic).
- src/app/api/auth/login/route.ts : POST -> /auth/login, store session.
- src/app/api/auth/signup/route.ts : POST -> resolve org id, /users/{org_id}.
- src/app/api/auth/logout/route.ts : POST -> /auth/logout, clear session.
- src/app/login/page.tsx : client login form posting to /api/auth/login.
- src/app/courses/[uuid]/actions.ts : Server Actions `enroll` and
`markActivityDone` using authedFetch.
- Enroll button on the course page (link to /login if no session); progress
ticks derived from GET /trail/org/{org_id}/trail.
## Environment
Create .env.local:
NEXT_PUBLIC_LEARNHOUSE_API_URL=<API_BASE>
NEXT_PUBLIC_LEARNHOUSE_ORG_SLUG=<ORG_SLUG>
## Build order
1. Scaffold: npx create-next-app@latest . --typescript --app --tailwind --eslint --src-dir --use-npm
2. Stage 1: lib/learnhouse.ts -> catalogue -> course page -> activity viewer.
STOP and verify the public site renders before continuing.
3. Stage 2: session.ts -> auth route handlers + login form -> authedFetch ->
enroll action -> progress display.
## Acceptance criteria (verify against the live instance)
1. Catalogue lists the org's public courses with thumbnails.
2. A course page lists chapters and their published activities.
3. At least one activity renders (video, document, or dynamic page).
4. A new user can sign up (TEST_EMAIL) and log in.
5. A logged-in user can enroll in a course and see at least one progress tick
after marking an activity done.
Report which criteria pass and show the commands/URLs you used to check.
## Known adjustment points (don't guess — inspect, then adapt)
- Activity `content` keys vary by activity_sub_type and editor version. Inspect a
real GET /activities/{uuid} and adapt the viewer's field names. Dynamic pages
are a structured block document; rendering raw JSON is an acceptable v1.
- Media is served from the backend ROOT (not /api/v1) at org/course-scoped
paths (see the Media section). If images 404, confirm the base has no /api/v1
and that you built the full orgs/{org}/courses/{course}/... path; S3 values
are absolute URLs.
- On SaaS instances, login may require email verification first. On local
self-hosted instances this is usually disabled.The agent can build the structure and the happy path, but it can’t see your instance’s exact content shapes or storage config. Plan to review the activity viewer and media URLs — the spec flags both as the likely places to adjust.
After it runs
Click through the acceptance criteria yourself, then continue with the same next steps as the manual build: real block rendering for dynamic activities, webhooks, and the full API Reference. Compare your result with learnhouse/headless-examples.