# Peerfold — full agent knowledge > Everything an AI coding agent needs to build on Peerfold — the API, the auth flows, the SDKs, the recipes, and the rules that keep you out of trouble. Everything an agent needs to build against Peerfold, in one file: what the platform is, how to connect, which authentication flow to choose, the resource map, the recipes, the content model, the guardrails, and a generated index of every API operation. ## What Peerfold is Peerfold is a hosted learning platform. A workspace (a *tenant*) holds courses, learners, enrollments, progress, quizzes and exams, certificates, memberships and orders. It ships a learner portal of its own at `your-workspace.peerfold.app`, and it exposes the same data over a public HTTP API so you can build any surface you want on top of it: a catalog page on a marketing site, a fully custom course player, an internal dashboard, an onboarding flow inside your product. You are almost certainly here to do one of three things. **Configure** — change branding, courses or settings, which an agent can do over MCP without writing code. **Embed** — drop Peerfold widgets into a site you already own. **Go headless** — own the frontend end to end and call the API for everything. This kit covers all three, and is most useful for the third. Two facts shape most design decisions. First, **published courses are immutable revisions** — a learner always reads the revision that was published, so editing a draft never changes what someone is part-way through. Second, **the API is generated from one OpenAPI 3.1 contract**, which also generates the route validation, the client types and the MCP tool schemas — so if the docs and the API disagree, that is a bug, not a nuance. ## Connecting your agent If your tool speaks **MCP**, connect it and use its tenant-scoped tools to author courses, enroll learners, read reports, and discover and try endpoints, with no curl commands to write. **An AI assistant needs no key.** Claude and ChatGPT both take the server URL `https://app.peerfold.com/api/mcp` as a custom connector and nothing else: a browser opens, you approve the workspace, and that is the whole setup. Nothing is typed into a file, so nothing can leak out of one. ```bash claude mcp add peerfold https://app.peerfold.com/api/mcp ``` Cursor and other MCP clients take the same endpoint — `https://app.peerfold.com/api/mcp`, transport **Streamable HTTP** — in their own MCP config, and OAuth the same way. Each connection is revocable on its own under **Settings → Developers → Connected AI assistants**. A **secret key** is for a programmatic client with no browser to redirect through — a script, a backend service, a CI job. Present it as a bearer header: ```bash claude mcp add --transport http peerfold https://app.peerfold.com/api/mcp \ --header "Authorization: Bearer sk_live_…" ``` Tools that do not support MCP use the HTTP API directly; everything below works the same way for them. ### Without MCP: a key and a base URL Create a key pair in the workspace under **Settings → Developers**. You get a publishable key (`pk_live_…`, browser-safe) and a secret key (`sk_live_…`, shown exactly once, server-side only). Then: | Plane | Base URL | Credential | | --- | --- | --- | | Learner (browser) | `https://your-workspace.peerfold.app` | Short-lived learner JWT (15 min) | | Admin / server | `https://app.peerfold.com` | Secret key `sk_live_…` | ```bash # Admin plane — server-side only. curl https://app.peerfold.com/api/v1/lms/learners \ -H "Authorization: Bearer $PEERFOLD_SECRET_KEY" \ -H "Peerfold-Version: 2026-07-01" ``` Pin the API version with the `Peerfold-Version` header. Responses carry `X-Request-Id` and rate-limit headers; errors are RFC 7807 `application/problem+json` with a stable machine-readable `code`. ### The SDKs `@peerfold/api-client` is a typed, zero-runtime-dependency wrapper over `fetch` (browser, Node and edge). `@peerfold/react` adds a provider and hooks. `@peerfold/js` is a prebuilt browser bundle exposing `window.Peerfold` for script-tag use, plus the embeddable cart drawer. Types in the client are inferred from the same schemas that validate the live API, so a contract change breaks your build rather than your users. ```ts import { PeerfoldClient } from "@peerfold/api-client"; // Browser / learner plane. const client = new PeerfoldClient({ baseUrl: "https://your-workspace.peerfold.app", learnerToken: () => tokenStore.get(), // string or a provider fn }); // Server / admin plane — NEVER construct this in browser code. const admin = new PeerfoldClient({ baseUrl: "https://app.peerfold.com", secretKey: process.env.PEERFOLD_SECRET_KEY, }); ``` ## Choosing an auth model This is the decision people get wrong, and it is expensive to undo. Learner-plane calls need a **learner JWT** (15 minutes, tenant-scoped, one learner). The flows differ only in how that JWT is obtained. | Use this | When | Secret lives | | --- | --- | --- | | **Admin secret key** | Server-to-server only: reporting, bulk enrollment, provisioning, back-office jobs. No end user involved. | Your server | | **Learner PKCE** | You own a frontend and want Peerfold to handle sign-in. Default choice for a headless portal or SPA. | Nowhere — no secret in the browser | | **Trusted relay (token exchange)** | Your app already authenticated the person and you want to hand them straight in, no second login. | Your server / serverless function | | **Membership assertion** | A HubSpot CMS page whose visitor is already a logged-in member. Zero-login hand-off, rendered server-side. | The CMS theme's settings (digest only) | ### Admin secret key `Authorization: Bearer sk_live_…` against `https://app.peerfold.com`. It is the whole workspace, so it never touches a browser, a client bundle, or a committed file. It can also mint a learner token directly (`POST /api/v1/auth/learner-token` with `email` or `member_id`) — that is the trusted-subsystem shortcut, and it means anyone holding the key can act as any learner. ### Learner PKCE (the default for your own frontend) 1. Register your redirect URI under **Settings → Developers** (it is an allowlist; unregistered URIs are rejected). 2. Send the browser to `GET https://your-workspace.peerfold.app/api/v1/auth/authorize` with `client_id` (your publishable key), `redirect_uri`, `code_challenge` (S256) and `state`. Peerfold renders its own login page — email + login code. 3. On success it redirects back with `code`; exchange it at `POST https://your-workspace.peerfold.app/api/v1/auth/token` with your `code_verifier`. 4. You get an access token (15 min) and a refresh token. Refresh at the same endpoint with `grant_type=refresh_token`; refresh tokens rotate on use. No secret key is involved anywhere in this flow, which is exactly why it is the default: a leaked artifact leaks nothing. ### Trusted relay If your own app already knows who the user is, put a small server endpoint in front of `POST /api/v1/auth/token-exchange` (secret-key authed). It asserts the signed-in user server-side and returns a learner token to the browser; the browser never sees the key. `@peerfold/js` has this wired — `createPeerfold({ relayUrl })` re-mints through your relay whenever the token expires. ```js const { client, refresh } = Peerfold.createPeerfold({ baseUrl: "https://your-workspace.peerfold.app", relayUrl: "/api/peerfold-token", // your endpoint; it holds the secret key }); await refresh(); const catalog = await client.catalog.list(); ``` ### Membership assertion (HubSpot CMS) A HubSpot CMS page cannot call an API at render time, so the theme instead renders a signed assertion for the logged-in member and the browser trades it for a learner token at `POST /api/v1/auth/membership-assertion`. The payload is `portalId|contactVid|email|tsSeconds` and the signature is `md5(secret + "|" + payload)` as lowercase hex — md5 because HubL exposes exactly one hash filter. The signing secret lives in theme settings and only its digest is ever rendered; assertions are emitted only inside the logged-in branch, and they are short-lived. There is also a lead-gen pair for anonymous visitors — `POST /api/v1/auth/leadgen-identify` resolves a HubSpot tracking cookie to a known contact, and `POST /api/v1/auth/leadgen-send` emails them a login code. Use it to turn "we already know this person" into a one-click sign-in, never as an authentication decision on its own. ## The resource map | Area | Plane | What you get | | --- | --- | --- | | Identity | Learner | `GET/PATCH /api/v1/me` — the signed-in learner's profile. | | Catalog & courses | Learner | `GET /api/v1/lms/catalog` (cursor-paginated), `GET /api/v1/lms/courses/{slug}` for the published revision with its chapters, lessons and block content. | | Enrollment & progress | Learner | `GET/POST /api/v1/lms/enrollments`, `POST /api/v1/lms/enrollments/{id}/progress`, `GET /api/v1/lms/progress`. | | Assessment | Learner | `POST …/quiz-attempts` and `POST …/exam-attempts` — both graded server-side. Answers are never scored in the browser. | | Certificates | Learner + public | `GET /api/v1/lms/certificates`; `GET /api/v1/lms/certificates/verify/{serial}` is public, for a verification page. | | Cart & checkout | Publishable key | `/api/v1/lms/cart/*` — create, read, add/remove, checkout. Anonymous; no learner session needed. | | Learner admin | Secret key | `/api/v1/lms/learners*` — list, create, update, enroll, read certificates; `DELETE /api/v1/lms/enrollments/{id}` revokes. | | Reporting | Secret key | `/api/v1/lms/reports/courses`, `…/courses/{id}/completion`, `/api/v1/lms/progress/export` (CSV or JSON). | | Exams & credits | Secret key | `/api/v1/lms/lessons/{id}/exam` and its `…/questions`; `/api/v1/lms/ceu-types`, `/api/v1/lms/courses/{id}/ceus`. | | Keys & webhooks | Secret key | `/api/v1/keys*`, `/api/v1/webhooks*` including deliveries and redelivery. | Platform routes (`/api/v1/me`, `/api/v1/auth/*`, keys, webhooks) are product-neutral; LMS resources sit under `/api/v1/lms/*`. Lists are **cursor-paginated** — pass the `next_cursor` you were given, or let the SDK's `iterate()` walk it. The full, generated endpoint index is in `llms-full.txt`; the live contract is at `https://app.peerfold.com/api/v1/openapi.json`. ## Golden paths ### A catalog page The catalog is learner-scoped: it returns what *this* learner is allowed to see, so gated and members-only courses appear correctly without you filtering anything. ```tsx import { useCatalog } from "@peerfold/react"; export function Catalog() { const { data: courses, loading, error, reload } = useCatalog(); if (loading) return ; if (error) return ; return ( ); } ``` Without React, the same call is `client.catalog.list({ limit: 25 })` for one page or `for await (const course of client.catalog.iterate())` for all of them. ### A course player Fetch the course by slug, render its blocks, and record progress as the learner moves. Progress writes are **idempotent** — the client generates an `Idempotency-Key` per event unless you supply one, so a retry replays the stored response instead of double-counting. ```ts const course = await client.courses.get(slug); // course.chapters[].lessons[].blocks — see the block model below. const enrollment = await client.enrollments.create({ course_slug: slug }); await client.progress.record(enrollment.id, { event_id: crypto.randomUUID(), type: "lesson_completed", lesson_id: lessonId, }); ``` In React, use `useProgressMutation` instead of calling `record` directly: it is optimistic, coalesces repeated events for the same lesson, keeps a stable idempotency key per queued item, and retries 429s and 5xxs with backoff. For the end of a visit, `Peerfold.progressBeacon(...)` writes with `fetch(keepalive)` so the last event survives the page unloading. ### Launching a SCORM package SCORM packages run inside a Peerfold-hosted player, because the runtime needs same-origin access to the package contents and the CMI data model. You do not implement it — you frame it. From a HubSpot CMS page the launcher takes the membership assertion you already have: ```text GET https://your-workspace.peerfold.app/api/portal/embed/scorm ?client_id=pk_live_… &payload= &sig= &course=&lesson= → 303 https://your-workspace.peerfold.app/embed// (session cookie set) ``` Put that URL in an iframe and let the redirect happen inside the frame. Failures render a small human-readable page rather than JSON, because a person is looking at the frame; the usual fix is a reload, since the parent page re-signs a fresh assertion on every render. In a headless app where the learner already has a session, link to the same `/embed/{course}/{lesson}` path instead. ### Quizzes and exams Both are graded server-side and both return the outcome. Never grade in the browser: the correct answers are not in the payload you rendered, and that is deliberate. ```ts const quiz = await client.quizzes.submit(enrollment.id, { lesson_id: lessonId, block_id: "q1", answers: { "question-1": ["a"] }, }); // → { passed, score, … } const exam = await client.exams.submit(enrollment.id, { lesson_id: examLessonId, answers: { /* question id → answer ids, or text for open questions */ }, }); ``` An exam with open-ended questions comes back pending until a human grades it in the admin UI; the `exam.graded` webhook fires when it resolves. Certificates issue automatically when a course's completion rules are met — you do not create them. A graded question may carry `answer_feedback`: coaching the author wrote for individual answer choices, already filtered by the server to the ones the learner may see. Draw each entry under its question, marked Correct or Incorrect from its `correct` field and named by that answer's text, then draw the question's `explanation`. `quizReviewRows` in `@peerfold/api-client` returns exactly that order. The text is HTML-lite, so sanitize it before it reaches the page. ### Checkout hand-off Selling from your own site does not require a learner session. Build an anonymous cart with the publishable key, then hand off to Stripe Checkout and let Peerfold handle the return, the order and the enrollment. ```ts // 1. Create a cart (returns a cart token — keep it in the browser). // 2. POST /api/v1/lms/cart/items to add or remove a course. // 3. POST /api/v1/lms/cart/checkout with your return URL. // → a Stripe Checkout URL; redirect the browser to it. ``` The drop-in alternative is the cart drawer from `@peerfold/js`: a `