# Honen for Developers > **This is the complete technical reference for building against Honen.** It is one > self-contained document: the HTTP API, Connectors and the permission model, MCP in > both directions, the CLI, Agent website embeds, every LMS integration (LTI 1.3, > SCORM 1.2/2004, xAPI, cmi5, Google Classroom), SSO, and the event model underneath > all of it. Nothing here requires a follow-up fetch. > > Honen is teaching and learning infrastructure built by StudyFetch, Inc. > Product: · Support: support@honen.com > > Companion files: > - — this exact document as plain text > - — product documentation index (what the platform does) > - — all product documentation in one file > - — machine-readable OpenAPI 3 spec, always current > > **If you are an AI agent:** you do not have to read this file to use Honen. Honen is > an MCP server. Connect to `https://honen.com/api/mcp/mcp` (OAuth, no API key) and call > the `honen_docs` tool. This document exists for when you need the whole surface at once, > or you are writing code rather than driving the product. > **This file is generated.** Do not edit it directly — edits are overwritten on the > next build. Prose lives in `apps/web/content/developers/*.md`; the API reference, > permission tables, and OAuth constants are read straight out of the code by > `apps/web/scripts/build-developers-doc`. Regenerate with > `pnpm --filter web docs:developers`. > > Command surface at build time: 113 commands. The live contract is always > . --- ## Table of contents 1. Orientation — the four ways in 2. Core concepts 3. Authentication — every credential type 4. The HTTP API (`/api/v1/*`) — conventions 5. The HTTP API — complete command reference 6. Connector management REST API 7. MCP — Honen as an MCP server 8. MCP — the tool surface 9. MCP — client configuration 10. `honen_docs` topics and the sandbox skill catalog 11. The Honen CLI 12. Agent website embeds 13. Outbound MCP — Honen's AI into your tools 14. Knowledge Base — the developer view 15. LMS integration — choosing a path 16. LTI 1.3 / LTI Advantage 17. SCORM 1.2 and 2004 18. xAPI (producer + LRS) 19. cmi5 20. Google Classroom add-on 21. SSO and identity 22. The learning-event bus and score projection 23. Logging, audit, and observability 24. Limits, TTLs, and rate limits 25. What Honen does not have 26. Where these facts come from --- # 1. Orientation — the four ways in There are exactly four integration directions. Almost every question resolves once you know which one you are in. | # | Direction | Mechanism | Read | | --- | --- | --- | --- | | 1 | **Your code → Honen** | `POST /api/v1/{domain}/{action}` with a Connector bearer token | §4, §5 | | 2 | **Your AI → Honen** | MCP server at `/api/mcp/mcp` (OAuth) or `/api/mcp/c/{connectorId}/mcp` (bearer) | §7–§9 | | 3 | **Honen's AI → your tools** | Outbound MCP client connections (Linear, Notion, Slack, Google, …) | §13 | | 4 | **Honen → your LMS** | LTI 1.3, SCORM, xAPI/cmi5, Google Classroom | §15–§20 | Plus two adjacent surfaces: - **Your users → Honen** without a second login: OIDC SSO or signed-JWT SSO (§21). - **Honen inside your product**: Agent website embeds (§12), course share links, and single-activity links. **One invariant across all of it: Honen is always the source of truth for learning progress.** Every integration receives a *projection* of it. No path lets an external system write learning state back into Honen. --- # 2. Core concepts ## Workspace The tenancy boundary. Every course, Knowledge Base item, group, member, Connector, and integration belongs to exactly one workspace. Data isolation is per workspace and absolute. Users belong to workspaces through a `WorkspaceMembership` carrying a role (`ADMIN`, `INSTRUCTOR`, `MEMBER` — surfaced as `SystemRoleKey`). Personal workspaces exist but lack the groups/analytics that most integrations target; **package export and most org features are organization-workspace only**. Workspace IDs are 24-character hex MongoDB ObjectIds. So are most other entity IDs. ## Connector A **Connector** is the single permission primitive for programmatic access. One Connector holds one `ConnectorPolicy` and can be reached three ways, all enforcing the same policy: - `POST /api/v1/{domain}/{action}` — the HTTP API - `https://honen.com/api/mcp/c/{connectorId}/mcp` — a per-Connector MCP endpoint - the `honen` CLI (which is just the HTTP API) Backing model: `WorkspaceIntegrationKey`. Managing Connectors requires the `MANAGE_INTEGRATIONS` workspace permission. **Calls run as the Connector's creator**, narrowed by the policy. The creator's own workspace access is the ceiling — a Connector can never exceed what the person who created it can do, and if that person loses workspace access the Connector stops working (`403 Connector creator no longer has access to this workspace`). ### Secret format ``` hn_<8 hex> prefix, stored in plaintext, used for lookup hn_<8 hex>_ the raw credential; only sha256(raw) is stored hns_... short-lived agent-issued key (issue_dev_token), never listed in the dashboard ``` Lookup is by prefix, then `timingSafeEqual` on the SHA-256. `lastUsedAt` is bumped on every authentication. A raw secret is shown exactly once, at create or rotate time. ### Child tokens A Connector can hold multiple child tokens (`/api/connectors/tokens/create`). Each has its own prefix and secret and can be rotated or revoked independently, but they all share the parent's policy. Child tokens **do not expire**; the parent Connector supports an `expiresAt`. Revoking the parent kills every child. ## ConnectorPolicy The complete JSON shape. Every top-level key is optional; a policy that grants nothing is rejected at create time (`Connector policy is empty — grant at least one capability`). ```jsonc { "knowledgeBase": { "access": "none" | "read" | "write" | "admin", "allowedItemIds": [""], // folder ids cascade to the whole subtree "allowedGroupIds": [""], // group-home folders "allowedTags": ["tag"], // 1–40 chars each "rules": [ // optional "rules mode" { "access": "read" | "write" | "admin", "itemIds": ["..."], "groupIds": ["..."], "tags": ["..."], "allItems": true } ] }, "courses": { "access": "none" | "read" | "edit", "allowedCourseIds": ["..."] }, "courseCreator": { "enabled": true, "maxCoursesPerDay": 5, "maxCoursesTotal": 100 }, "workspaceAssistant": { "enabled": true }, "people": { "access": "none" | "read" | "manage" }, "analytics": { "read": true }, "assignments": { "access": "none" | "read" | "manage" }, "groups": { "access": "none" | "read" | "manage" }, "radar": { "access": "none" | "read" | "manage" }, "explore": { "access": "none" | "read" | "manage" }, "workspace": { "manage": true }, "integrationsAudit": { "read": true } } ``` Access ladders are ordered and inclusive: ``` knowledgeBase : none < read < write < admin courses : none < read < edit people : none < read < manage assignments / groups / radar / explore : none < read < manage ``` Every capability key the policy accepts: | Key | Controls | | --- | --- | | `knowledgeBase` | Knowledge Base documents, folders, versions, sharing, and images | | `courses` | Reading course structure and content; `edit` also unlocks `course-editor/run` | | `courseCreator` | Creating new courses, with optional daily and lifetime caps | | `workspaceAssistant` | Running bash in the Workspace Assistant sandbox | | `people` | Workspace members and invites | | `analytics` | Workspace, cohort, and per-learner analytics (read-only) | | `assignments` | Course assignments | | `groups` | Groups and group memberships | | `radar` | Radar monitors and alerts | | `explore` | The learner-facing Explore catalog | | `workspace` | Workspace settings (name, slug) | | `integrationsAudit` | The integration audit log | ### Knowledge Base allowlists - **Empty allowlists mean "everything the workspace can see."** They are a narrowing device, not an opt-in list. - An item is in scope if its own id is listed, **or any ancestor folder is listed** (selecting a folder cascades to its entire subtree), **or** its group-home folder is in `allowedGroupIds`, **or** it carries a listed tag. - **Rules mode**: when `knowledgeBase.rules` is non-empty, resolution becomes most-specific-wins and **deny-by-default** — an item with no matching rule gets no access. Specificity order: direct item id → nearest ancestor folder → group home → tag → `allItems` catch-all. This is how you grant write on one folder and read-only on a single file inside it. - Rules with no target (`allItems` falsy and no ids/tags) are silently dropped before persisting, so a half-configured rule is dead config, never an accidental grant. Underneath the policy, ordinary Knowledge Base ACLs still apply. A policy widens nothing: it can only narrow what the Connector creator already has. ### Course creator budgets `courseCreator.maxCoursesPerDay` / `maxCoursesTotal` are enforced against DB counters (`coursesCreatedTotal`, `lastCreatedAt`) at generation time, not in the static authorize check. Editing a policy preserves the running counters rather than resetting them. ## Identity model summary | Surface | Runs as | Authorized by | | --- | --- | --- | | `/api/v1/*` | Connector creator | `ConnectorPolicy` | | `/api/mcp/c/{id}/mcp` | Connector creator | `ConnectorPolicy` | | `/api/mcp/mcp` | The consenting user | that user's full workspace permissions + OAuth scopes | | `honen` CLI | Connector creator | `ConnectorPolicy` | | Agent embed | The visitor's linked identity (or the connector's acting user in `SERVICE` mode) | Agent's Connector policy | | LTI / SCORM / cmi5 launch | The provisioned learner | provisioning rules on the connection | --- # 3. Authentication — every credential type | Credential | Format | Where | Lifetime | Revocable | | --- | --- | --- | --- | --- | | Connector secret | `hn_<8hex>_` | `Authorization: Bearer` | until revoked, or `expiresAt` | yes, instantly | | Connector child token | `hn_<8hex>_` | `Authorization: Bearer` | non-expiring | yes, instantly | | Agent-issued dev token | `hns_…` | `Authorization: Bearer` | 60–3600 s (default 900) | expires | | MCP OAuth access token | opaque sealed blob | `Authorization: Bearer` | 90 days | **no** (stateless) | | MCP OAuth refresh token | opaque sealed blob | token endpoint | 1 year (does not extend on refresh) | **no** | | Personal MCP token | opaque sealed blob | `Authorization: Bearer` | 1 year | **no** | | Agent embed key | `agek_<12hex>_` | `Authorization: Bearer`, server-side only | until revoked | yes | | Agent embed ticket | `agt_` | URL fragment | **2 minutes, single use** | consumed | | Agent embed session | `ags_` | Bearer or cookie | 24 hours | yes | | xAPI LRS credential | `hxk_<24hex>` + secret | HTTP Basic | until disabled/deleted | yes | | SCORM/cmi5 package token | sealed | baked into the zip | ~5 years | rotate the package secret | | Signed-JWT SSO assertion | RS256/ES256 JWT | POST body | **≤ 60 s** (`exp - iat`) | key removal | **No token revocation for OAuth/personal MCP tokens.** They are stateless `iron-session` seals verified by decrypt-and-compare, with no database row. The only levers are rotating the platform `SESSION_SECRET` (which invalidates every session and token platform-wide) or, for Connector-bound credentials, revoking the Connector. ## Getting a Connector token Dashboard: **Workspace → Developers → Connectors → New Connector**. Pick capabilities, save, copy the secret once. Programmatically (from a session with `MANAGE_INTEGRATIONS`): ```bash curl -X POST https://honen.com/api/connectors/create \ -H "Content-Type: application/json" \ --cookie "$HONEN_SESSION" \ -d '{ "label": "Reporting job", "policy": { "analytics": { "read": true }, "courses": { "access": "read" } } }' ``` Response (201): ```json { "success": true, "data": { "connector": { "id": "…", "prefix": "hn_ab12cd34", "policy": {…} }, "raw": "hn_ab12cd34_…" } } ``` From an MCP agent, mint a short-lived one instead — see `issue_dev_token` in §8. --- # 4. The HTTP API (`/api/v1/*`) — conventions Base URL: `https://honen.com/api/v1` Spec: `GET https://honen.com/api/v1/openapi.json` (public, unauthenticated, always current) ## The shape Every capability is one command at `POST /api/v1/{domain}/{action}` with a JSON body. There are no GETs, no path parameters, no query strings, no REST verbs. This is deliberate: one command registry backs the HTTP API, the registry-backed MCP tools, and the OpenAPI spec, so the three surfaces cannot drift. ```bash curl -X POST https://honen.com/api/v1/analytics/hero \ -H "Authorization: Bearer hn_…" \ -H "Content-Type: application/json" \ -d '{}' ``` - `Authorization: Bearer hn_…` is required on every call. - `Content-Type: application/json`. An empty body is tolerated for no-argument commands. - `maxDuration` is 60 seconds per request. ## Response envelope Success: ```json { "success": true, "data": { … } } ``` Failure: ```json { "success": false, "error": { "message": "…", "code": "…" } } ``` ## Status codes | Code | Meaning | | --- | --- | | `200` | Command result | | `400` | Malformed JSON body | | `401` | Missing or invalid Connector credentials. Response carries `WWW-Authenticate: Bearer realm="honen-connector"` and `code: "UNAUTHENTICATED"` | | `403` | The Connector's policy does not permit this command. Message is `Forbidden: requires ` or `Forbidden: not in this Connector's allowlist` | | `404` | Unknown command — `Unknown command: {name}. See /api/v1/openapi.json.` | | `422` | Input validation error (zod), with field-level detail | ## Authorization semantics Two checks run per call: 1. **Static** — `cmd.authorize(policy, input)` is a pure function of the policy and the parsed input. It can express resource-scoped rules (e.g. a `courseId` allowlist). 2. **Resource-scoped** — handlers re-check against fetched entities where the static check cannot (e.g. resolving a `topicId` to its owning course before applying a course allowlist). Read commands do not mutate; write commands are tagged `write` in the audit log. Every call writes an integration request log row (§23). ## Pagination Commands that paginate take `page` / `limit` or `cursor` / `limit` on the body. The audit and log endpoints return `nextCursor`; pass it back as `cursor`. `null` means end of feed. --- # 5. The HTTP API — complete command reference 113 commands across 14 domains. `field?` marks an optional field, and union types show the exact allowed literal values. Everything from here to the worked examples is generated from the same command registry that serves `/api/v1/openapi.json`, so it cannot drift from the running API. Policy capability required per domain: | Domain | Requires | | --- | --- | | `analytics` | `analytics.read` | | `assignments` | `assignments.read` (list/get) / `assignments.manage` (create/remove) | | `course-editor` | `courses.edit` | | `courses` | `courses.read`, plus `courses.allowedCourseIds` if set | | `explore` | `explore.read` / `explore.manage` | | `groups` | `groups.read` / `groups.manage` | | `invites` | `people.read` / `people.manage` | | `kb` | `knowledgeBase.read` / `.write` / `.admin` (share, permissions, public links, permanent delete) | | `learning` | `analytics.read` | | `members` | `people.read` / `people.manage` | | `performance` | `analytics.read` | | `radar` | `radar.read` / `radar.manage` | | `workspace` | `workspace.manage` (update) | | `workspace-assistant` | `workspaceAssistant.enabled` | ## analytics ``` analytics/courses Per-course performance rows for the workspace. body: limit?:integer analytics/hero Workspace top-line counts (students, courses, completions). body: {} analytics/needs-attention Students with assigned coursework but zero completions. body: limit?:integer analytics/students Student roster with assigned vs completed counts. body: limit?:integer analytics/top-performers Students ranked by completion ratio. body: limit?:integer ``` ## assignments ``` assignments/create Assign one or more courses to a scope. body: courseIds:string[], scopeType:"WORKSPACE"|"GROUP"|"CUSTOM", groupId?:string, name?:string, dueDate?:string, message?:string, includedGroupIds?:string[], includedUserIds?:string[], excludedGroupIds?:string[], excludedUserIds?:string[] assignments/for-user List one user's assignments and completion state. body: userId:string assignments/get Get one assignment with completion stats. body: assignmentId:string assignments/list List course assignments in the workspace. body: type?:"WORKSPACE"|"GROUP"|"CUSTOM", groupId?:string assignments/remove Remove an assignment. body: assignmentId:string ``` ## course-editor The working revision **is** the persistent state: every call mounts the same draft and commits `/course/*` mutations back. `sessionId` adds a separate `/data` scratch directory that survives between calls; omit it for a fresh `/data` each time. See §10 for the command surface inside the sandbox. ``` course-editor/run Run a bash script against a course revision filesystem. body: courseId:string, script:string, sessionId?:string ``` ## courses ``` courses/activity Activity detail (cards/questions/body by type). body: activityId:string courses/list List courses visible to this Connector (owner/editor/admin). body: {} courses/project Project detail (type, config, learning goals). body: projectId:string courses/structure Unit/topic structure for a course. body: courseId:string courses/topic Topic detail (learning goals + activities). body: topicId:string courses/unit-test Unit test detail (questions). body: testId:string courses/view-image Fetch a course image asset as base64 + metadata. body: courseId:string, target:string ``` ## explore Explore is the in-workspace course catalog shown to learners. ``` explore/add-course Add a course to an Explore section. body: sectionId:string, courseId:string explore/candidate-courses List courses eligible to feature on Explore. body: {} explore/create-section Create an Explore section. body: slug:string, title:string, subtitle?:string, tag?:string, description?:string, color?:string, layout?:"row"|"featured"|"trending"|"path"|"path-vertical", position?:integer, isActive?:boolean, curriculumId?:string explore/delete-section Delete an Explore section. body: sectionId:string explore/remove-course Remove a course from an Explore section. body: sectionId:string, courseId:string explore/reorder Reorder Explore sections top-to-bottom. body: orderedIds:string[] explore/sections-list List Explore sections + the master toggle state. body: {} explore/set-audience Limit a section to groups, or hide it from groups. body: sectionId:string, visibleToGroupIds?:string[], hiddenFromGroupIds?:string[] explore/set-courses Replace a section's course list, in order. body: sectionId:string, courseIds:string[] explore/toggle Show or hide the Explore page for learners. body: exploreEnabled:boolean explore/toggle-home Also show the Explore rows on the learner home page. body: exploreOnHome:boolean explore/update-section Update an Explore section. body: sectionId:string, slug?:string, title?:string, subtitle?:string, tag?:string, description?:string, color?:string, layout?:"row"|"featured"|"trending"|"path"|"path-vertical", position?:integer, isActive?:boolean, curriculumId?:string ``` ## groups Groups are cohorts, and they nest. `parentId` builds subtrees. ``` groups/create Create a group. body: name:string, description?:string, tags?:string[], parentId?:string groups/delete Delete a group (cascades children + memberships). body: groupId:string groups/list List groups (optionally a subtree). body: parentId?:string, includeDescendants?:boolean groups/member-add Add a user to a group. body: groupId:string, userId:string, systemKey?:"ADMIN"|"INSTRUCTOR"|"MEMBER" groups/member-remove Remove a user from a group. body: groupId:string, userId:string groups/member-set-status Set a group member's status (ACTIVE/INACTIVE). body: groupId:string, userId:string, status:"ACTIVE"|"INACTIVE" groups/members-list List members of a group. body: groupId:string groups/update Update a group's name, description, or lead instructor. body: groupId:string, name?:string, description?:string, leadInstructorId?:string ``` ## invites ``` invites/create Invite a person to the workspace (or a group). body: email:string, firstName?:string, lastName?:string, role?:"ADMIN"|"INSTRUCTOR"|"MEMBER", groupId?:string invites/list List invites, optionally filtered by status. body: status?:"PENDING"|"ACCEPTED"|"EXPIRED"|"REVOKED" invites/resend Resend a pending invite email. body: inviteId:string invites/revoke Revoke a pending invite. body: inviteId:string ``` ## kb — Knowledge Base ``` kb/artifact-branches List Git branches for a migrated document. body: itemId:string kb/artifact-create-branch Create an isolated Git draft/agent/review branch. body: itemId:string, branch:string kb/artifact-delete Delete an item on an ACL-scoped branch. body: branchId:string, itemId:string, reason?:string kb/artifact-diff Compare an ACL-scoped branch with its base commit. body: branchId:string kb/artifact-files List files on an ACL-scoped Knowledge Base branch. body: branchId:string kb/artifact-merge Merge an ACL-scoped branch into canonical main. body: branchId:string, reason?:string kb/artifact-move Move an item on an ACL-scoped branch. body: branchId:string, itemId:string, parentId:string, reason?:string kb/artifact-read-file Read one file from an ACL-scoped Knowledge Base branch. body: branchId:string, path:string kb/artifact-write-file Commit one UTF-8 file to an ACL-scoped branch. body: branchId:string, path:string, content:string, reason?:string kb/confirm-image-upload Finalize a presigned image PUT (downloads bytes + indexes). body: itemId:string, imageId:string, imageContentType:string kb/create Create a document or folder. body: title:string, itemType?:"FOLDER"|"DOCUMENT", html?:string, parentId?:string, tags?:string[] kb/delete Soft-delete (or permanently delete) an item. body: itemId:string, permanent?:boolean kb/import-dispatch Dispatch a completed presigned upload for processing. body: jobId:string kb/import-status Poll transcript and visual-analysis status for an import. body: jobId:string kb/move Move an item under a folder (or to root). body: itemId:string, parentId?:string kb/permissions List permission grants on an item. body: itemId:string kb/public-link-disable Disable a document's public link. body: itemId:string kb/public-link-enable Publish a document's public /p/{slug} link. body: itemId:string kb/public-link-get Get a document's public link status. body: itemId:string kb/read Read a document's canonical HTML. body: itemId:string kb/read-version Read one historical document revision. body: itemId:string, versionId:string kb/restore Restore a soft-deleted item. body: itemId:string kb/restore-version Restore a historical revision as a new current version. body: itemId:string, versionId:string kb/revoke-share Revoke a permission grant. body: itemId:string, grantId:string kb/search Keyword + vector search over the KB. body: query:string, limit?:integer kb/share Grant a permission on an item. body: itemId:string, principalType:"WORKSPACE"|"GROUP"|"USER"|"API_KEY", role:"VIEWER"|"EDITOR"|"MAINTAINER"|"ADMIN", userId?:string, groupId?:string, integrationKeyId?:string kb/sign-image-upload Get a presigned PUT URL for a client-side image upload. body: itemId:string, imageContentType:string, imageFilename?:string, imageSizeBytes?:integer kb/str-replace Surgical exact-match HTML edit. body: itemId:string, oldString:string, newString:string, replaceAll?:boolean, expectedReplacements?:integer kb/tree List KB folders and documents. body: parentId?:string, includeDescendants?:boolean kb/update Rename and/or retag an item. body: itemId:string, title?:string, tags?:string[] kb/upload-image Upload an image (base64) and get an embeddable public URL. body: itemId:string, imageContentType:string, imageBase64:string, imageFilename?:string kb/versions List document revision history. body: itemId:string, limit?:integer, offset?:integer kb/walkthrough-import-start Create a walkthrough import and return a presigned video PUT URL. body: filename:string, mimeType:"video/mp4"|"video/webm"|"video/quicktime", sizeBytes:integer, sha256:string, parentId?:string, title?:string kb/walkthrough-read Read structured walkthrough steps and short-lived media URLs. body: jobId:string kb/write Overwrite a document's HTML. body: itemId:string, html:string ``` ## learning — cohort and per-learner analytics ``` learning/assignment-cohort Assignment assignees: completion %, weak units, struggling topics. body: assignmentId:string learning/completion-time Fastest/slowest learners by avg tracked time-to-complete. body: groupId?:string, search?:string learning/completions Paginated course completion events. body: courseId?:string, userId?:string, groupId?:string, page?:integer, limit?:integer learning/course-at-risk Who hasn't started, is behind, or has low test scores. body: courseId:string, groupId?:string, behindBelow?:integer, scoreBelow?:integer learning/course-detail All assigned members with progress %, scores, status. body: courseId:string, groupId?:string learning/course-units Per-unit completion %, avg test score, avg time-on-task. body: courseId:string, groupId?:string learning/creator-review One creator's video-review submission + the Creator Team's feedback thread + acknowledgment status. body: userId:string, projectId:string learning/learning-modes Which modalities learners used (% of cohort). body: courseId?:string, userId?:string, groupId?:string learning/learning-modes-time Time spent per modality. body: courseId?:string, userId?:string, groupId?:string learning/overdue-assignments All incomplete assignments past due date in scope. body: courseId?:string, groupId?:string learning/project-results One learner's graded project: rubric grade + submission (form field responses, etc.). body: userId:string, projectId:string, attemptId?:string learning/project-scenarios Role-play / scenario completion, attempts, avg best score. body: courseId:string, unitId?:string, groupId?:string learning/question-stats Cohort wrong-answer rates per question (worst-first). body: kind:"test"|"quiz", refId:string, groupId?:string learning/scenario-attempts One learner's scenario attempt: evaluation + transcript. body: userId:string, projectId:string, attemptId?:string learning/struggling-topics Ranked hardest topics (low completion + low scores). body: courseId:string, groupId?:string, limit?:integer learning/student-questions One learner's per-question answers (office-hours drill-down). body: userId:string, kind:"test"|"quiz", refId:string, attemptId?:string learning/student-units One learner's unit/topic/activity breakdown + times vs team avg. body: userId:string, courseId:string, groupId?:string learning/training-activity Daily active learners for the last 7 days. body: groupId?:string learning/unit-topics Per-topic completion %, avg quiz score, avg time-on-task. body: unitId:string, groupId?:string ``` ## members ``` members/list List workspace members and their roles. body: {} members/remove Remove a member from the workspace. body: userId:string members/set-role Set a member's workspace role. body: userId:string, systemKey:"ADMIN"|"INSTRUCTOR"|"MEMBER" members/set-tags Replace a member's organizational tags (labels). Creates missing tags; [] clears. body: userId:string, tags:string[] ``` ## performance ``` performance/course Class-wide rollup for a course. body: courseId:string performance/quiz Score distribution for one quiz activity. body: activityId:string performance/student One student's quiz/test scores and activity completion. body: userId:string, courseId?:string performance/test Score distribution for one unit test. body: testId:string ``` ## radar Radar watches the web and connected sources and raises alerts. ``` radar/alert-dismiss Dismiss a Radar alert. body: alertId:string radar/alerts-list List Radar alerts by status. body: status?:"new"|"dismissed"|"actioned"|"partially_actioned"|"all", limit?:integer radar/monitor-cancel Cancel a monitor. body: monitorId:string radar/monitor-create Create a Radar monitor. body: name:string, query:string, frequency?:"1h"|"1d"|"1w", tags?:string[], attachedCourseIds?:string[], description?:string radar/monitor-trigger Schedule an immediate monitor run. body: monitorId:string radar/monitors-list List Radar monitors. body: {} ``` ## workspace ``` workspace/get Get workspace metadata (name, member + group counts). body: {} workspace/update Update workspace name and/or slug. body: name?:string, slug?:string ``` ## workspace-assistant Same `just-bash` environment, the same `fetch-*` / `do-*` / `navigate` commands, and the same `/skills/*.md` documentation as the in-app assistant. This is where invites, member management, messaging, and anything without a dedicated command live. Returns `sessionId`, `exitCode`, `stdout`, `stderr`, `navigateIntents`, and a `/data` snapshot report. See §10. ``` workspace-assistant/run Run a bash script in the Workspace Assistant sandbox. body: script:string, sessionId?:string ``` ## Worked examples Top-line numbers: ```bash curl -sX POST https://honen.com/api/v1/analytics/hero \ -H "Authorization: Bearer $HONEN_API_KEY" -H "Content-Type: application/json" -d '{}' ``` Who is behind on a course: ```bash curl -sX POST https://honen.com/api/v1/learning/course-at-risk \ -H "Authorization: Bearer $HONEN_API_KEY" -H "Content-Type: application/json" \ -d '{"courseId":"66f0…","behindBelow":50,"scoreBelow":70}' ``` Assign a course to a group with a due date: ```bash curl -sX POST https://honen.com/api/v1/assignments/create \ -H "Authorization: Bearer $HONEN_API_KEY" -H "Content-Type: application/json" \ -d '{"courseIds":["66f0…"],"scopeType":"GROUP","groupId":"66f1…","dueDate":"2026-12-01T00:00:00.000Z"}' ``` Search the Knowledge Base and read the top hit: ```bash curl -sX POST https://honen.com/api/v1/kb/search \ -H "Authorization: Bearer $HONEN_API_KEY" -H "Content-Type: application/json" \ -d '{"query":"lockout tagout","limit":5}' curl -sX POST https://honen.com/api/v1/kb/read \ -H "Authorization: Bearer $HONEN_API_KEY" -H "Content-Type: application/json" \ -d '{"itemId":"66f2…"}' ``` Build a course from a script: ```bash curl -sX POST https://honen.com/api/v1/course-editor/run \ -H "Authorization: Bearer $HONEN_API_KEY" -H "Content-Type: application/json" \ -d '{"courseId":"66f0…","script":"cat /skills/course-structure.md; list-files"}' ``` --- # 6. Connector management REST API These routes are **session-authenticated** (dashboard cookie), not bearer-authenticated, and require the `MANAGE_INTEGRATIONS` workspace permission. They manage the credentials that §4 consumes. All are `POST` with a JSON body unless noted, and all use the same `{ success, data | error }` envelope. | Route | Purpose | Body | | --- | --- | --- | | `/api/connectors/list` | Every `STANDARD` Connector in the active workspace. Ephemeral `hns_*` keys are excluded. | — | | `/api/connectors/get` | One Connector, including its child tokens | `{ connectorId }` | | `/api/connectors/create` → **201** | Create; raw secret returned once as `raw` | `{ label (1–100), description? (≤500), iconColor? (/^#[0-9a-fA-F]{6}$/), policy, expiresAt? (ISO) }` | | `/api/connectors/update` | Change label, description, color, or policy | `{ connectorId, label?, description?, iconColor?, policy? }` | | `/api/connectors/rotate` | New secret; old value immediately dead | `{ connectorId }` | | `/api/connectors/revoke` | Kill every credential on the Connector | `{ connectorId }` | | `/api/connectors/tokens/create` → **201** | Additional non-expiring child token | `{ connectorId, label (1–100) }` | | `/api/connectors/tokens/rotate` | New secret for one child token | `{ connectorId, tokenId }` | | `/api/connectors/tokens/revoke` | Revoke one child token | `{ connectorId, tokenId }` | | `/api/connectors/audit` | Workspace integration audit feed, cursor-paginated | `{ connectorId?, q? (≤200), source?, after?, before?, cursor?, limit? (1–200, default 50) }` | | `/api/connectors/logs` | Request-level MCP / CLI / HTTP-API logs | `{ connectorId?, source?, transport?, method?, status? (100–599), ok?, tag?, q? (≤300), after?, before?, cursor?, limit? (≤200) }` | | `GET /api/connectors/logs/{id}` | One request log in full | path `id` | | `/api/integrations/audit/list` | Simple non-cursor audit list | `{ limit: 1–200, default 50 }` | Audit cursor format is opaque (`createdAt|id`); pass `nextCursor` straight back as `cursor`. `nextCursor: null` means end of feed. **Deprecated.** The four `/api/integrations/keys/{create,list,rotate,revoke}` routes still work but return a deprecation header pointing at their `/api/connectors/*` replacement. Do not build against them. Audit action names you will see: `connectors.create`, `connectors.update`, `connectors.rotate`, `connectors.revoke`, `connectors.token.create`, `connectors.token.rotate`, `connectors.token.revoke`, `connectors.mcp.workspace_assistant`, `integrations.key.issue_agent`. --- # 7. MCP — Honen as an MCP server Honen speaks the Model Context Protocol over Streamable HTTP. There are three servers. | Endpoint | Auth | Runs as | Use when | | --- | --- | --- | --- | | `https://honen.com/api/mcp/mcp` | OAuth 2.1 + PKCE | the consenting user, with their full permissions | a person connects Claude / Cursor / ChatGPT to their own Honen account | | `https://honen.com/api/mcp/c/{connectorId}/mcp` | `Authorization: Bearer hn_…` | the Connector creator, narrowed by `ConnectorPolicy` | an automation, a contractor, or an agent that should see one folder and nothing else | | `https://honen.com/api/courses/{courseId}/mcp/mcp` | OAuth | the consenting user | a client scoped to one course | CORS is open (`Access-Control-Allow-Origin: *`) with `Authorization`, `Content-Type`, `MCP-Protocol-Version`, `MCP-Session-Id`, and `Last-Event-ID` allowed; `WWW-Authenticate` and `MCP-Session-Id` are exposed. `OPTIONS` returns 204. `maxDuration` is 300 seconds. ## OAuth 2.1 Discovery documents (both cached 300 s, CORS-open): `GET /.well-known/oauth-authorization-server` (RFC 8414) ```json { "issuer": "https://honen.com", "authorization_endpoint": "https://honen.com/api/oauth/authorize", "token_endpoint": "https://honen.com/api/oauth/token", "registration_endpoint": "https://honen.com/api/oauth/register", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "token_endpoint_auth_methods_supported": ["none"], "code_challenge_methods_supported": ["S256"], "scopes_supported": ["honen:mcp","course:edit","site:edit","knowledgebase.read","knowledgebase.write","workspace-assistant"], "service_documentation": "https://honen.com/dashboard/settings" } ``` `GET /.well-known/oauth-protected-resource` (RFC 9728) ```json { "resource": "https://honen.com/api/mcp/mcp", "authorization_servers": ["https://honen.com"], "scopes_supported": ["honen:mcp","course:edit","site:edit","knowledgebase.read","knowledgebase.write","workspace-assistant"], "bearer_methods_supported": ["header"], "resource_documentation": "https://honen.com/dashboard/settings" } ``` A per-course variant lives at `/api/courses/{courseId}/mcp/.well-known/oauth-protected-resource`. ### Scopes | Scope | Grants | | --- | --- | | `honen:mcp` | Umbrella scope; required on every MCP request | | `course:edit` | `course_editor`, `course_create`, `course_preview`, `course_share`, `course_view_image`, `list_courses` | | `site:edit` | Site sandbox editing. Publishing is re-checked per call against your own rights, so this is the editor surface, not permission to push a site live | | `knowledgebase.read` | `tree`, `search`, `read`, `read_image`, `sign_image_read`, `versions` | | `knowledgebase.write` | `write`, `str_replace`, `create`, `update`, `move`, `delete`, image uploads, sharing, public links, `issue_dev_token` | | `workspace-assistant` | Bash in the Workspace Assistant sandbox. Also requires the `USE_WORKSPACE_ASSISTANT` workspace permission | `knowledgebase.admin` exists in code as a wildcard over the `knowledgebase.*` family but is **not obtainable through OAuth** — it appears only on agent-issued keys. Scope negotiation at `/authorize`: granted = intersection of the requested `scope` with the set above. No `scope` parameter grants the full set. A non-empty request whose intersection is empty is rejected with `invalid_scope` rather than silently re-expanded. ### Flow - **PKCE is mandatory and S256-only.** `code_challenge` is required; `code_challenge_method` defaults to `S256` and any other value is rejected. Verification runs before the single-use check, so a leaked-but-unredeemed code cannot be burned by an attacker who lacks the verifier. - **Public clients only** (`token_endpoint_auth_method: "none"`). `client_id` and `redirect_uri` must match the values sealed at `/authorize` exactly. - `POST /api/oauth/token` accepts `application/x-www-form-urlencoded` (per RFC 6749 §3.2), `application/json`, and content-type-less bodies sniffed by a leading `{`. Grants: `authorization_code`, `refresh_token`. Anything else → `unsupported_grant_type`. - Refresh **rotates** the refresh token but does **not extend** the grant: `refresh_token_expires_in` counts down from the original issue. - Responses carry `Cache-Control: no-store` and `Pragma: no-cache`. ```json { "access_token": "…", "refresh_token": "…", "token_type": "Bearer", "expires_in": 7776000, "refresh_token_expires_in": 31536000, "scope": "honen:mcp course:edit site:edit knowledgebase.read knowledgebase.write workspace-assistant" } ``` ### Dynamic client registration (RFC 7591) `POST /api/oauth/register` → **201**. Stateless: no row is written and `client_id` is a fresh UUID each call, so cached client IDs from earlier deployments keep working. A malformed or absent body is tolerated. ```json { "client_id": "…uuid…", "client_id_issued_at": 1234567890, "client_name": "Honen MCP Client", "redirect_uris": [], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none", "scope": "honen:mcp" } ``` `response_types` and `token_endpoint_auth_method` are always forced to these values; `client_name`, `redirect_uris`, `grant_types`, and `scope` echo the request. ### Resource indicators (RFC 8707) `resource` is accepted at `/authorize` and `/token`, validated as an absolute `http(s)` URI with no fragment (else `invalid_target`), and stripped of its query string. Three outcomes: invalid → reject; matching our public origin → bind the token; **well-formed but unattributable → issue an unbound token rather than fail**, because custom domains and embed subdomains are legitimately ours but not cheaply provable. At `/token` a supplied `resource` must match the one sealed at `/authorize`; the sealed value always wins, so the audience can only narrow. Audience matching is a deliberately lenient bidirectional prefix test, so a client connecting to `/api/mcp` with a token bound to `/api/mcp/mcp` is not stuck in a permanent 401 loop. It still isolates the workspace server from per-course servers, and courses from each other. ### Personal MCP tokens `POST /api/mcp/token` mints a 1-year token for the signed-in user with a **fixed four-scope set** that deliberately omits Sites and the Assistant: ``` honen:mcp course:edit knowledgebase.read knowledgebase.write ``` Use OAuth unless you specifically need a long-lived static header. ## Per-Connector MCP (bearer) No OAuth dance. Point the client at the Connector endpoint and set the header: ``` POST https://honen.com/api/mcp/c//mcp Authorization: Bearer hn_<8hex>_ ``` The bearer must belong to that exact Connector; a mismatch returns `API key does not match the Connector in the request path`. Every tool call is authorized against the Connector's policy before it runs. --- # 8. MCP — the tool surface ## Global server (`/api/mcp/mcp`) | Tool | Scope | What it does | | --- | --- | --- | | `honen_docs` | — | Documentation for the toolset. `topic` selects a tool; omit for an index. **Call `honen_docs(topic="brand")` before generating any human-visible HTML.** | | `list_workspaces` | — | ` ` rows, ` (personal)` suffixed | | `list_courses` | `course:edit` | ` (owner\|editor)` rows | | `knowledge_base` | `knowledgebase.read` / `.write` | One tool, ~40 actions (below) | | `workspace_assistant` | `workspace-assistant` | Bash in the assistant sandbox | | `course_create` | `course:edit` + `CREATE_COURSE` | Create an empty course; returns `courseId`, `workingRevisionId` | | `course_editor` | `course:edit` | Bash against a course revision filesystem | | `course_preview` | `course:edit` | Short-lived authenticated preview URL, rendered inline as an MCP App resource | | `course_share` | `course:edit` | The stable "anyone with the link" learner URL | | `course_view_image` | `course:edit` | Pull a course asset in as multimodal image content | | `list_sites` | `site:edit` | Sites you can edit, with `editorUrl` | | `site_editor` | `site:edit` | Bash in the Site's long-lived cloud sandbox | | `site_preview` | `site:edit` | Live editor preview URL | | `site_upload` | `site:edit` | Write a base64 file into the sandbox | | `site_upload_url` | `site:edit` | 15-minute presigned PUT/POST for raw bytes (preferred over base64) | | `site_view_image` | `site:edit` | Pull a sandbox image in as multimodal content | | `site_settings` | `site:edit` | `get` / `update`; publish-facing fields require publish rights | | `site_status` | `site:edit` | `PUBLISHED` / `DRAFT` / `ARCHIVED` visibility | | `site_publish` | `site:edit` | Build and deploy to the live domain | `knowledge_base` actions: ``` tree search read walkthrough_import_start import_dispatch import_status walkthrough_read write str_replace create update move delete restore versions read_version restore_version artifact_branches artifact_create_branch artifact_files artifact_read_file artifact_write_file artifact_diff artifact_merge artifact_move artifact_delete permissions share revoke_share public_link_get public_link_enable public_link_disable upload_image sign_image_upload confirm_image_upload read_image sign_image_read issue_dev_token ``` `workspaceId` is required on every `knowledge_base` action. Get it from `list_workspaces`. ### `issue_dev_token` — the agent → CLI handoff Mints a short-lived `hns_*` workspace API key for `/api/v1/*` or the `honen` CLI. ``` tokenScopes? desired scopes; intersected with the agent's own. Empty intersection is an error. tokenTtlSeconds? default 900, clamped 60 ≤ ttl ≤ 3600 tokenLabel? shows in the integrations audit log ``` The raw value is shown once and logged as `integrations.key.issue_agent`. Do not echo it into user-visible output; pass it through an environment variable. ```bash TOKEN=$(<mint via MCP issue_dev_token>) HONEN_API_KEY="$TOKEN" honen kb search "onboarding" HONEN_API_KEY="$TOKEN" honen kb create-doc "Q1 report" --parent "<folder-id>" --file ./q1.html ``` ## Per-Connector server (`/api/mcp/c/{id}/mcp`) Three hand-written tools plus the entire command registry: - `knowledge_base` — the same action surface, minus images and `issue_dev_token`, gated by the Connector's KB rules - `workspace_assistant` — only if `workspaceAssistant.enabled` - `whoami` — returns `{ connectorId, workspaceId, policy }`. Call this first when debugging a `403`. - **Every registry command from §5 as `<domain>_<action>`** — `analytics_hero`, `members_invite`, `learning_course_at_risk`, `explore_create_section`, and so on. The `kb` and `workspace-assistant` domains are excluded because the dedicated tools above already own them. Because it is the same registry, the HTTP API, these MCP tools, and the OpenAPI spec are by construction identical. ## Error convention MCP tools return errors as `isError: true` text content, never as JSON-RPC errors. This is deliberate: one call can surface partial success, and a model can correct itself in-loop. A missing required field comes back as `Invalid input: action='x' requires 'y'. See honen_docs(topic="…").` --- # 9. MCP — client configuration Endpoint: `https://honen.com/api/mcp/mcp`. OAuth, no API key. `https://honen.com/mcp` has one-click installers. **Cursor** (`~/.cursor/mcp.json`), VS Code, Windsurf, Zed, and most other clients: ```json { "mcpServers": { "honen": { "type": "http", "url": "https://honen.com/api/mcp/mcp" } } } ``` **Claude Code:** ```bash claude mcp add --transport http honen https://honen.com/api/mcp/mcp ``` **Claude (web, Desktop, mobile):** Settings → Connectors → **Add custom connector**, name it `Honen`, paste the endpoint. Leave the OAuth fields blank — Honen auto-registers your client. On Team and Enterprise plans an Owner must first enable connectors under Organization settings → Connectors. **ChatGPT:** Settings → Connectors → add a custom connector and paste the endpoint. Availability depends on your plan and whether your workspace admin allows custom connectors. **Deep links** (generated by `https://honen.com/mcp`): ``` cursor://anysphere.cursor-deeplink/mcp/install?name=honen&config=<base64 of the server config> vscode:mcp/install?<urlencoded JSON of the server config> vscode-insiders:mcp/install?<urlencoded JSON of the server config> lmstudio://add_mcp?name=honen&config=<base64 of the server config> ``` **Per-Connector (bearer, scoped):** ```json { "mcpServers": { "honen-reporting": { "url": "https://honen.com/api/mcp/c/<connectorId>/mcp", "headers": { "Authorization": "Bearer hn_ab12cd34_<secret>" } } } } ``` Any MCP-aware client that speaks OAuth 2.1 + PKCE works; discovery happens through `/.well-known/oauth-protected-resource`. --- # 10. `honen_docs` topics and the sandbox skill catalog `honen_docs` is the in-protocol documentation tool. Topics: ``` overview knowledge_base workspace_assistant course_create course_editor course_share course_view_image site_editor list_workspaces list_courses honen_docs brand ``` `brand` returns Honen's HTML style guide (cream + soft black, Source Serif 4 + Inter, six secondary palettes, and the block patterns: eyebrow + title + body, stat-block, kpi-grid, card-grid, quote, takeaway). Knowledge Base HTML is rendered in the workspace iframe, so this is the difference between a Honen page and a generic web page. ## State model per tool | Tool | Persistent state | Session needed? | | --- | --- | --- | | `knowledge_base` | Metadata in Mongo, revisions in object storage | No — automatic | | `course_editor` | The working revision tree in a content-addressed blob store | No for content; `sessionId` only adds a `/data` scratch dir | | `site_editor` | A long-lived sandbox shared with the in-app editor | No — the sandbox *is* the state | | `workspace_assistant` | None by default | `sessionId` to persist `/data` | `/data` snapshots are capped at roughly 2 MB and belong to the creating user. Pass `sessionId: "new"` on the first call and re-pass the id the response returns. ## Workspace Assistant skills (`cat /skills/<name>.md` inside your script) | Skill | Covers | | --- | --- | | `people.md` | Members, pending invites, `do-invite`, `do-revoke-invite`, `do-resend-invite`, roles, `do-remove-member`, `do-set-role` | | `groups.md` | Group tree, membership, statuses | | `assignments.md` | Creating and inspecting assignments | | `analytics.md` | Workspace and cohort analytics | | `performance.md` | Quiz/test score distributions | | `courses.md` | Course listing (`canEdit` marks editor-safe rows), `open-course-creator`, `open-course-editor` | | `radar.md` | Web monitors, Slack/Teams/Drive connections, alerts | | `knowledge-base.md` | KB administration from the assistant | | `brand.md` | Brand rules for generated output | | `messaging.md` | Slack, Microsoft Teams, Google Chat, iMessage | | `json-render.md` | `spec` fences, renderable components, `navigate` | | `destructive.md` | `ActionBar` confirmation for destructive operations | **There is no separate `invite` / `add_member` MCP tool.** Those live in `workspace_assistant` — see `/skills/people.md`. ## Course editor sandbox Commands: `list-files`, `read-file`, `write-file`, `generate-*`, `course-create-unit`, `course-create-topic`, `course-bootstrap-outline`. `cat /skills/*.md` inside the script for the live catalog; `course-structure.md` and `course-bootstrap-outline.md` are the two to start with. Fastest path for a whole course: write `/course/bootstrap-outline.json`, then run `course-bootstrap-outline /course/bootstrap-outline.json --template=academic`. READ, QUIZ, FLASHCARDS, and GAME activities are written directly as files (`activities/read.md`, `activities/quiz.json`, `activities/flashcards.json`). `course-generate` is only for media types: LECTURE, PODCAST, COMIC, VIDEO, JAM_SESSION. **The gotcha worth internalizing:** the compiler only writes the `Course` row when a course-level file is dirty. A course built purely from unit/topic/activity writes stays `isDraft: true` and never appears in `list_courses`. Always also write `/course/description.md` and `/course/about.md`. There is no publish step for courses — `course_editor` compiles to the live course on every run. Sites are the exception and need `site_publish`. ## Site editor sandbox React + Vite project with a Cloudflare Worker. **`cat SITE_AGENT.md` first** — it is the same briefing the in-app Site Assistant follows. `ls src` gives `main.tsx`, `worker.ts`, `styles.css`, `docs.tsx`, `site-sdk.ts`; `cat site.manifest.json` gives theme, catalog, routes, and docs config. Content is **not** in the repo. Courses, curricula, and published docs are fetched at request time through `honen.*` in `src/site-sdk.ts`; hardcoding catalog or docs content is rejected by a validator. Browser tooling inside the sandbox: `agent-browser open|snapshot -i|screenshot|close`, then `site_view_image(path=…)`. Prefer `snapshot -i` over screenshots for "did this render" — far cheaper in context. Three separate concepts, often confused: editor edits appear in the live preview immediately; `site_publish` builds and deploys to the live domain; `site_status` controls whether the world may see it. A site must deploy successfully before `site_status` can go `PUBLISHED`. `sfdeploy deploy` is refused inside a script. --- # 11. The Honen CLI Binaries: `honen`, `honen-kb`. > **Availability note.** The CLI ships as an internal workspace package in the Honen > monorepo (`@repo/honen-cli`, `private: true`). The dashboard advertises > `curl -fsSL https://honen.com/cli | sh` and `npm install -g @honen/cli`; confirm the > published artifact before scripting against a specific install path. Everything the CLI > does is a thin wrapper over `POST /api/v1/*`, so `curl` is always a valid substitute. ## Configuration Environment variables only. No config file, no `login` command, no keychain. ```bash export HONEN_API_URL=https://honen.com # falls back to KB_API_URL export HONEN_API_KEY=hn_… # falls back to KB_API_KEY ``` Missing either → `Set HONEN_API_URL and HONEN_API_KEY before using honen.` A trailing slash on the URL is stripped. The key is sent as `Authorization: Bearer <key>`. Responses are unwrapped from the `{ success, data, error }` envelope; a failure throws with `error.message`. ## Commands ``` honen kb search <query> [--limit N] [--json] honen kb tree [--parent ID] [--all] [--children-only] [--json] honen kb read <itemId> [--text] [--json] honen kb write <itemId> --file file.html honen kb str-replace <itemId> --old text|--old-file path --new text|--new-file path [--all] [--expected N] [--json] honen kb create-doc <title> [--parent ID] [--file file.html] [--tag a --tag b] [--json] honen kb create-folder <title> [--parent ID] [--tag a --tag b] [--json] honen kb upload-image <file> [--content-type <mime>] [--alt <text>] [--sign] [--html] [--json] honen api <get|post|put|patch|delete> /api/... [json|--file path|-] ``` Aliases: `str-replace` → `replace`; `upload-image` → `image`, `image-upload`. Every `kb` subcommand also works as a bare top-level shorthand, so `honen search "x"` is `honen kb search "x"` — which is what makes the `honen-kb` binary name work. `--tags a,b,c` is accepted anywhere `--tag` is. Both `--opt value` and `--opt=value` parse. `honen api` is the escape hatch and can reach any `/api/v1/*` command, not just `kb`. The path must start with `/`. The body comes from an inline JSON string, `--file path`, `--file -`, or a bare `-` (both read stdin). `create-doc` with no HTML defaults the body to `<article><h1>{title}</h1></article>`. `upload-image` infers the content type from the extension (`png`, `jpg`/`jpeg`, `gif`, `webp`, `svg`), and with `--html` emits `<img src="<publicUrl>" alt="<description>" />` where alt precedence is `--alt` → the server-generated caption → the filename. `--sign` switches to the three-step presigned flow. ## Command → endpoint mapping | Command | Request | | --- | --- | | `search` | `POST /api/v1/kb/search` `{ query, limit? }` | | `tree` | `POST /api/v1/kb/tree` `{ includeDescendants, parentId? }` | | `read` | `POST /api/v1/kb/read` `{ itemId }` | | `write` | `POST /api/v1/kb/write` `{ itemId, html }` | | `str-replace` | `POST /api/v1/kb/str-replace` `{ itemId, oldString, newString, replaceAll, expectedReplacements? }` | | `create-doc` | `POST /api/v1/kb/create` `{ title, itemType: "DOCUMENT", html, parentId?, tags? }` | | `create-folder` | `POST /api/v1/kb/create` `{ title, itemType: "FOLDER", parentId?, tags? }` | | `upload-image` | `POST /api/v1/kb/upload-image` `{ imageContentType, imageBase64, imageFilename }` | | `upload-image --sign` | `sign-image-upload` → client `PUT` to `uploadUrl` → `confirm-image-upload` | --- # 12. Agent website embeds A Honen Agent is an AI assistant scoped to a workspace, deployed to Slack, Microsoft Teams, iMessage, and your own website. The website channel is an **iframe** — there is no loader script and no `embed.js`. Public embed URL format: ``` https://embed.honen.com/agent/<publicId> ``` `publicId` matches `/^agpub_[A-Za-z0-9_-]{16,}$/`. Legacy per-agent hosts `agent-<publicId>.honen.com` still resolve so existing snippets keep working. ## Public (unauthenticated) embed Paste into WordPress or any iframe-capable builder: ```html <iframe src="https://embed.honen.com/agent/agpub_XXXXXXXXXXXXXXXX" title="Support Agent" style="width:100%;min-height:640px;border:0;" loading="lazy" ></iframe> ``` Requires `config.publicAccess === true` on the deployment and is refused for `MEMBER_GATED` agents. Anonymous session creation is rate-limited to **60 per deployment per 60 s** (429 `RATE_LIMITED`). ## Signed embed — your users, identified Three tiers of credential: 1. **Embed key** (`agek_<12hex>_<secret>`) — server-side, long-lived. Never send it to a browser. 2. **Ticket** (`agt_…`) — **2 minutes, single use**, exchanged for a session. Consumption is an atomic conditional update, so exactly one of N concurrent exchanges wins. 3. **Session** (`ags_…`) — 24 hours, re-validated on every call. The HTML is an empty-src iframe: ```html <iframe id="honen-agent" title="Support Agent" style="width:100%;min-height:640px;border:0;" loading="lazy" ></iframe> ``` Server — mint a ticket for the signed-in user: ```js const response = await fetch( "https://honen.com/api/agents/embed/tickets", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.HONEN_AGENT_KEY}`, }, body: JSON.stringify({ externalId: user.id, email: user.email, displayName: user.name, parentOrigin: "https://your-site.com", }), }, ); if (!response.ok) throw new Error("Could not start Agent session"); const { url } = (await response.json()).data; // Return only this short-lived URL to your authenticated browser. return Response.json({ url }); ``` Browser — assign the returned URL: ```js const { url } = await fetch("/api/agent-session").then((r) => r.json()); document.querySelector("#honen-agent").src = url; ``` The ticket travels in the URL **fragment** (`…#ticket=agt_…`) so it never reaches HTTP logs or referrer headers. ## Embed endpoints | Method + URL | Auth | Body / params | Returns | | --- | --- | --- | --- | | `POST /api/agents/embed/keys` | session + `MANAGE_INTEGRATIONS` | `{ deploymentId, label? (≤120) }` | `{ key: { id, prefix, label, createdAt }, raw }` — raw once | | `GET /api/agents/embed/keys?deploymentId=` | session + `MANAGE_INTEGRATIONS` | query `deploymentId` | `{ keys: [{ id, prefix, label, createdAt, lastUsedAt, revokedAt }] }` | | `DELETE /api/agents/embed/keys` | session + `MANAGE_INTEGRATIONS` | `{ keyId }` | `{ ok: true }` | | `POST /api/agents/embed/tickets` | `Bearer <embed key>` | `{ externalId (1–500, required), issuer? (1–200), displayName? (≤200), email?, metadata?, parentOrigin? }` | `{ ticket, url, expiresIn: 120 }` | | `POST /api/agents/embed/{publicId}/session` | ticket, or public | `{ ticket?, parentOrigin? }` | `{ expiresAt, identity }` + sets the session cookie | | `GET /api/agents/embed/{publicId}/bootstrap` | session (optional if public) | — | `{ publicId, name, description, persona, access: "public"\|"private", identity }` | | `GET /api/agents/embed/{publicId}/history` | session | — | `{ conversations: [...] }`, newest first | | `GET /api/agents/embed/{publicId}/messages?conversationId=` | session | query `conversationId` | `{ messages: [...] }`, oldest first | | `POST /api/agents/embed/{publicId}/messages` | session | `{ conversationId?, text (1–20000) }` | `{ conversationId, message, assistantMessage, runId }` | Read routes accept the session token as `Authorization: Bearer` or from the embed session cookie; Bearer wins. ## Origin enforcement and identity `config.allowedOrigins` is optional. Empty means no restriction. When set, the origin must parse as `http(s)` and appear in the list, and a ticket that recorded a `parentOrigin` must be exchanged from that same origin (`Embed ticket origin does not match`). Identity is upserted on `(deploymentId, provider = ticket.issuer ?? "default", externalId)`. If the ticket carries an `email`, Honen looks for a matching workspace membership; a `MEMBER_GATED` deployment with no match returns 401, and membership is re-checked on every request. `SERVICE` identity mode runs as the Connector's acting user instead of the visitor's linked account. ## Rate limits Per 60-second window: **20 user messages per identity**, **200 per deployment** → 429. On failure the trigger message is deleted, and a conversation created solely for it is deleted too. The assistant sees the last **30** messages of history. ## Agent guardrails An Agent's Connector is a **hard permission ceiling**. It cannot exceed its policy no matter what the prompt says or what a user asks for. Identity can be service-wide, hybrid, or member-gated. --- # 13. Outbound MCP — Honen's AI into your tools The Course Agent and Workspace Assistant can call out to MCP servers you already run. Presets, all Streamable HTTP, all OAuth: | id | Label | Category | Server URL | Notes | | --- | --- | --- | --- | --- | | `linear` | Linear | Productivity | `https://mcp.linear.app/mcp` | `/sse` exists as a legacy fallback; HTTP preferred | | `notion` | Notion | Productivity | `https://mcp.notion.com/mcp` | | | `granola` | Granola | Productivity | `https://mcp.granola.ai/mcp` | | | `hubspot` | HubSpot | Sales & CRM | `https://mcp.hubspot.com` | | | `slack` | Slack | Communication | `https://mcp.slack.com/mcp` | | | `google-calendar` | Google Calendar | Google Workspace | `https://calendarmcp.googleapis.com/mcp/v1` | Developer Preview | | `google-chat` | Google Chat | Google Workspace | `https://chatmcp.googleapis.com/mcp/v1` | Developer Preview | | `google-drive` | Google Drive | Google Workspace | `https://drivemcp.googleapis.com/mcp/v1` | Developer Preview | | `gmail` | Gmail | Google Workspace | `https://gmailmcp.googleapis.com/mcp/v1` | Developer Preview | | `google-people` | Google Contacts | Google Workspace | `https://people.googleapis.com/mcp/v1` | Developer Preview | The Google presets carry explicit `defaultScopes` because their servers do not advertise scopes through protected-resource metadata; without them the SDK mints a scope-less authorize URL that Google rejects with `Missing required parameter: scope`. All five include `openid email profile` so users appear under their real Google identity in the audit log. **Any MCP-compatible internal tool works too** — the presets are convenience, not a whitelist. Management routes: `/api/mcp-connections/{list,create,presets,test,callback}` and `/api/mcp-connections/{id}`. **Salesforce is deliberately absent.** Its hosted MCP servers accept only an External Client App, and an ECA refuses authorization from any org but the one that defined it, so a single platform-owned client cannot serve customers' orgs. Salesforce Knowledge still syncs into the Knowledge Base through a Connected App and is unaffected. If one of these tools is slow or down, the assistant carries on without it. Chat never hangs waiting on someone else's server. ## Inbound content sync Separately from MCP, the Knowledge Base pulls from: Google Drive, OneDrive, SharePoint, Notion, Confluence, Salesforce Knowledge, Document360, Granola, any website (crawled on a schedule), and YouTube (transcript). Sources are re-checked roughly **every minute**; when nothing changed, nothing happens. Deletions upstream remove the item. Sync is **one-way, inbound** — nothing you do in Honen writes back to the source. Refreshes run as the person who linked the source, so a synced folder can never show more than they could see themselves. --- # 14. Knowledge Base — the developer view One library per workspace. Items are documents or folders in a tree; `parentId: null` is the root. ## Search `kb/search` combines keyword and vector scoring. Results are constrained by the caller's ACLs and, for a Connector, by the policy allowlist. ## Editing - `kb/write` overwrites the canonical HTML and creates a new `KnowledgeDocumentVersion`, then asynchronously refreshes the search index and vectors. - `kb/str-replace` requires **exactly one** match unless you pass `replaceAll: true` or `expectedReplacements: N`. This is a safety property, not a limitation — use it. - `kb/update` replaces the whole tag list; it does not merge. - `kb/move` prevents cycles. `kb/delete` soft-deletes by default; `permanent: true` hard-deletes the item, its descendants, its versions, and its vectors, and requires `knowledgeBase.admin`. ## Images Two paths: - **Inline**: `kb/upload-image` with base64 (no `data:` prefix). Max **10 MB**. Allowed types: `image/png`, `image/jpeg`, `image/gif`, `image/webp`, `image/svg+xml` (SVG is served with a CSP that disables scripts). - **Presigned**: `kb/sign-image-upload` → `PUT` the bytes within 10 minutes → `kb/confirm-image-upload`. **Skipping confirm leaves the alt text empty and the image unfindable in search.** Confirm returns `NOT_FOUND` if the PUT has not landed; retrying is safe because storage is content-addressed. Upload responses include a generated `description`. Paste it verbatim into `alt` — that is what makes the image searchable through the document's plain-text snapshot. Reading images back: `read_image` returns bytes as MCP multimodal content with **no network access required** (capped at 3 MB, refuses SVG because no model provider accepts it as image input); `sign_image_read` returns a signed unauthenticated 10-minute URL. `imageRef` accepts a full proxy URL, `{workspaceId}/{file}`, a bare `{file}`, or the 32-hex image id. `kb/read` with `includeImageUrls: true` returns an `images` array of `{ filename, url, expiresAt, contentType, sizeBytes }` — signed 10-minute links, capped at **25** images, with `imagesTruncated` and `imagesSkipped: [{ filename, reason }]`. Images from synced sources are copied into workspace storage at import time, so documents do not fill with broken links when the upstream URL expires. ## Walkthrough video import ``` walkthrough-import-start → { jobId, uploadUrl } # PUT the exact bytes with the declared MIME import-dispatch → begins processing import-status → poll transcript + visual analysis walkthrough-read → structured steps + short-lived sourceVideoUrl / frameUrl / endFrameUrl ``` `sha256` must be 64 lowercase hex characters of the source bytes, and `sizeBytes` must be exact. The transcript is also written into the canonical HTML, so `read` and `search` see it. ## Artifact branches Documents migrated to the artifact store get a Git-backed filesystem with ACL-scoped branches (`draft/…`, `agent/…`, `review/…`). The server generates branch refs, re-authorizes every path, records signed commits, and re-checks scope at merge. Raw workspace repository tokens are never issued to item-scoped users, agents, or Connectors. ## Public links Documents only. `public-link-enable` publishes the latest version at `/p/{slug}` and requires item `ADMIN`. Always share the absolute `url` from the response, never the bare path. ## Permissions Grants are `(principalType, role)` pairs on an item: ``` principalType : WORKSPACE | GROUP | USER | API_KEY role : VIEWER | EDITOR | MAINTAINER | ADMIN ``` `API_KEY` grants are how you give a Connector access to a specific document without widening its policy. --- # 15. LMS integration — choosing a path Honen is a full LMS and does not need to replace one you already run. ## Status | Path | Status | Notes | | --- | --- | --- | | **LTI 1.3 / LTI Advantage** | Shipped, with gaps | Launch, Deep Linking, AGS grade passback, NRPS rosters, dynamic registration | | **SCORM 1.2 and 2004** | Shipped | Broadest LMS support; SCORM 1.2 is the default recommendation | | **xAPI (producer + LRS) and cmi5** | Shipped | ADL conformance 1353/1365 with 12 documented gaps; cmi5 export enables xAPI | | **Google Classroom** | Shipped | Native add-on, attachment discovery, grade passback | | LTI 1.1 | Schema only | `LTIVersion.LTI_1_1` exists; no active flow. Launch requires `1.3.0` | | SCORM / xAPI / cmi5 import | Not supported | Honen produces packages and statements; it does not play third-party ones | | Common Cartridge, AICC | Not supported | No runtime, exporter, or importer | | SAML 2.0 | **Not implemented** | Enum values and a UI tile exist; there is no ACS route, no metadata route, and no SAML library. Use OIDC. | ## Which one Ask which LMS they run, not which standard it speaks. | They run… | Use | Why | | --- | --- | --- | | Canvas, Moodle, Blackboard, D2L Brightspace, Skilljar | **LTI 1.3** | Deepest: rosters and server-side grades | | Cornerstone, Docebo, SAP SuccessFactors, Workday Learning, TalentLMS | **SCORM** | Package-based corporate LMSs | | Google Classroom | **Classroom add-on** | Native, and better than LTI there | | Anything with an LRS (Watershed, Learning Locker, SCORM Cloud) | **xAPI / cmi5** | They want the learning record, not one score | | Anything else, or they don't know | **SCORM** | Near-universal | | | LTI 1.3 | SCORM | xAPI / cmi5 | Classroom | | --- | --- | --- | --- | --- | | Setup | Admin handshake per platform | Download a zip, upload it | Zip, or an LRS endpoint | Install the add-on | | Rostering | Automatic (NRPS) | On first launch | On first launch | From Classroom | | Grade passback | Server-to-server (AGS) | Browser-side via the LMS's SCORM API | Statements to an LRS | Server-to-server | | Instructor picks content in-LMS | Yes (Deep Linking) | No | No | Yes (Attachment Discovery) | | Granularity | Score + completion | Score + completion | Every learning event | Score + completion | | Re-deploy after a content edit | Never | Never | Never | Never | **All four are independent and simultaneous.** One workspace can be LTI-connected to two universities, have SCORM packages in five corporate LMSs, ship cmi5 to a sixth, and run Google Classroom — all serving the same live courses into the same Honen analytics. `LTIConfiguration` is unique on `(platformId, ltiClientId, deploymentId)` and merely indexed by workspace; neither `SCORMPackage` nor `Cmi5Package` is unique on `courseId`. ## Thin, live packages Package-based paths do **not** bundle course content. The zip carries a launcher plus a signed token; the launcher loads the live Honen course. 1. **Edit the course and every deployed copy reflects it.** No re-export, no re-upload. 2. **The runtime is fetched live** (`/api/scorm/runtime.js`, `/api/cmi5/runtime.js`) with the bundled copy as an offline/CSP fallback, so the *bridge* can be fixed for already-distributed packages. Breaking changes bump a `?v=` wrapper version. Re-export only when a package's **settings** change (audience, group, reporting policy) or its secret is rotated. Re-exporting with identical settings reuses the existing package rather than accumulating stray tokens. ## Session isolation An embedded launch creates a `SameSite=None` session cookie. On the apex that would overwrite the admin's or learner's real session in the same browser, so every embedded launch runs on its own host: ``` SCORM (default tenant) scorm-<workspaceId>.honen.com SCORM (named tenant) scorm-<workspaceId>-<slug>.honen.com cmi5 reuses the SCORM tenant LTI (dynamic registration) lti-<configId>.honen.com LTI (manual config) the apex — the setup URLs are copied into the LMS before the connection exists, so a subdomain can't be committed up front SSO connection sso-<configId>.honen.com Agent embeds embed.honen.com ``` Sessions use **partitioned (CHIPS) cookies**, so launches work where third-party cookies are blocked. Where neither is supported, the learner gets an explicit "open in a new tab" screen and progress still reports, because the wrapper tracks by token rather than cookie. While a tenant's domain is provisioning, a launch shows a self-refreshing "setting up" screen (up to 15 attempts, ~2 minutes); the fallback is never "write the session on the apex anyway." **Known caveat:** isolation is per tenant subdomain, not per learner. Two learners on the same tenant in the same browser profile (a shared kiosk) share one cookie jar and the second launch replaces the first. Use separate profiles, or route cohorts to separate tenants. ## Learner provisioning `INTERNAL` learners are normal workspace members and **consume a billable seat**. `EXTERNAL` learners are community/academy learners attached to a Site with a capped role and are **excluded from seat counts**. Identity is scoped by `(workspaceId, tenantId, lmsLearnerId, learnerType)` — deliberately including the tenant, because two client LMSs can both call someone `12345` and they are different people. SCORM and cmi5 share one identity table, so one human launching both is one Honen user. Learners get a deterministic, non-deliverable synthetic email so they can claim a real account later. That address must never escape into an xAPI actor `mbox`. --- # 16. LTI 1.3 / LTI Advantage ## Endpoints | URL | Methods | Purpose | | --- | --- | --- | | `/api/lti/login` | `GET`, `POST` | OIDC third-party initiation | | `/api/lti/launch` | `POST` | `id_token` callback; also the registered `redirect_uri` | | `/api/lti/jwks` | `GET` | Tool JWKS | | `/api/lti/deep-link/complete` | `POST` | Builds the Deep Linking response | | `/api/lti/dynamic-registration` | `GET` | Registration endpoint; requires `?setup=<LtiSetupToken>` | | `/api/workspaces/lti/nrps-sync` | `POST` | NRPS membership pull | | `/api/workspaces/lti` | `GET`, `POST`, `PATCH`, `DELETE` | Connection CRUD | Learner-facing pages: `/lti/home`, `/lti/launching`, `/lti/open`, `/lti/redeem`, `/lti/setup-required`, `/lti/deep-link/assign`. Admin UI: `/dashboard/workspace/integrations/lms`. ## Setup **Dynamic registration (preferred).** Hand the LMS: ``` https://honen.com/api/lti/dynamic-registration?setup=<setupToken> ``` The tool registers itself and the connection gets its own isolated subdomain atomically. Canvas and Moodle support this. **Manual configuration.** Paste these three URLs into the LMS: ``` loginUrl: https://honen.com/api/lti/login launchUrl: https://honen.com/api/lti/launch jwksUrl: https://honen.com/api/lti/jwks redirectUris: [ https://honen.com/api/lti/launch ] ``` Manual connections stay on the apex. Re-connecting via dynamic registration adopts an isolated subdomain. Honen keeps a per-connection RSA keypair (`keyId`, `publicKey`, `privateKey` on `LTIConfiguration`) for signing service calls. ## Launch sequence 1. LMS → `/api/lti/login` with `iss`, `client_id`, `login_hint`, `lti_message_hint`, `target_link_uri`, and `lti_deployment_id` (Canvas) or `deployment_id`. The first three are required (400 otherwise). Both GET query and POST form are accepted. 2. Honen creates an `LtiLaunchState` (`state` + `nonce`) and issues a **303** — deliberately not a 307, because a platform that POSTs the initiation would otherwise replay the POST — to the platform auth endpoint with: ``` scope=openid response_type=id_token response_mode=form_post client_id=<client_id> redirect_uri=<embedBase>/api/lti/launch login_hint=<login_hint> state=<state> nonce=<nonce> prompt=none lti_message_hint=<passthrough> target_link_uri=<passthrough> ``` 3. Platform → `POST /api/lti/launch`. Honen verifies the `id_token` against the platform JWKS with `jose`: issuer, audience, `azp` when multiple audiences are present, nonce, and LTI version. Then it consumes the one-time `LtiLaunchState` (CSRF/replay), backfills `deploymentId` on first sight, resolves the target through **`LTIResourceLink` — the source of truth that prevents forged custom launch parameters from opening arbitrary content** (custom params are hints only), provisions the user, maps the LTI context to a Honen Group, creates an iframe-safe session, and redirects. ## Claims ``` https://purl.imsglobal.org/spec/lti/claim/message_type https://purl.imsglobal.org/spec/lti/claim/version https://purl.imsglobal.org/spec/lti/claim/deployment_id https://purl.imsglobal.org/spec/lti/claim/target_link_uri https://purl.imsglobal.org/spec/lti/claim/resource_link https://purl.imsglobal.org/spec/lti/claim/context https://purl.imsglobal.org/spec/lti/claim/roles https://purl.imsglobal.org/spec/lti/claim/custom https://purl.imsglobal.org/spec/lti/claim/launch_presentation https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings https://purl.imsglobal.org/spec/lti-ags/claim/endpoint https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice https://www.instructure.com/placement ``` Message types: `LtiResourceLinkRequest`, `LtiDeepLinkingRequest`. Version: `1.3.0`. ## Requested scopes ``` https://purl.imsglobal.org/spec/lti-ags/scope/lineitem https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly https://purl.imsglobal.org/spec/lti-ags/scope/score https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly ``` ## Configuration fields | Field | Values | | --- | --- | | `launchBehavior` | `EMBEDDED`, `NEW_TAB`, `OPEN_IN_HONEN` | | `landingExperience` | `HUB`, `DIRECT` | | `learnerCourseTarget` | `IN_FRAME`, `NEW_TAB` | | `platformKind` | `CANVAS`, `MOODLE`, `SKILLJAR`, `OTHER` | | `autoProvisionUsers` | boolean | | `autoEnrollStudents` | boolean | | `defaultPassbackPolicy` | `COMPLETION` (default), `PROGRESS_PERCENT`, `TEST_GRADE`, `PASS_FAIL` | `platformKind` is inferred from the issuer: containing `instructure` or `canvas` → `CANVAS`; `moodle` → `MOODLE`; else `OTHER`. `LTITargetType`: `DASHBOARD`, `GROUP`, `COURSE`, `TOPIC`, `ASSIGNMENT`. `LTISyncStatus`: `PENDING`, `SYNCED`, `FAILED`, `DISABLED`. ## Role mapping | LTI role suffix | Workspace role | Group role | | --- | --- | --- | | `Administrator` | `ADMIN` | `ADMIN` | | `Instructor` | `INSTRUCTOR` | `INSTRUCTOR` | | `TeachingAssistant` | `INSTRUCTOR` | `INSTRUCTOR` | | `ContentDeveloper` | `INSTRUCTOR` | `INSTRUCTOR` | | `Learner` | `MEMBER` | `MEMBER` | | `Member` | `MEMBER` | `MEMBER` | Only **context** roles count — a role URI is kept if its lowercased form contains `membership#`, or it is a bare short name with no `/` and no `:`. Institution- and system-level role URIs are dropped, so an institution `#Administrator` who is only a course Learner is not over-privileged. `LTIRoleMapping` rows carry a `SystemRoleKey` fallback plus optional custom `workspaceRoleId` / `groupRoleId`. Provisioning validates the custom role still belongs to the workspace and falls back to the system key if not, so deleting a custom role can never leave a launch without a safe target. ## Deep Linking An instructor picks Honen content from inside the LMS. Honen builds an `LtiDeepLinkingResponse` and records an `LTIResourceLink` with an `LTITargetType`. ## Grade passback (AGS) Runs as a sink on the learning-event bus and reacts **only** to `course.progress-recomputed`. For each `UserAssignment`: find the matching `LtiAssignmentLineItem`, mint a service token (cached in `LTIServiceTokenCache` to avoid a token request per score), project the score with `scoreForLmsPolicy`, and POST. Every attempt writes an `LtiGradeSyncLog` row with status, score, and the response body — because external gradebook APIs fail for many reasons and "why didn't this sync?" needs an answer. ## Rostering (NRPS) `POST /api/workspaces/lti/nrps-sync` pulls context memberships, paged, into an existing mapped group. The membership URL is **per-context**, stored on `LTIGroupMapping.nrpsContextMembershipUrl`, not globally — one deployment serves many courses. Models: `LTIConfiguration`, `LTIGroupMapping`, `LtiLaunchState`, `LtiSetupToken`, `LTIResourceLink`, `LtiAssignmentLineItem`, `LTIServiceTokenCache`, `LtiGradeSyncLog`, `LTIRoleMapping`. --- # 17. SCORM 1.2 and 2004 ## Package contents ``` your-course-scorm.zip ├── imsmanifest.xml # tells the LMS this is a SCORM SCO ├── index.html # the launcher (full-screen frame) ├── config.js # this package's settings + signed token └── scorm-runtime.js # bridges Honen progress ↔ the LMS gradebook ``` ## Endpoints | URL | Methods | Params | | --- | --- | --- | | `/api/scorm/launch` | `GET` | `t` (package token), `lid` (LMS learner id; falls back to `anon-<uuid>`), `ln` (learner name), `pv` (provisioning retry counter) | | `/api/scorm/progress` | `GET` | `pt` or header `X-Honen-Progress`; legacy fallback `t` + `lid` | | `/api/scorm/runtime.js` | `GET` | live runtime, cache-busted with `?v=<wrapperVersion>` | | `/api/courses/{courseId}/scorm` | `GET`, `POST` | export / download | | `/api/workspaces/scorm` | `GET`, `PATCH`, `POST`, `DELETE` | workspace config + package lifecycle | | `/api/workspaces/scorm/tenants` | `GET`, `POST`, `PATCH`, `DELETE` | tenants | Pages: `/scorm/open`, `/scorm/redeem`. Admin UI: `/dashboard/workspace/integrations/scorm`. Export: course **Manage** header → **SCORM**. ## Reporting policy | Setting | Values | | --- | --- | | `completionTrigger` | `COURSE_COMPLETION`, `PROGRESS_THRESHOLD`, `ON_LAUNCH` | | `progressThreshold` | 0–100 (with `PROGRESS_THRESHOLD`) | | `successSource` | `NONE`, `COURSE_COMPLETION`, `TEST_SCORE` | | `scoreSource` | `NONE`, `PROGRESS_PERCENT`, `TEST_GRADE`, `CERTIFICATE_GRADE`, `PASS_FAIL` | | `reportSessionTime` | boolean | | `reportSuspendData` | boolean (resume position) | | `commitSeconds` | wrapper commit interval | | `SCORMVersion` | `SCORM_12`, `SCORM_2004` | Internally this produces a version-agnostic report: `completion: "completed" | "incomplete"`, `success: "passed" | "failed" | "unknown"`, `scoreScaled`, `progressMeasure`, `location` (resume topic id). `"failed"` is only emitted once a completed attempt misses the bar, never prematurely. ## CMI field mapping Reads — SCORM 2004: `cmi.learner_id`, `cmi.learner_name`, `cmi.suspend_data`. SCORM 1.2: `cmi.core.student_id`, `cmi.core.student_name`, `cmi.core.suspend_data`. Writes — SCORM 2004: ``` cmi.completion_status cmi.success_status cmi.progress_measure cmi.score.raw / cmi.score.min (0) / cmi.score.max cmi.score.scaled cmi.location # only when reportSuspendData cmi.session_time ``` Writes — SCORM 1.2: ``` cmi.core.lesson_status cmi.core.score.raw / cmi.core.score.min (0) / cmi.core.score.max cmi.core.lesson_location # only when reportSuspendData cmi.core.session_time ``` On exit with nothing set, 2004 writes `cmi.completion_status = "incomplete"` when empty or `"unknown"`; 1.2 writes `cmi.core.lesson_status = "incomplete"` when empty or `"not attempted"`. **A completed or passed result is never downgraded**, and numeric writes compare against the current value first, so a gradebook cannot be walked backwards by a later partial session. ## How tracking works SCORM has **no server-to-server passback.** The wrapper polls `/api/scorm/progress` with a per-learner signed token minted at launch, then writes the result into the LMS's own SCORM API and flushes on exit. The token scopes reads to that learner and that course — no name, no email, and it cannot be used to read another learner's progress by guessing their LMS id. ## Tokens All package/launch tokens go through one sealed envelope: `sealData` with the app secret, plus a `kind` stamped on create and required to match on read. Reading returns `null` for **every** failure — expired, forged, truncated, wrong kind, missing field — so a caller cannot accidentally treat a bad token as good and an attacker learns nothing from which way it failed. `kind` is what stops a progress token being redeemed as a launch token. | Kind | TTL | Required fields | Purpose | | --- | --- | --- | --- | | `scorm-package` | ~5 years | `packageId`, `secret` | baked into the zip | | `scorm-open` | 30 min | `userId`, `workspaceId`, `courseId` | one-time handoff | | `scorm-progress` | 7 days | `packageId`, `lmsLearnerId`, `secret` | per-learner progress reads | ## Revocation Rotating a package secret, disabling the package, or hitting the workspace kill switch invalidates every previously downloaded copy **and** revokes in-progress learner sessions — a learner already in the course is signed out, not merely blocked from relaunching. Re-download after a rotate. **Known window:** `/scorm/redeem` and `/cmi5/redeem` accept a 30-minute open ticket without re-checking package `isEnabled`/secret, so revocation has a 30-minute tail on that one path. Reporting is cut immediately regardless, because every progress call re-resolves the package. ## Limitations Org workspaces only. Learners without an LMS id get a stable generated per-seat id stored in the LMS's own `suspend_data` — tracked separately, but shown as generated ids rather than names. External-targeted packages require a published Site. Models: `SCORMConfiguration`, `SCORMPackage`, `SCORMTenant`, `SCORMLearnerIdentity`, `SCORMLaunchLog`. --- # 18. xAPI (producer + LRS) Three separate capabilities, often confused: Honen **emits** statements, Honen **is** an LRS, and Honen **exports cmi5**. ## Honen as an LRS Seven resources. The spec spelling `/xAPI/` is served through a rewrite; both `/xAPI/…` and `/xapi/…` resolve. ``` /xAPI/statements GET | POST | PUT /xAPI/activities/state GET | PUT | POST | DELETE | OPTIONS /xAPI/activities GET /xAPI/activities/profile GET | PUT | POST | DELETE | OPTIONS /xAPI/agents GET /xAPI/agents/profile GET | PUT | POST | DELETE | OPTIONS /xAPI/about GET ``` Admin: `/api/workspaces/xapi` (`GET`, `PATCH`), `/api/workspaces/xapi/clients` (`POST`, `PATCH`, `DELETE`), `/api/workspaces/xapi/statements` (`GET`, statement explorer). Admin UI: `/dashboard/workspace/integrations/xapi`. ## Auth **HTTP Basic only**, against workspace-scoped `XAPIClient` rows. Never cookies. ``` Key format: hxk_<24 hex> Header: Authorization: Basic base64(key:secret) On failure: WWW-Authenticate: Basic realm="Honen xAPI" ``` Required headers: `X-Experience-API-Version` on requests **and** responses. Also emitted: `X-Experience-API-Consistent-Through`, `X-Experience-API-Hash`. Target version is 1.0.3; 2.0 requests are accepted. The secret is shown once and stored only as a hash. ## Scopes | Wire spelling | Enum | Grants | | --- | --- | --- | | `statements/write` | `STATEMENTS_WRITE` | Write statements | | `statements/read` | `STATEMENTS_READ` | Read own statements | | `state` | `STATE` | State documents — **read and write** (spec §4.2) | | `profile` | `PROFILE` | Profile documents — **read and write** | | `all/read` | `ALL_READ` | Unrestricted read; the basis of the "Read only" preset | The spec defines `state` and `profile` as read/write, so there is deliberately no `state/read`. **A genuinely read-only credential is `statements/read` + `all/read`.** Mutating methods require a write-bearing scope. The only widening rule is `all/read` satisfying a read; it never satisfies a write. Presets: read = `STATEMENTS_READ` + `ALL_READ`; write = `STATEMENTS_WRITE`, `STATEMENTS_READ`, `STATE`, `PROFILE`. **Credentials are a hard tenancy boundary.** A statement or document written with one workspace's key can never be read, listed, overwritten, or voided with another's. ## Statement producer Honen emits statements from its own learning events, stores them locally first, and optionally forwards them to your LRS with retry and backoff. Failures land in `XAPIForwardLog` and never block completion. Multiple forwarding destinations are supported, each attempted independently with its own endpoint and HTTP Basic credentials. "Test" uses the one xAPI request that stores nothing. Statement ids for completion events are **derived** — a v5 UUID from the `(workspace, user, object)` transition — so the `@@unique([workspaceId, statementId])` constraint makes emission idempotent in the database rather than in application logic that can be raced. Statements are always built server-side from the database, never in the browser. ## Disable vs revoke Disabling an `XAPIClient` stops a cmi5 package reporting immediately — launches still work, but the assignable unit gets an error instead of a key. Deleting one causes the package to mint a fresh credential on next launch, which is what keeps a package already sitting in a customer's LMS working. Neither deletes statements already written. ## Known conformance gaps 1353 of 1365 ADL tests pass. The 12 failures: | Area | Cases | Meaning | | --- | --- | --- | | Signed statements | 4 | JWS signature is not validated; a signed statement is stored but carries no more authority than an unsigned one | | Multipart `Content-Transfer-Encoding` | 1 | one attachment-part encoding variant unhandled | | `format=ids` | 1 | reduces the object less aggressively than the spec requires | | Attachment parameter | 1 | one `attachments=true` case non-conforming | | Voided statements with `since`/`until` | 3 | time-filtered queries treat voided statements differently | | Profile PUT without ETag | 2 | Agent/Activity Profile don't return the required concurrency error | None affect the paths cmi5 needs (State, statement write, Agent Profile read at launch). Models: `XAPIConfiguration`, `XAPIClient`, `XAPIStatement`, `XAPIDocument`, `XAPIForwardLog`. --- # 19. cmi5 The SCORM-successor package format. Available by default in an organization workspace on the same contract as SCORM: the first export materializes the xAPI config with `isEnabled: true` and provisions the launch tenant, so **exporting is the switch**. Only an explicit off blocks it — a cmi5 unit has no reporting channel other than an LRS, so a package exported into a workspace with xAPI switched off would launch into silence. ## Package contents ``` your-course-cmi5.zip ├── cmi5.xml # the course structure the LMS reads ├── index.html # the assignable unit (full-screen frame) ├── config.js # this package's settings + signed token └── cmi5-runtime.js # talks to your LMS's LRS ``` `cmi5.xml`, single AU, per cmi5 §13: ```xml <?xml version="1.0" encoding="UTF-8"?> <courseStructure xmlns="https://w3id.org/xapi/profiles/cmi5/v1/CourseStructure.xsd"> <course id="${courseIri}"> <title><langstring lang="en-US">${title}</langstring> ${description} <langstring lang="en-US">${title}</langstring> ${description} index.html ${packageId} ``` `` position is fixed by the schema (after ``) and is echoed back under `courseStructure` in `LMS.LaunchData` (§10.2.7). `masteryScore` is clamped to 0–1 at four decimals. ## Endpoints | URL | Methods | Notes | | --- | --- | --- | | `/api/cmi5/launch` | `GET` | `t` (package token, required), `actor` (JSON Agent), `registration` (UUID), `pv` | | `/api/cmi5/fetch` | `POST`, `GET` | single-use credential fetch | | `/api/cmi5/launch-data` | `POST` | the AU writes back mode / moveOn / masteryScore | | `/api/cmi5/statements` | `GET`, `POST` | statements the AU relays | | `/api/cmi5/runtime.js` | `GET` | live runtime, `?v=` | | `/api/courses/{courseId}/cmi5` | `GET`, `POST` | export / download | Pages: `/cmi5/open`, `/cmi5/redeem`. ## Launch The LMS launches the AU with **`endpoint`, `fetch`, `actor`, `registration`, `activityId`**. The unit redeems the single-use `fetch` URL (an atomic compare-and-set, so two concurrent requests cannot both claim it — a replay gets 400, not a second credential), reads `LMS.LaunchData` from the State API, and writes the required sequence: ``` initialized → (learning) → completed / passed / failed → terminated ``` Each statement carries a `duration`. Honen's handling: `actor` is parsed as JSON and **its IFI is the stable key**, not the display name; a malformed or missing actor gets `anon-` rather than collapsing several real learners onto one account. `registration` must be a valid UUID or Honen mints one. `LMS.LaunchData` is written into State **before** the AU can ask for it; a failed write degrades the AU to defaults but must not block the learner. ## Two rules that are easy to get wrong - **The actor on statements relayed to the LMS's LRS is the LMS-supplied `actor`**, not Honen's account identity (cmi5 §9.2). Honen's own copy keeps the Honen actor. Getting this wrong means completion never attaches to the learner in the customer's LRS — and it is invisible to any test that reads back only Honen's copy. - **`launchMode: Browse` and `Review` must not send completion or success statements** (§9.3.6). The mode comes from `LMS.LaunchData`. ## moveOn | Enum | Wire | The LMS waits for | | --- | --- | --- | | `COMPLETED` | `Completed` | a completion | | `PASSED` | `Passed` | a pass | | `COMPLETED_AND_PASSED` | `CompletedAndPassed` | both | | `COMPLETED_OR_PASSED` | `CompletedOrPassed` | either (**default**) | | `NOT_APPLICABLE` | `NotApplicable` | nothing — informational unit | Where the LMS supplies a `masteryScore`, it decides `passed` vs `failed`. ## Tokens | Kind | TTL | Purpose | | --- | --- | --- | | `cmi5-package` | ~5 years | baked into the zip | | `cmi5-fetch` | 15 min | single-use LRS credential fetch (§8.2) | | `cmi5-open` | 30 min | one-time handoff | | `cmi5-session` | 24 hours | per-session relay token for the AU's cross-origin poll | The activity IRI is fixed at export and stored with the package. Changing the workspace IRI prefix affects only *future* exports — an LMS matches statements to the unit by exact IRI. Models: `Cmi5Package`, `Cmi5Session`. --- # 20. Google Classroom add-on A Google Workspace Marketplace **Classroom add-on**, not an LTI deployment. Credentials are scoped to the authorizing user and workspace, and mappings preserve the external coordinates needed to resolve untrusted iframe launch parameters. ## Endpoints ``` GET /api/google-classroom/auth/start GET /api/google-classroom/auth/callback POST /api/google-classroom/auth/redeem GET /api/google-classroom/launch/teacher GET /api/google-classroom/launch/student GET /api/google-classroom/launch/review GET|POST /api/google-classroom/attachments/create ``` ## Marketplace-registered URIs ``` Authorized redirect URI: /api/google-classroom/auth/callback Attachment setup URI: /classroom/discovery Allowed attachment URI prefixes: /classroom/teacher-view /classroom/student-view /classroom/student-work-review ``` `` is `GOOGLE_CLASSROOM_EMBED_BASE_URL` when set, otherwise `BASE_URL`. It must be HTTPS, stable, with no path, no wildcard, and not a customer-specific origin. ## OAuth scopes ``` openid email profile https://www.googleapis.com/auth/classroom.addons.student https://www.googleapis.com/auth/classroom.addons.teacher ``` Student launches request only the student scope; teacher and discovery launches only the teacher scope. ## Environment ```dotenv BASE_URL=https://app.example.test GOOGLE_CLASSROOM_EMBED_BASE_URL=https://classroom-addon.example.test GOOGLE_CLASSROOM_CLIENT_ID= GOOGLE_CLASSROOM_CLIENT_SECRET= GOOGLE_CLASSROOM_CREDENTIAL_ENCRYPTION_KEY= ``` Use a **dedicated** OAuth client — do not reuse Honen's Google SSO, Drive, or MCP credentials. Ciphertext is AES-256-GCM bound to the encryption key, so rotating it currently forces every user to reconnect. ## Attachments and grades Attachments hang off `COURSE_WORK`, `COURSE_WORK_MATERIAL`, or `ANNOUNCEMENT`. Graded activities only where Classroom supplies student-work support. Grade passback runs as a sink on the learning-event bus, triggered only by `course.progress-recomputed`. It PATCHes the add-on submission's **draft points only** — the teacher publishes. The score is scaled to the Classroom assignment's point value; `maxPoints` must be finite and positive or the sync is recorded as failed with `"Classroom maxPoints must be positive"`. A Classroom 401 refreshes once. Permanent and retryable failures are audited and **never block Honen completion**. Classroom `copyHistory` resolves the source while Honen creates isolated course/attachment/assignment/submission coordinates for the copy. ## Security expectations `/classroom/*` and `/api/google-classroom/*` return `Cache-Control: no-store`, `Referrer-Policy: no-referrer`, and a CSP limited to Classroom framing. The redeemed session cookie is `Secure; SameSite=None; Partitioned; HttpOnly`. The OAuth callback HTML posts only a short-lived one-time ticket to the exact embed origin. `login_hint` binding rejects an account mismatch. The one-time ticket and the discovery return URL live in same-origin `sessionStorage`, **never** as URL parameters. --- # 21. SSO and identity Two implemented paths. **SAML 2.0 is not implemented** — enum values (`SAML_GENERIC`) and a dashboard tile exist, but there is no ACS route, no metadata route, and no SAML library. Use OIDC. ## OIDC SSO | URL | Methods | Params | | --- | --- | --- | | `/api/auth/sso/oidc/start` | `GET` | `connection` (config id), `course_id`, `session_scope=workspace\|account` | | `/api/auth/sso/oidc/callback` | `GET` | `state`, `code`, `error` | | `/api/workspaces/oidc` | `GET`, `POST`, `PATCH`, `DELETE` | connection CRUD | Register Honen at your provider as a **confidential web app** using the authorization-code flow with scopes `openid`, `profile`, `email`, and the redirect URI ending `/api/auth/sso/oidc/callback` (the exact value is shown in **Workspace → Integrations → Identity & SSO**). Then paste the issuer URL, client ID, and client secret into Honen and run **Test sign-in**. Endpoints are discovered from `/.well-known/openid-configuration`, and the discovered issuer must match **exactly**. ``` Google Workspace: https://accounts.google.com Microsoft Entra ID: https://login.microsoftonline.com/{tenant-id}/v2.0 Okta: https://{your-domain}.okta.com Auth0: https://{your-tenant}.{region}.auth0.com Keycloak: https://{host}/realms/{realm} ``` Use a tenant-specific Entra issuer rather than `common`, so a connection is contained to one customer tenant. Security behavior: authorization code with **PKCE S256**; a one-time hashed `state` with a **ten-minute** expiry; nonce validation on the ID token; exact issuer and client-ID audience validation; **RS256 or ES256** signatures verified against the provider JWKS; a verified email required for provisioning; discovery and provider endpoints must be HTTPS and publicly resolvable — private, loopback, link-local, and cloud-metadata targets are rejected. Client secrets are sealed at rest and never returned by any API. Course destinations are generated by Honen and cannot be supplied as arbitrary redirect URLs. `session_scope` is stored in the one-time state record and **not trusted from the callback**. Presets: `GENERIC`, `GOOGLE_WORKSPACE`, `MICROSOFT_ENTRA`, `OKTA`, `AUTH0`, `KEYCLOAK`. ## Signed JWT SSO You sign a short-lived assertion; Honen validates it against a public JWK you registered. ```html
``` `POST /api/auth/sso/signed/redeem`, `application/x-www-form-urlencoded`, one field: `assertion`. **The assertion is accepted only in the POST body**, never in a query string. HTML-escape it when generating markup. Responses: `303` redirect to the validated course or workspace home; `400` malformed; `403` signature, claim, connection, key, or course validation failure; `409` replay detected. Config CRUD: `/api/workspaces/signed-sso` (`GET`, `POST`, `PATCH`, `DELETE`). ### Claims | Claim | Rule | | --- | --- | | `iss` | Exactly the issuer registered in the workspace integration | | `aud` | The Honen-generated audience shown in integration settings | | `sub` | Stable client user ID. **Do not use a mutable email address.** | | `email` | Current user email | | `email_verified` | Must be the boolean `true` | | `name` | Display name | | `iat` | Integer issued-at | | `nbf` | Integer not-before | | `exp` | Integer expiration. **`exp - iat` must not exceed 60 seconds.** | | `jti` | Unique random identifier for this one launch; consumed, replay → 409 | | `course_id` | Optional Honen course ID; opened only when available to the configured workspace | | `session_scope` | Optional `workspace` (default) or `account`; invalid values fall back to `workspace` | Algorithms: only the configured `RS256` and/or `ES256`. Every JWT must carry a registered `kid`. ```ts import { randomUUID } from "node:crypto"; import { SignJWT, importPKCS8 } from "jose"; const now = Math.floor(Date.now() / 1000); const privateKey = await importPKCS8( process.env.HONEN_SSO_PRIVATE_KEY!, "RS256", ); const assertion = await new SignJWT({ email: user.email, email_verified: true, name: user.name, course_id: requestedCourseId, session_scope: "workspace", }) .setProtectedHeader({ alg: "RS256", kid: "client-production-2026-07", typ: "JWT", }) .setIssuer(process.env.HONEN_SSO_ISSUER!) .setAudience(process.env.HONEN_SSO_AUDIENCE!) .setSubject(user.id) .setIssuedAt(now) .setNotBefore(now) .setExpirationTime(now + 60) .setJti(randomUUID()) .sign(privateKey); ``` Register the public half: ```json { "keys": [ { "kty": "RSA", "kid": "client-production-2026-07", "use": "sig", "alg": "RS256", "n": "...", "e": "AQAB" } ] } ``` Honen stores **only** the public JWK. Never send the private key to Honen or to browser code. Zero-downtime rotation: generate a new pair, add the new public JWK **alongside** the old one, deploy the signer with the new `kid`, then remove the old JWK after the maximum assertion lifetime plus a safety margin. "Revoke all keys" disables the connection and removes every public key; existing Honen sessions stay valid and must be revoked separately. ### Signed SSO is not an analytics token If your app needs learner progress, issue a Honen Connector API key and call the learning analytics commands in §5. Do not try to derive progress from SSO. ## Shared identity behavior Returning users resolve by **`issuer` + `subject` + SSO configuration, never by email.** A new email creates one real Honen account with that globally unique email, links the external identity atomically, adds only the configured workspace membership, and begins in a bootstrap state with no password. An existing email **never** links from an assertion alone. Honen issues a hashed ten-minute handoff, requires a fresh normal sign-in as that exact account, asks for explicit confirmation, consumes the handoff once, and sends a security email. Supporting routes: `/api/auth/sso/handoff`, `/api/auth/sso/link`, `/api/auth/sso/link/step-up`, `/api/auth/sso/claim`, `/api/user/sso-identities`; pages `/sso/link`, `/sso/claim`, `/sso/error`. ### Session scope `WORKSPACE` (default) or `ACCOUNT`. Account-wide requires **all three** gates: the request asks for `account`, a workspace administrator has enabled `allowAccountWideSessions`, and the owner of that exact identity approves. Otherwise Honen silently issues a workspace-scoped session. A workspace-scoped SSO session cannot switch workspaces, cross custom-domain boundaries, join or create workspaces, or use global account management. Normal password sessions are always unscoped. ### Configuration fields `providerType`, `name`, `isEnabled` (default false), `claimDomains`, `enforceDomainClaim` (default true — blocks normal signup/login for claimed domains), `autoProvision` (default true), `allowAccountWideSessions` (default false), `defaultSystemRole` (default `MEMBER`), plus per-protocol fields (`oidcIssuer`, `oidcClientId`, `oidcClientSecretSealed`, `oidcScopes`, `signedIssuer`, `signedAudience`, `signedPublicJwks`, `signedAlgorithms`). **Disable vs delete.** Deleting a connection prevents future logins and causes its SSO-context sessions to fail their next validation. Disabling blocks new logins while existing sessions remain valid — revoke those separately. --- # 22. The learning-event bus and score projection ## Events One protocol-neutral event union. Domain code emits a plain fact and knows nothing about who consumes it. | `type` | Payload beyond `{ userId, workspaceId }` | | --- | --- | | `activity.completed` | `activityId`, `activityType`, `activityName?`, `topicId?`, `topicName?`, `unitId?`, `courseId?`, `courseName?`, `timeSpentSeconds?`, `scoreRaw?`, `scoreMax?`, `success?` | | `topic.completed` | `topicId`, `topicName?`, `unitId?`, `courseId?`, `courseName?` | | `test.completed` | `testId`, `testName?`, `courseId?`, `courseName?`, `scoreRaw`, `scoreMax`, `passed?` (null when there is no mastery bar), `timeSpentSeconds?` | | `project.graded` | `projectId`, `projectName?`, `courseId?`, `courseName?`, `scoreRaw`, `scoreMax` | | `course.progress-recomputed` | `courseId` | Sinks, in order: LTI grade passback → Google Classroom grade passback → the xAPI recorder. Both gradebook sinks return early unless the event is `course.progress-recomputed`. Emission is fire-and-forget and never throws; sinks are isolated with `Promise.allSettled`, so one failing integration cannot block another or the request that triggered it. Adding an outbound sink later is one function and one array entry. This is **not** the product-analytics path (Mixpanel). It is the LMS delivery path. ## Score projection Every path shares one projection from Honen completion state onto four passback policies. | Policy | `scoreGiven` | `scoreMaximum` | | --- | --- | --- | | `COMPLETION` (default) | `completed ? scoreMaximum : 0` | `scoreMaximum` | | `PASS_FAIL` | `completed ? 1 : 0` | `1` | | `PROGRESS_PERCENT` | `completed ? 100 : started ? 50 : 0` | `100` | | `TEST_GRADE` | `completed ? (certificateGrade ?? 0) : 0` | `100` | Derivation: ``` completed = assignmentCompletedAt || (!assignmentIsScoped && courseCompletedAt) activityProgress = completed ? "Completed" : started ? "InProgress" : "Initialized" gradingProgress = completed ? "FullyGraded" : "Pending" ``` `activityProgress` / `gradingProgress` are exactly the pair LTI AGS requires. `assignmentIsScoped` exists because a partial-course assignment must be graded on its **own** completion — passing the whole-course bar through would post a full grade for a section the learner never did. **Two stated limitations:** `PROGRESS_PERCENT` is coarse (0 / 50 / 100) until Honen exposes a canonical continuous course percentage. Certificates are curriculum-level, not per-course, so `TEST_GRADE` resolves to 0 for a single course. --- # 23. Logging, audit, and observability Every programmatic call is recorded. Two separate streams, deliberately: **Integration audit log** (`/api/connectors/audit`) — low-volume administrative facts: Connector created, updated, rotated, revoked; token issued; permission granted. Each row carries `source` (`WEB`, `MCP`, `DEVELOPER_API`, …), `action`, `targetType`, `targetId`, the acting user, the Connector, metadata, IP address, and timestamp. **Integration request log** (`/api/connectors/logs`) — per-request records for the HTTP API, MCP, and CLI: `route`, `transport` (`http-api`, `mcp`, `mcp-oauth`), `toolName`, `toolAction`, `status`, `ok`, `tags` (including `read` / `write`), a request summary, a response summary (content types, text bytes, binary bytes counted separately so an image response does not log as ~50 bytes), and an error message. Authentication failures on the per-Connector MCP endpoint are logged too, so a misconfigured client is visible rather than silent. `GET /api/connectors/logs/{id}` returns one row in full. High-volume LMS learner launches go to `SCORMLaunchLog` rather than the admin audit, so they do not bury administrative events. LTI grade passback attempts get their own per-attempt `LtiGradeSyncLog` row. Everything is workspace-scoped. Revoking a Connector or key stops access immediately; the history stays. --- # 24. Limits, TTLs, and rate limits | Thing | Value | | --- | --- | | HTTP API request timeout | 60 s | | MCP request timeout | 300 s | | KB image upload | 10 MB | | KB image returned inline over MCP | 3 MB (SVG refused — no provider accepts it as image input) | | Signed image URLs | 10 minutes | | Images returned by `read` with `includeImageUrls` | capped at 25, with `imagesTruncated` | | `/data` sandbox snapshot | ~2 MB | | Agent embed ticket | 2 minutes, single use | | Agent embed session | 24 hours | | Agent embed anonymous sessions | 60 per deployment per 60 s | | Agent embed messages | 20 per identity and 200 per deployment, per 60 s | | Agent embed history window | last 30 messages | | Agent embed message length | 1–20 000 characters | | `issue_dev_token` TTL | 60–3600 s, default 900 | | OAuth authorization code | 5 minutes | | OAuth access token | 90 days | | OAuth refresh token | 1 year, not extended by refresh | | Personal MCP token | 1 year | | Signed-JWT SSO assertion | `exp - iat` ≤ 60 s | | OIDC state record | 10 minutes | | SCORM/cmi5 package token | ~5 years | | SCORM/cmi5 open ticket | 30 minutes | | SCORM progress token | 7 days | | cmi5 fetch token | 15 minutes, single use | | cmi5 session token | 24 hours | | Site upload URL | 15 minutes | | Knowledge Base source re-check | roughly every minute | | Audit / log page size | default 50, max 200 | Workspace Assistant turns on the free Team plan are metered monthly; exceeding the quota returns an explanatory error rather than a generic 429. --- # 25. What Honen does not have State these plainly rather than discovering them at integration time. - **No outbound webhooks.** There is no workspace-facing event subscription. To learn that something happened, poll `/api/v1/*` or `/api/connectors/logs`. (Two internal webhook systems exist for StudyFetch platform administration; they are not customer-facing and cannot be subscribed to.) - **No token revocation for OAuth or personal MCP tokens.** They are stateless seals. Use a Connector when you need revocability. - **No GraphQL.** One HTTP verb, one path shape, one envelope. - **No SCORM, xAPI, or cmi5 import.** Honen produces packages and statements; it does not play third-party ones. There is no importer or parser. - **No Common Cartridge or AICC.** - **No SAML 2.0 flow.** Enum values and a dashboard tile exist; the endpoints do not. - **No LTI 1.1 flow.** The schema value exists; launch requires `1.3.0`. - **No write-back of learning state.** No integration can set progress in Honen. - **No push from Honen to a synced content source.** Knowledge Base sync is one-way, inbound. - **No xAPI signed-statement validation.** Signed statements are stored but carry no extra authority. - **No OAuth 1.0a on the LRS.** HTTP Basic only. - **Package export is organization-workspace only.** Personal workspaces lack the groups and analytics that provisioning targets. - **No per-course certificate grade.** Certificates are curriculum-level, so grade-derived passback policies resolve to 0 for a single course. --- # 26. Where these facts come from This document is built, not maintained by hand. Prose lives in `apps/web/content/developers/*.md`; the command reference, permission tables, OAuth scopes, token lifetimes, and LMS status table are read out of the code at build time by `apps/web/scripts/build-developers-doc`. Adding a command, a scope, or a policy capability updates this page on the next deploy — and adding one without describing it fails the build. Canonical sources, if you have repository access: | Area | Source | | --- | --- | | HTTP API command registry | `apps/web/lib/commands/` (`registry.ts`, `openapi.ts`, `domains/*.ts`) | | Live spec | `GET /api/v1/openapi.json` | | Connector policy | `apps/web/lib/connectors/policy.ts` | | Connector CRUD and auth | `apps/web/lib/connectors/index.ts` | | MCP servers | `apps/web/app/api/mcp/[[...transport]]/route.ts`, `apps/web/app/api/mcp/c/[connectorId]/[[...transport]]/route.ts` | | MCP OAuth | `apps/web/lib/mcp-oauth/` | | In-protocol tool docs | `apps/web/lib/mcp-docs/honen-docs.ts` | | CLI | `packages/honen-cli/src/` | | Agent embeds | `apps/web/lib/agents/embed.ts`, `apps/web/app/api/agents/embed/` | | LMS cross-path reference | `docs/lms-integrations.md` | | SCORM | `docs/scorm.md`, `apps/web/lib/scorm/` | | xAPI and cmi5 | `docs/xapi.md`, `apps/web/lib/xapi/`, `apps/web/lib/cmi5/` | | Google Classroom | `docs/google-classroom-addon.md` | | SSO | `docs/oidc-sso.md`, `docs/signed-jwt-sso.md` | | Learning event bus | `apps/web/lib/events/learning.ts` | | Score projection | `apps/web/lib/lms/score-policy.ts` | | Feature status (authoritative) | `apps/web/lib/agent-skills/platform-features.ts` | | Plain-English integrations overview | `docs/integrations-explained.md` | | This document's own generator | `apps/web/scripts/build-developers-doc/`, `apps/web/content/developers/` | Product documentation (what the platform does, rather than how to call it) lives at and , with per-topic markdown under . --- ## Contact - Support: support@honen.com - MCP endpoint: - OpenAPI: - Status: - Trust Center: - Book a walkthrough: