@superbuilders/primer-tives
TypeScript SDK primitives for the Primer adaptive learning runtime.
The public lifecycle starts with one async call and, when hosted auth is needed, one user-gesture auth transition:
start(options with accessToken) -> Promise<AccessTokenStartState>
start(options without accessToken) -> Promise<ManagedStartState>
SignInRequiredState.login() -> Promise<ManagedStartState>
SignInFailedState.login() -> Promise<ManagedStartState>
start enters the first Primer learning state when learner auth is ready and returns the live state machine object your renderer drives. For an authenticated learner that first state is typically FrontierState: a set of equally valid next-lesson routes the frontend chooses between. When learner sign-in is needed, managed-auth start returns SignInRequiredState; render a sign-in button and call state.login() directly from that button's click or tap handler.
bun add @superbuilders/primer-tives
Version
The current SDK version is 9.1.0. Version 9.0.0 introduced a breaking wire revision. Every graded
response now REQUIRES total next: frontier | completed | pending. When the
server-side write already served the next state, advance() from the feedback
state resolves it LOCALLY with zero network round trips (route consumption and
frame-open beacon semantics are identical to a transported frontier). When
next.outcome === "pending", the server has derived that future work is still in
flight; advance() executes exactly one real continue to resolve it. Hosted
Primer rejects mismatched SDK majors with sdk_upgrade_required; first-party
renderer deployments must ship the server and SDK from the same monorepo build.
Version 9.1.0 restores supportedPcis: readonly PciId[] on PrimerOptions and removes the mistaken pciRenderers function-map option from 9.0.0. The SDK never invoked those functions; see Migrating from 9.0.0 pciRenderers.
Entrypoints
There is no package-root export. Import from the public subpath that owns the surface you need.
| Subpath | Owns |
|---|---|
@superbuilders/primer-tives/client/start | start, PrimerOptions, auth-specific start option types |
@superbuilders/primer-tives/client/types | PrimerState, all state interfaces, optional host-renderer PCI prop types (PciRenderProps, not start inputs) |
@superbuilders/primer-tives/client/matchers | matchPrimerState, matchInteraction, matchFeedback, matchErrorState |
@superbuilders/primer-tives/contracts/content | Content blocks and plain-text helpers |
@superbuilders/primer-tives/contracts/types | Renderer interaction, stimulus, and submission types |
@superbuilders/primer-tives/contracts/validation | Submission schemas and validation helpers |
@superbuilders/primer-tives/contracts/pci | PCI ids, props, values, registry |
@superbuilders/primer-tives/contracts/review | Interaction review types |
@superbuilders/primer-tives/contracts/lesson-stage | LessonStage, LESSON_STAGES, isLessonStage |
@superbuilders/primer-tives/contracts/advance-wire | Advance wire request/response types |
@superbuilders/primer-tives/errors | Every SDK error sentinel |
@superbuilders/primer-tives/logger | PrimerLogger interface |
@superbuilders/primer-tives/subject | Subject, SUBJECTS |
@superbuilders/primer-tives/subject-pcis | Subject-required PCI helpers and type helpers |
Architecture: Three Layers
PrimerTives is a framework-agnostic TypeScript SDK. It owns the learner runtime state machine and wire protocol. It does not render UI, bundle React components, or invoke host renderer code.
Integrators should keep three layers separate:
┌──────────────────────────────────────────────────────────────────────┐
│ Layer 1 — primer-tives (framework-agnostic) │
│ start() → PrimerState → enter / advance / submit / timeout / login │
│ declares supportedPcis: PciId[] for wire PCI capability negotiation │
│ never calls host renderer functions │
└──────────────────────────────────────────────────────────────────────┘
│
▼ every advance request body includes:
{ subject, supportedPcis, intent }
┌──────────────────────────────────────────────────────────────────────┐
│ Layer 2 — Primer server │
│ filters frontier routes whose frames need PCIs not in supportedPcis │
│ grades submissions, advances curriculum, returns next state │
└──────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ Layer 3 — your host renderer (React, Vue, Svelte, vanilla DOM, …) │
│ switch on state.phase and state.kind │
│ for portable-custom: read state.pciId + state.properties, render UI │
│ call state.submit(value) with typed PciValue<K> │
│ optional: import PciRenderProps<K> to type your component props │
└──────────────────────────────────────────────────────────────────────┘
Layer boundaries
| Concern | Owner | SDK surface |
|---|---|---|
| Auth and session token cache | primer-tives | start, SignInRequiredState.login(), … |
| Frontier routing and frame transitions | primer-tives + server | FrontierState.enter, advance, submit, … |
| PCI capability negotiation on the wire | primer-tives + server | supportedPcis on PrimerOptions |
| PCI widget UI (fraction inputs, etc.) | host renderer | PciInteractionState, optional PciRenderProps |
| Reference React implementation | @superbuilders/primer-renderer (separate package) | not required for SDK integration |
Invariants integrators should memorize
start()never invokes renderer functions. There is no renderer registry on the state machine.supportedPcisis a readonly array of PCI id strings sent on the wire.- PCI rendering is orthogonal to the state machine. When
state.kind === "portable-custom", the SDK gives youpciId,properties, andsubmit(value). How you paint that on screen is entirely host code. PciRenderPropsis an optional host-renderer contract, exported for component authors. Import it when typing your fraction-input component. Do not pass it tostart().- The SDK has no React dependency.
package.jsondepends onerrors,validate, andpinoonly. Examples in this README may show React for familiarity; the samestart()options work in any runtime.
What the SDK does not do
PrimerTives intentionally does not:
- render Portable Custom Interactions or standard interactions
- require React, Vue, Svelte, or any UI framework
- ship UI components in the npm package
- call functions you pass in
startoptions (you do not pass functions) - negotiate PCI support implicitly — you declare
supportedPcisexplicitly - select frontier routes server-side — the frontend chooses from
FrontierState.routes
If documentation or examples suggest passing React components to start(), that is incorrect. Version 9.0.0 briefly exposed a pciRenderers option that looked like a component registry; 9.1.0 removes it and restores supportedPcis: readonly PciId[]. See Migrating from 9.0.0 pciRenderers.
Quick Start
Math content can require the fraction-input PCI capability, so a math integration must declare it in supportedPcis. Declaration is wire negotiation only — you still render the PCI in your own UI layer when the state machine reaches PciInteractionState.
import { logger } from "@/logger"
import {
start,
type PrimerOptionsWithAccessToken,
type PrimerOptionsWithManagedAuth
} from "@superbuilders/primer-tives/client/start"
const options = {
publishableKey: "pk_...",
subject: "math",
supportedPcis: ["urn:primer:pci:fraction-input"],
logger
} satisfies PrimerOptionsWithManagedAuth<"math", readonly ["urn:primer:pci:fraction-input"]>
let state = await start(options)
Primer exposes one unified learning surface. There is no product-mode option: every learner moves through the same frontier-driven curriculum state machine, and the server owns all progression policy.
const options = {
publishableKey: "pk_...",
subject: "science",
logger
} satisfies PrimerOptionsWithManagedAuth<"science">
let state = await start(options)
If your application already has a learner access token, pass it directly. start uses that token for the learning runtime.
const options = {
publishableKey: "pk_...",
accessToken,
subject: "math",
supportedPcis: ["urn:primer:pci:fraction-input"],
logger
} satisfies PrimerOptionsWithAccessToken<"math", readonly ["urn:primer:pci:fraction-input"]>
let state = await start(options)
When managed-auth start returns SignInRequiredState, render sign-in UI and call login() directly from a user gesture:
import type { ManagedStartState, SignInRequiredState } from "@superbuilders/primer-tives/client/types"
let state: ManagedStartState = await start(options)
if (state.phase === "sign-in-required") {
renderSignInButton(state)
}
function handleSignInClick(authState: SignInRequiredState): void {
void authState.login().then(function continueAfterLogin(nextState) {
state = nextState
renderPrimer(state)
})
}
For Chrome and other popup blockers, login() must be called directly from the click or tap handler. Do not put await, setTimeout, dynamic imports, analytics calls, or other async work before state.login().
Science currently has no required PCI capabilities, so supportedPcis can be omitted for that subject.
const options = {
publishableKey: "pk_...",
subject: "science",
logger
} satisfies PrimerOptionsWithManagedAuth<"science">
let state = await start(options)
The Frontier Model
Primer routing is frontier-first. For an authenticated, configured learner, start typically resolves to FrontierState: the set of equally valid next-lesson routes at the learner's current position in the curriculum DAG.
interface FrontierState<Pcis extends PciId = PciId> {
readonly phase: "frontier"
readonly routes: readonly [FrontierRoute, ...FrontierRoute[]]
enter(route: FrontierRoute): ObservationState<Pcis> | InteractionState<Pcis>
}
interface FrontierRoute {
readonly lesson: LessonMetadata
}
interface LessonMetadata {
readonly id: string
readonly title: string
readonly stage: LessonStage
}
type LessonStage = "teaching" | "testing" | "transfer"
The frontend chooses the route. That is the point of the frontier: every route is an equally valid path through the curriculum DAG, and Primer delegates the choice to the renderer. Pick the first route, render route.lesson metadata as a chooser, or apply any host-side selection policy.
state.enter(route) is synchronous. It returns ObservationState | InteractionState immediately from local data already delivered with the frontier. Never write await state.enter(route). Entering a route fires a background frame-open beacon that the caller never sees and never waits on.
if (state.phase === "frontier") {
const route = state.routes[0]
renderLessonTitle(route.lesson.title, route.lesson.stage)
state = state.enter(route)
}
After the entered frame reaches a terminal action (a graded submission or timeout) and the learner advances through feedback with advance(), the SDK returns a fresh FrontierState reflecting the learner's new position, or CompletedState when the runtime scope is finished.
If a frontier route's frame requires a PCI that the host did not declare in supportedPcis, the server filters that route before it reaches the SDK. The SDK keeps an unsupported-PCI fatal path as an invariant guard.
start(options)
function start<
const S extends Subject | undefined = undefined,
const Supported extends readonly PciId[] = []
>(options: PrimerOptionsWithAccessToken<S, Supported>): Promise<AccessTokenStartState>
function start<
const S extends Subject | undefined = undefined,
const Supported extends readonly PciId[] = []
>(options: PrimerOptionsWithManagedAuth<S, Supported>): Promise<ManagedStartState>
start is the first SDK lifecycle operation. Its return type depends on whether accessToken is present:
| Result | Meaning |
|---|---|
SignInRequiredState | Learner sign-in is needed before runtime learning can begin. Managed-auth mode only. |
SignInFailedState | Hosted sign-in failed but can be retried. Managed-auth mode only. |
AuthUnavailableState | Browser-hosted auth cannot run in the current runtime. Managed-auth mode only. |
AuthConfigInvalidState | Hosted-auth configuration is invalid and cannot be retried. Managed-auth mode only. |
FrontierState | A frontier of next-lesson routes is ready. The renderer chooses a route and enters it. |
ObservationState, InteractionState, or FeedbackState | A learning state is ready to render. |
CompletedState | The runtime scope is already complete. |
ErroredState | Startup or runtime communication failed but may be retriable. |
FatalState | Startup or runtime communication failed terminally for this state object. |
Always switch on state.phase. Do not assume the first state is renderable learning content.
Every learning state exposes state.journey, a read-only NON-NULL learner progress object. Auth states (sign-in-required, sign-in-failed, auth-unavailable, auth-config-invalid, not-entitled, placement-required) have no journey field at all; session-expired carries the last-known journey or null; errored/fatal carry an optional last-known journey for display continuity. The journey is pure progress data — course and lesson, each with { done, total } — and carries no mode: journey.lesson === null means the run is complete, anything else is active learning. Runtime identity is resolved by TimeBack auth and server-owned roster identity. Placement is server-owned and grade is not an SDK input, output, state field, export, or error.
interface Journey {
readonly course: { readonly title: string; readonly progress: JourneyProgress }
readonly lesson: { readonly title: string; readonly progress: JourneyProgress } | null
}
state.journey cannot be used to select content or change Primer routing. It is display data only.
import * as errors from "@superbuilders/errors"
import { logger } from "@/logger"
import { start, type PrimerOptionsWithAccessToken } from "@superbuilders/primer-tives/client/start"
import { ErrAuthUnavailable, ErrMalformedAccessToken } from "@superbuilders/primer-tives/errors"
const options = {
publishableKey,
accessToken,
subject: "math",
supportedPcis: ["urn:primer:pci:fraction-input"],
logger
} satisfies PrimerOptionsWithAccessToken<"math", readonly ["urn:primer:pci:fraction-input"]>
let state = await start(options)
if (state.phase === "fatal") {
if (errors.is(state.error, ErrMalformedAccessToken)) {
renderSignInAgain()
return
}
logger.error({ error: state.error }, "primer fatal state")
throw state.error
}
PrimerOptions
type PrimerOptions<S extends Subject | undefined = undefined, Supported extends readonly PciId[] = []> = {
readonly publishableKey: string
readonly origin?: string
readonly authOrigin?: string
readonly subject?: S
readonly supportedPcis: subject-dependent
readonly fetch?: typeof globalThis.fetch
readonly abort?: AbortController
readonly logger: PrimerLogger
}
type PrimerOptionsWithAccessToken<S, Supported> = PrimerOptions<S, Supported> & {
readonly accessToken: string
}
type PrimerOptionsWithManagedAuth<S, Supported> = PrimerOptions<S, Supported> & {
readonly accessToken?: undefined
}
| Field | Required | Meaning |
|---|---|---|
publishableKey | Yes | Public key identifying the Primer frontend your runtime belongs to. |
origin | No | Primer origin. Defaults to https://primerlearn.dev. |
authOrigin | No | Hosted-auth origin override. Defaults to origin. |
accessToken | Mode-dependent | Learner access token. When present, start uses access-token mode. When absent, start uses managed hosted-auth mode. |
subject | Yes | Public content scope: "math" or "science". |
supportedPcis | Subject-dependent | Renderer PCI capability declaration (ids only). Required when the chosen scope can emit required PCIs. Sent on every advance request; not a UI registry. |
fetch | No | Fetch override for tests, instrumentation, or host runtime integration. |
abort | No | Abort controller for SDK runtime work. |
logger | Yes | Structured logger implementing debug, info, warn, and error. |
The presence or absence of accessToken selects startup auth semantics.
The SDK uses Primer's production runtime by default.
| Shape | Semantics |
|---|---|
accessToken present | start validates the token shape locally and returns AccessTokenStartState. This mode cannot return sign-in states. |
accessToken absent | start uses managed hosted-auth mode. It reads and writes the SDK-managed browser session token cache documented below. |
The public API exposes hosted auth as state behavior. SignInRequiredState.login() and SignInFailedState.login() are the sign-in transitions. AuthUnavailableState and AuthConfigInvalidState expose no login operation.
subject must be a concrete literal subject at the options declaration site. Do not pass a broad Subject value into PrimerOptions; narrow it in host control flow, then construct options with satisfies PrimerOptionsWithManagedAuth<"math", ...>, satisfies PrimerOptionsWithAccessToken<"math", ...>, or the corresponding literal subject variant.
Managed Auth Token Persistence
Managed hosted auth is designed for browser-only consumers that do not have their own server-side token broker. When accessToken is absent, PrimerTives owns a session-scoped browser token cache with one fixed storage location:
sessionStorage["primer:access-token:<publishableKey>"]
This is intentionally zero config. There is no storage selector and no persistence flag. Managed-auth mode always uses globalThis.sessionStorage; if sessionStorage is unavailable, start returns AuthUnavailableState.
The cache stores only the final Primer access token returned by hosted sign-in. OAuth transaction state, nonce, verifier, and callback validation state are not stored in browser storage; those remain server-managed by Primer.
Managed-auth startup behavior is:
| Situation | SDK behavior |
|---|---|
| Valid cached token exists | start uses it and enters the runtime without opening sign-in. |
| No cached token exists | start returns SignInRequiredState. |
| Cached token is malformed or expired | SDK clears the key and returns SignInRequiredState. |
| Hosted sign-in succeeds | SDK validates the returned token, stores it at the documented key, then starts the runtime. |
| Runtime rejects a managed cached token as expired or invalid | SDK clears the key and returns SessionExpiredState so the app can ask the learner to sign in again with progress context. |
Access-token mode bypasses this cache entirely. If accessToken is present in start options, PrimerTives does not read, write, or clear sessionStorage.
The cached value is a bearer credential. Browser-only consumers get reload convenience from this default, but any script that can read the page can read the token. Host applications must maintain normal XSS protections and should use access-token mode if they need to own token storage themselves.
Auth Semantics
An access token is expected to be JWS-shaped: it starts with eyJ and contains exactly two dots. If a provided token does not match that shape, start returns FatalState with ErrMalformedAccessToken.
SDK-managed auth may require browser capabilities and learner interaction. Auth failures are represented by explicit auth states:
| Sentinel | Meaning |
|---|---|
ErrAuthUnavailable | SDK-managed auth requires browser functionality that is unavailable in the current runtime. |
ErrAuthConfigInvalid | SDK-managed auth was given invalid public configuration. |
ErrAuthCallbackInvalid | The auth result could not be accepted as a successful learner auth result. |
ErrAuthStateMismatch | The auth result did not match the auth attempt that initiated it. |
ErrAuthPopupBlocked | The browser blocked the learner auth window. |
ErrAuthCancelled | The learner auth interaction was closed or exceeded its allowed time. |
ErrMalformedAccessToken | The resolved token was not shaped like a learner access token. |
Applications should handle user-actionable auth failures directly and log unexpected failures before propagating them.
import * as errors from "@superbuilders/errors"
import { logger } from "@/logger"
import {
ErrAuthCancelled,
ErrAuthPopupBlocked,
ErrAuthUnavailable
} from "@superbuilders/primer-tives/errors"
let state = await start(options)
if (state.phase === "sign-in-failed") {
if (errors.is(state.error, ErrAuthPopupBlocked)) {
renderPopupInstructions(state)
return
}
if (errors.is(state.error, ErrAuthCancelled)) {
renderTryAgain(state)
return
}
logger.error({ error: state.error }, "primer auth failed")
renderSignInButton(state)
return
}
if (state.phase === "auth-unavailable") {
renderUnsupportedBrowserMessage(state.error)
return
}
if (state.phase === "auth-config-invalid") {
logger.error({ error: state.error }, "primer auth configuration invalid")
renderIntegrationError(state.error)
return
}
if (state.phase === "sign-in-required") {
renderSignInButton(state)
}
Subject And PCI Contract
subject selects content scope and determines required renderer capabilities.
import { SUBJECTS } from "@superbuilders/primer-tives/subject"
import type { Subject } from "@superbuilders/primer-tives/subject"
const subjects = SUBJECTS
type RuntimeSubject = Subject
Current public subjects:
| Subject | Required PCI support |
|---|---|
"math" | "urn:primer:pci:fraction-input" |
"science" | none |
The type-level rule is a subset check:
required PCIs for selected scope <= supportedPcis
Order does not matter. Extra supported PCIs are allowed. supportedPcis is a capability declaration for wire negotiation, not a content request and not a component registry. The SDK never calls host renderer code.
This fails at compile time because math can emit a required PCI:
await start({
publishableKey,
subject: "math",
logger
})
This passes:
const options = {
publishableKey,
subject: "math",
supportedPcis: ["urn:primer:pci:fraction-input"],
logger
} satisfies PrimerOptionsWithManagedAuth<"math", readonly ["urn:primer:pci:fraction-input"]>
await start(options)
The server uses the declared supportedPcis list to avoid serving portable custom interactions the host cannot render. The SDK keeps ErrUnsupportedPci as an invariant guard if the server ever returns an undeclared PCI anyway.
Runtime Loop
Renderer code can use total matcher helpers instead of hand-written switches. Handler maps are exhaustive at compile time.
import { matchPrimerState } from "@superbuilders/primer-tives/client/matchers"
import type { PrimerState } from "@superbuilders/primer-tives/client/types"
function renderPrimer(state: PrimerState) {
return matchPrimerState(state, {
"sign-in-required": renderSignIn,
"sign-in-failed": renderSignInFailure,
"session-expired": renderSessionExpired,
"auth-unavailable": renderAuthUnavailable,
"auth-config-invalid": renderAuthConfigInvalid,
"not-entitled": renderNotEntitled,
"placement-required": renderPlacementRequired,
frontier: renderFrontier,
observation: renderObservation,
interaction: renderInteraction,
feedback: renderFeedback,
completed: renderCompleted,
errored: renderErrored,
fatal: renderFatal
})
}
Raw switch statements remain supported. Every renderer should switch on state.phase. Interaction rendering should then switch on state.kind.
import { logger } from "@/logger"
import type { PrimerState } from "@superbuilders/primer-tives/client/types"
async function runPrimer(initialState: PrimerState): Promise<void> {
let state = initialState
while (state.phase !== "completed" && state.phase !== "fatal") {
switch (state.phase) {
case "sign-in-required":
case "sign-in-failed":
renderSignInButton(state)
return
case "auth-unavailable":
renderUnsupportedBrowserMessage(state.error)
return
case "frontier": {
// Every route is equally valid. Pick one, or render route.lesson
// metadata as a chooser and enter the learner's pick.
const route = state.routes[0]
state = state.enter(route)
break
}
case "observation":
renderFrame(state.body, state.stimulus)
state = await state.advance()
break
case "interaction":
state = await renderAndSubmitInteraction(state)
break
case "feedback":
if (state.verdict === "timedOut") {
renderTimeoutFeedback(state.feedbackContent)
} else {
renderAnsweredFeedback(state.feedbackContent, state.verdict === "correct", state.review)
}
state = await state.advance()
break
case "errored":
if (state.retriable) {
state = await state.retry()
break
}
logger.error({ error: state.error }, "primer state error")
throw state.error
}
}
if (state.phase === "fatal") {
logger.error({ error: state.error }, "primer fatal state")
throw state.error
}
}
State transitions:
| Current state | Valid operation | Next result |
|---|---|---|
SignInRequiredState | login() | Promise<ManagedStartState> |
SignInFailedState | login() | Promise<ManagedStartState> |
SessionExpiredState | login() | Promise<ManagedStartState> |
AuthUnavailableState | none | terminal for hosted auth in this runtime |
AuthConfigInvalidState | none | terminal for the current configuration |
FrontierState | enter(route) | EnterNext (synchronous) |
ObservationState | advance() | Promise<ObservationAdvanceNext> |
ChoiceState | submitChoice(selectedKeys) or timeout() | Promise<SubmitNext> / Promise<TimeoutNext> |
TextEntryState | submitText(value) or timeout() | Promise<SubmitNext> / Promise<TimeoutNext> |
ExtendedTextSingleState | submitText(value) or timeout() | Promise<SubmitNext> / Promise<TimeoutNext> |
ExtendedTextMultipleState | submitTexts(values) or timeout() | Promise<SubmitNext> / Promise<TimeoutNext> |
OrderState | submitOrder(orderedKeys) or timeout() | Promise<SubmitNext> / Promise<TimeoutNext> |
MatchState | submitMatch(pairs) or timeout() | Promise<SubmitNext> / Promise<TimeoutNext> |
PciInteractionState | submit(value) or timeout() | Promise<SubmitNext> / Promise<TimeoutNext> |
FeedbackState | advance() | Promise<FeedbackAdvanceNext> |
CompletedState | none | terminal |
RetriableErroredState | retry() | Promise<RetryNext> |
NonRetriableErroredState | none | terminal for the failed intent |
FatalState | none | terminal |
PrimerState
type PrimerState<Pcis extends PciId = PciId, M extends AuthMode = "managed"> =
| SignInRequiredState<Pcis>
| SignInFailedState<Pcis>
| SessionExpiredState<Pcis>
| AuthUnavailableState
| AuthConfigInvalidState
| RuntimeState<Pcis, M>
| CompletedState
| ErroredState<Pcis, M>
| FatalState
type RuntimeState<Pcis extends PciId = PciId, M extends AuthMode = "managed"> =
| FrontierState<Pcis, M>
| ObservationState<Pcis, M>
| InteractionState<Pcis, M>
| FeedbackState<Pcis, M>
PrimerState is live in-memory state. It contains transition closures, pending-operation guards, and retry behavior. Do not serialize it, store it, clone it through JSON, or pass it through host data. Calling JSON.stringify(state) throws ErrNotSerializable.
Start a new state by calling start again after a reload, remount, account switch, or subject switch.
SignInRequiredState
interface SignInRequiredState<Pcis extends PciId = PciId> {
readonly phase: "sign-in-required"
login(): Promise<ManagedStartState<Pcis>>
}
SignInRequiredState means learner sign-in is required before learning content can be rendered. It has no error field because no sign-in attempt has failed. Render a sign-in button or equivalent learner action. Call login() only from that user action.
Correct browser-safe pattern:
function handleSignInClick(state: SignInRequiredState): void {
void state.login().then(function continueAfterLogin(nextState) {
renderPrimer(nextState)
})
}
React renderers should use the same direct-call rule:
function SignInButton({ state }: { state: SignInRequiredState }) {
async function handleClick() {
const nextState = await state.login()
renderPrimer(nextState)
}
return <button type="button" onClick={handleClick}>Sign in to continue</button>
}
For Chrome popup blocking, state.login() must be the first async-producing operation in the click or tap handler. This is correct:
async function handleClick() {
const nextState = await state.login()
renderPrimer(nextState)
}
This is not browser-safe:
async function handleClick() {
await recordAnalyticsClick()
const nextState = await state.login()
renderPrimer(nextState)
}
If login() fails in a retryable hosted-auth way, it resolves to SignInFailedState. If hosted auth cannot run in the current runtime, it resolves to AuthUnavailableState. If the public hosted-auth configuration is invalid, it resolves to AuthConfigInvalidState.
SignInFailedState
interface SignInFailedState<Pcis extends PciId = PciId> {
readonly phase: "sign-in-failed"
readonly error: Error
login(): Promise<ManagedStartState<Pcis>>
}
SignInFailedState means a hosted sign-in attempt failed but another user gesture may retry it. Render the error and bind login() to a retry button.
SessionExpiredState
interface SessionExpiredState<Pcis extends PciId = PciId> {
readonly phase: "session-expired"
readonly error: Error
readonly journey: Journey | null
login(): Promise<ManagedStartState<Pcis>>
}
SessionExpiredState means a managed-auth runtime token expired or was rejected after learning had started. Render sign-in UI again; journey carries the last known learner position when one was available.
AuthUnavailableState
interface AuthUnavailableState {
readonly phase: "auth-unavailable"
readonly error: Error
}
AuthUnavailableState means the browser capabilities needed for hosted auth are unavailable. It does not expose login() because retrying the same operation cannot work in that runtime.
AuthConfigInvalidState
interface AuthConfigInvalidState {
readonly phase: "auth-config-invalid"
readonly error: Error
}
AuthConfigInvalidState means hosted auth cannot run because the public configuration is invalid. It does not expose login() because retrying cannot fix invalid configuration.
Common State Fields
Learning states that render content expose body and stimulus.
interface RenderableState {
readonly body: ContentBlock[]
readonly stimulus: RendererStimulus | null
}
body is the main instructional content. stimulus is optional supporting material. Current stimulus support is image-only, but it is still a discriminated union so renderers can remain future-safe.
Observation and interaction states also expose lesson, the public metadata of the lesson the frame belongs to.
interface LessonMetadata {
readonly id: string
readonly title: string
readonly stage: LessonStage
}
lesson.stage is the lesson's stage in the curriculum: "teaching", "testing", or "transfer". LessonStage, LESSON_STAGES, and isLessonStage are exported from @superbuilders/primer-tives/contracts/lesson-stage. lesson is display data; it cannot be used to select content or change Primer routing.
FrontierState
interface FrontierState<Pcis extends PciId = PciId> {
readonly phase: "frontier"
readonly routes: readonly [FrontierRoute, ...FrontierRoute[]]
enter(route: FrontierRoute): ObservationState<Pcis> | InteractionState<Pcis>
}
interface FrontierRoute {
readonly lesson: LessonMetadata
}
The frontier is the central routing state. Each route carries public lesson metadata so the renderer can present the choice. The frontend chooses which route to enter; every route is an equally valid next step in the curriculum DAG.
state.enter(route) is synchronous and local. It does not return a promise; never await state.enter(route). It returns the route's frame as ObservationState | InteractionState from data already delivered with the frontier, and fires a background frame-open beacon the caller never observes.
Enter exactly one route per frontier. After the entered frame's terminal action and feedback advance(), the SDK resolves a fresh frontier (or CompletedState).
ObservationState
interface ObservationState<Pcis extends PciId = PciId> {
readonly phase: "observation"
readonly lesson: LessonMetadata
readonly body: ContentBlock[]
readonly stimulus: RendererStimulus | null
advance(): Promise<PrimerState<Pcis>>
}
Render the frame, then call advance() when the learner is ready to continue. Observation states have no answer to submit.
Repeated advance() calls while the first one is pending return the same pending result.
InteractionState
type InteractionState<Pcis extends PciId = PciId> =
| ChoiceState<Pcis>
| TextEntryState<Pcis>
| ExtendedTextState<Pcis>
| OrderState<Pcis>
| MatchState<Pcis>
| PciInteractionState<Pcis>
Every interaction state includes:
| Field | Meaning |
|---|---|
phase: "interaction" | State-machine discriminator. |
kind | Renderer-facing interaction kind. |
lesson | Public lesson metadata for the frame. |
body | Frame content. |
stimulus | Optional frame stimulus. |
interaction | Full interaction contract object. |
feedback | Recoverable feedback for the current attempt, or null on a fresh frame. |
rejection | Local/server submission validation feedback for the current attempt, or null. |
| submit method | Kind-specific learner submission operation. |
timeout() | Records that the learner timed out or the host chose to end the attempt without a submission. A successful timeout resolves to timeout feedback before the next frame. |
Submission methods validate standard interaction payloads before runtime submission. Invalid standard submissions resolve back to InteractionState with rejection populated, so state = await state.submit...() is always safe.
Recoverable Feedback
Not every submission is terminal. A submission may resolve back to the same interaction phase with feedback populated (ContentInline[]) so the learner can revise and resubmit.
When a recoverable result arrives, the prior input is preserved in state.revision — uniformly across every interaction kind. Render state.revision.feedback, prefill the input from state.revision.previous, and let the learner submit again. state.revision.revisionsRemaining exposes the revision budget and state.revision.finalAttempt is true when the next submit is terminal. Terminal submissions resolve to FeedbackState instead.
Concurrent interaction operations are guarded:
| Situation | SDK behavior |
|---|---|
| Same submit payload while submit is pending | Returns the same pending result. |
| Different submit payload while submit is pending | Returns the already in-flight submit result. |
| Submit while timeout is pending | Returns the already in-flight timeout result. |
| Timeout while submit is pending | Returns the already in-flight submit result. |
| Repeated timeout while timeout is pending | Returns the same pending result. |
Interaction Calibration And Accuracy
Every interaction object carries aggregate calibration and accuracy data:
type InteractionCalibration = {
readonly p0: number
readonly p25: number
readonly p50: number
readonly p75: number
readonly p100: number
}
type InteractionAccuracy =
| { readonly correct: number; readonly total: number }
| { readonly correct: 0; readonly total: 0 }
Access these fields from the interaction contract object:
state.interaction.calibration
state.interaction.accuracy
interaction.calibration is nullable. If Primer has no terminal samples for an interaction, calibration is null.
if (state.interaction.calibration !== null) {
const medianMs = state.interaction.calibration.p50
}
interaction.accuracy is never nullable. If Primer has no terminal samples, accuracy is { correct: 0, total: 0 }.
const { correct, total } = state.interaction.accuracy
const observedAccuracy = total === 0 ? undefined : correct / total
Calibration is aggregate historical timing data for this interaction. It measures elapsed time from frame open to the first terminal green/red submitted response. All calibration values are milliseconds. Yellow-path recoverable responses are not terminal and are excluded. Timeouts are excluded. p100 is the literal maximum observed terminal duration.
Accuracy is aggregate correctness over the same terminal submitted response set. correct is the number of terminal responses graded correct. total is the number of terminal responses. Primer exposes counts rather than a ratio so hosts can choose their own smoothing and display rules.
Primer uses UUIDv7 event ids as the timing source for calibration. No timestamp fields are exposed through the SDK.
Observation states do not expose interaction calibration or accuracy because they have no interaction object.
Example game-feedback usage:
if (state.phase === "interaction") {
const calibration = state.interaction.calibration
const accuracy = state.interaction.accuracy
if (calibration !== null) {
const fastTarget = calibration.p25
const typicalTarget = calibration.p50
const slowTarget = calibration.p75
configureRewardTiming({ fastTarget, typicalTarget, slowTarget })
}
const observedAccuracy = accuracy.total === 0 ? undefined : accuracy.correct / accuracy.total
configureRewardRules({ observedAccuracy })
}
ChoiceState
interface ChoiceState<Pcis extends PciId = PciId> {
readonly phase: "interaction"
readonly kind: "choice"
readonly lesson: LessonMetadata
readonly body: ContentBlock[]
readonly stimulus: RendererStimulus | null
readonly interaction: Extract<StandardRendererInteraction, { type: "choice" }>
readonly revision: Revision<{ readonly selectedKeys: string[] }> | null
readonly options: RendererChoice[]
readonly minChoices: number
readonly maxChoices: number
submitChoice(selectedKeys: string[]): Promise<PrimerState<Pcis>>
timeout(): Promise<PrimerState<Pcis>>
}
Use minChoices and maxChoices to decide whether the UI should submit immediately or require an explicit submit action.
Valid submitChoice payloads use identifiers from state.options:
| Requirement | Error if violated |
|---|---|
At least minChoices identifiers | ErrInvalidSubmission |
At most maxChoices identifiers | ErrInvalidSubmission |
Every identifier exists in options | ErrInvalidSubmission |
| No duplicate identifiers | ErrInvalidSubmission |
TextEntryState
interface TextEntryState<Pcis extends PciId = PciId> {
readonly phase: "interaction"
readonly kind: "text-entry"
readonly lesson: LessonMetadata
readonly body: ContentBlock[]
readonly stimulus: RendererStimulus | null
readonly interaction: Extract<StandardRendererInteraction, { type: "text-entry" }>
readonly revision: Revision<{ readonly value: string }> | null
submitText(value: string): Promise<PrimerState<Pcis>>
timeout(): Promise<PrimerState<Pcis>>
}
The interaction may include expectedLength, patternMask, and placeholderText. These are renderer hints. The SDK requires the submission to be a text-entry submission with a string value.
ExtendedTextState
Extended text has two cardinalities.
interface ExtendedTextSingleState<Pcis extends PciId = PciId> {
readonly phase: "interaction"
readonly kind: "extended-text"
readonly cardinality: "single"
readonly lesson: LessonMetadata
readonly body: ContentBlock[]
readonly stimulus: RendererStimulus | null
readonly interaction: Extract<
StandardRendererInteraction,
{ type: "extended-text"; cardinality: "single" }
>
readonly revision: Revision<{ readonly value: string }> | null
submitText(value: string): Promise<PrimerState<Pcis>>
timeout(): Promise<PrimerState<Pcis>>
}
interface ExtendedTextMultipleState<Pcis extends PciId = PciId> {
readonly phase: "interaction"
readonly kind: "extended-text"
readonly cardinality: "multiple"
readonly lesson: LessonMetadata
readonly body: ContentBlock[]
readonly stimulus: RendererStimulus | null
readonly interaction: Extract<
StandardRendererInteraction,
{ type: "extended-text"; cardinality: "multiple" }
>
readonly revision: Revision<{ readonly values: string[] }> | null
readonly minStrings: number
readonly maxStrings: number
submitTexts(values: string[]): Promise<PrimerState<Pcis>>
timeout(): Promise<PrimerState<Pcis>>
}
For single-cardinality extended text, call submitText(value). For multiple-cardinality extended text, call submitTexts(values).
Valid multiple-cardinality payloads:
| Requirement | Error if violated |
|---|---|
At least minStrings values | ErrInvalidSubmission |
At most maxStrings values | ErrInvalidSubmission |
| No duplicate values | ErrInvalidSubmission |
expectedLength, expectedLines, patternMask, and placeholderText are renderer hints.
OrderState
interface OrderState<Pcis extends PciId = PciId> {
readonly phase: "interaction"
readonly kind: "order"
readonly lesson: LessonMetadata
readonly body: ContentBlock[]
readonly stimulus: RendererStimulus | null
readonly interaction: Extract<StandardRendererInteraction, { type: "order" }>
readonly revision: Revision<{ readonly orderedKeys: string[] }> | null
readonly choices: RendererChoice[]
readonly minChoices: number
readonly maxChoices: number
submitOrder(orderedKeys: string[]): Promise<PrimerState<Pcis>>
timeout(): Promise<PrimerState<Pcis>>
}
Submit identifiers from state.choices in learner-selected order.
Valid submitOrder payloads:
| Requirement | Error if violated |
|---|---|
At least minChoices identifiers | ErrInvalidSubmission |
At most maxChoices identifiers | ErrInvalidSubmission |
Every identifier exists in choices | ErrInvalidSubmission |
| No duplicate identifiers | ErrInvalidSubmission |
MatchState
interface MatchPair {
source: string
target: string
}
interface MatchState<Pcis extends PciId = PciId> {
readonly phase: "interaction"
readonly kind: "match"
readonly lesson: LessonMetadata
readonly body: ContentBlock[]
readonly stimulus: RendererStimulus | null
readonly interaction: Extract<StandardRendererInteraction, { type: "match" }>
readonly revision: Revision<{ readonly pairs: MatchPair[] }> | null
readonly sourceChoices: RendererChoice[]
readonly targetChoices: RendererChoice[]
readonly minAssociations: number
readonly maxAssociations: number
submitMatch(pairs: MatchPair[]): Promise<PrimerState<Pcis>>
timeout(): Promise<PrimerState<Pcis>>
}
Each pair connects one source identifier from state.sourceChoices to one target identifier from state.targetChoices.
Valid submitMatch payloads:
| Requirement | Error if violated |
|---|---|
At least minAssociations pairs | ErrInvalidSubmission |
At most maxAssociations pairs | ErrInvalidSubmission |
Every source identifier exists in sourceChoices | ErrInvalidSubmission |
Every target identifier exists in targetChoices | ErrInvalidSubmission |
| No duplicate source-target pairs | ErrInvalidSubmission |
| No duplicate source identifiers | ErrInvalidSubmission |
| No duplicate target identifiers | ErrInvalidSubmission |
PciInteractionState
Portable Custom Interaction state is typed by PCI id.
type PciInteractionState<Pcis extends PciId = PciId> = {
[K in Pcis]: {
readonly phase: "interaction"
readonly kind: "portable-custom"
readonly lesson: LessonMetadata
readonly body: ContentBlock[]
readonly stimulus: RendererStimulus | null
readonly interaction: PciInteraction<K>
readonly revision: Revision<{ readonly value: PciValue<K> }> | null
readonly pciId: K
readonly properties: PciProps<K>
submit(value: PciValue<K>): Promise<PrimerState<Pcis>>
timeout(): Promise<PrimerState<Pcis>>
}
}[Pcis]
When the state is narrowed to a PCI id, submit accepts only that PCI's value type.
import type { PciValue } from "@superbuilders/primer-tives/contracts/pci"
if (state.phase === "interaction" && state.kind === "portable-custom") {
if (state.pciId === "urn:primer:pci:fraction-input") {
const value: PciValue<"urn:primer:pci:fraction-input"> = readFractionInput(
state.properties
)
state = await state.submit(value)
}
}
FeedbackState
type FeedbackState<Pcis extends PciId = PciId> =
| AnsweredFeedbackState<Pcis>
| TimedOutFeedbackState<Pcis>
type AnsweredFeedbackState<Pcis extends PciId = PciId> = {
[K in InteractionKind]: {
readonly phase: "feedback"
readonly verdict: "correct" | "incorrect"
readonly kind: K
readonly body: ContentBlock[]
readonly stimulus: RendererStimulus | null
readonly interaction: InteractionFor<K, Pcis>
readonly submission: SubmissionFor<K, Pcis>
readonly feedbackContent: ContentInline[]
readonly review: ReviewFor<K, Pcis> | null
advance(): Promise<PrimerState<Pcis>>
}
}[InteractionKind]
interface TimedOutFeedbackState<Pcis extends PciId = PciId> {
readonly phase: "feedback"
readonly verdict: "timedOut"
readonly body: ContentBlock[]
readonly stimulus: RendererStimulus | null
readonly interaction: RendererInteraction<Pcis>
readonly feedbackContent: ContentInline[]
advance(): Promise<PrimerState<Pcis>>
}
Feedback state is returned after a terminal learner submission or timeout. Switch on verdict — wrongness has one name: verdict !== "correct". Answered feedback ("correct" | "incorrect") includes the submitted value, feedback content, and optional review data. "timedOut" feedback has no submitted value (there is none) and is scored as wrong. Call advance() when the learner is ready to continue; it typically resolves to a fresh FrontierState (or CompletedState).
Repeated advance() calls while the first one is pending return the same pending result.
CompletedState
interface CompletedState {
readonly phase: "completed"
}
Terminal state. There is no transition method.
ErroredState
type ErroredState<Pcis extends PciId = PciId> =
| RetriableErroredState<Pcis>
| NonRetriableErroredState
interface RetriableErroredState<Pcis extends PciId = PciId> {
readonly phase: "errored"
readonly error: Error
readonly retriable: true
retry(): Promise<PrimerState<Pcis>>
}
interface NonRetriableErroredState {
readonly phase: "errored"
readonly error: Error
readonly retriable: false
}
ErroredState means the current learner intent could not complete, but the learning session itself is not necessarily terminal.
If retriable is true, retry() repeats the exact failed intent. If retriable is false, the state does not expose retry().
Local and server submission validation failures resolve back to the interaction phase with rejection populated. Renderer code can safely adopt the returned state and let the learner fix the payload in place.
FatalState
interface FatalState {
readonly phase: "fatal"
readonly error: Error
readonly retriable: false
}
Fatal state means the SDK cannot recover by retrying the current learner intent. Render a terminal error UI, call start again with valid options, or send the learner through auth again depending on the sentinel.
Fatal sentinels:
| Sentinel | Meaning |
|---|---|
ErrBadRequest | Primer rejected the runtime request as invalid for the SDK contract. |
ErrWireContractViolation | A successful response violated the wire contract (version skew or server bug). |
ErrInvalidPublishableKey | The publishable key is missing or unknown — integrator configuration; the token is NOT cleared. |
ErrInvalidAccessToken | The learner token was rejected. |
ErrTokenExpired | The learner token expired. |
ErrNoRoutableContent | The catalog has no routable bootstrap content for the learner's scope. |
ErrSdkUpgradeRequired | The installed SDK is too old for the current Primer runtime. |
ErrUnsupportedPci | Primer presented a PCI that the host did not declare in supportedPcis. |
Stale-offer responses are NOT fatal: when a frame_event answers with
offer_not_found, offer_superseded, frame_already_completed, or
event_kind_mismatch, the SDK silently issues continue once and resolves to
the fresh state — multi-tab races self-heal. A second consecutive stale
response lands in a retriable errored state instead of looping.
Local in-flight races (submit while timeout is pending, conflicting
payloads) resolve to the first in-flight transition's pending promise. Local and
server ErrInvalidSubmission results are converted back into interaction states
with rejection populated. ErrContentUngradeable (authored content cannot be
graded) and infrastructure failures land in retriable errored states.
The frontier is one-shot: the first state.enter(route) call wins, every
subsequent enter(route) on that FrontierState returns the same state object,
and the open beacon fires exactly once for the entered route.
Contracts
Import renderer-facing data types and validation helpers from leaf paths under @superbuilders/primer-tives/contracts/.
import { blocksToPlainText, inlinesToPlainText } from "@superbuilders/primer-tives/contracts/content"
import { LESSON_STAGES, isLessonStage } from "@superbuilders/primer-tives/contracts/lesson-stage"
import { isPciId, PCI_IDS } from "@superbuilders/primer-tives/contracts/pci"
import {
ChoiceSubmissionSchema,
ExtendedTextSubmissionSchema,
FractionInputPciSubmissionSchema,
MatchPairSchema,
MatchSubmissionSchema,
OrderSubmissionSchema,
RendererSubmissionSchema,
TextEntrySubmissionSchema,
correlateSubmission,
submissionValidationMessage,
validateSubmission
} from "@superbuilders/primer-tives/contracts/validation"
import type { ContentBlock, ContentInline, ContentSpan } from "@superbuilders/primer-tives/contracts/content"
import type { LessonStage } from "@superbuilders/primer-tives/contracts/lesson-stage"
import type {
FractionInputForm,
FractionInputProps,
FractionInputSubmission,
PciId,
PciProps,
PciRegistry,
PciUrn,
PciValue
} from "@superbuilders/primer-tives/contracts/pci"
import type { InteractionReview } from "@superbuilders/primer-tives/contracts/review"
import type {
ImageStimulus,
InteractionFor,
InteractionKind,
MatchPair,
PciInteraction,
PciSubmission,
RendererChoice,
RendererInteraction,
RendererStimulus,
RendererSubmission,
ReviewFor,
SubmissionFor,
StandardRendererInteraction
} from "@superbuilders/primer-tives/contracts/types"
Content
type ContentSpan = { type: "text"; value: string } | { type: "italic"; value: string }
type ContentInline = ContentSpan | { type: "latex"; value: string }
type ContentBlock = { type: "paragraph"; children: ContentInline[] }
Helpers:
function inlinesToPlainText(nodes: ContentInline[]): string
function blocksToPlainText(blocks: ContentBlock[]): string
Use the plain-text helpers for accessibility labels, logging summaries, search snippets, and renderer fallbacks. LaTeX inline nodes contribute their raw value to plain text.
Stimulus
interface ImageStimulus {
kind: "image"
alt: ContentInline[]
src: string
}
type RendererStimulus = ImageStimulus
RendererStimulus is currently image-only. Always switch on stimulus.kind anyway.
Interactions
type RendererInteraction<Pcis extends PciId = PciId> =
| StandardRendererInteraction
| PciInteraction<Pcis>
Standard interactions:
| Type | Key fields |
|---|---|
choice | prompt, options, minChoices, maxChoices |
text-entry | prompt, base, expectedLength, patternMask, placeholderText |
extended-text single | prompt, format, expectedLines, expectedLength, patternMask, placeholderText |
extended-text multiple | single fields plus minStrings, maxStrings |
order | prompt, choices, minChoices, maxChoices |
match | prompt, sourceChoices, targetChoices, minAssociations, maxAssociations |
portable-custom | prompt, pciId, properties |
Choice objects:
interface RendererChoice {
identifier: string
content: ContentInline[]
}
Submissions
type RendererSubmission<Pcis extends PciId = PciId> =
| { type: "choice"; selectedKeys: string[] }
| { type: "text-entry"; value: string }
| { type: "extended-text"; values: string[] }
| { type: "order"; orderedKeys: string[] }
| { type: "match"; pairs: MatchPair[] }
| PciSubmission<Pcis>
Public schemas:
| Schema | Validates |
|---|---|
MatchPairSchema | { source, target } match pair shape |
ChoiceSubmissionSchema | choice submission shape |
TextEntrySubmissionSchema | text-entry submission shape |
ExtendedTextSubmissionSchema | extended-text submission shape |
OrderSubmissionSchema | order submission shape |
MatchSubmissionSchema | match submission shape |
FractionInputPciSubmissionSchema | fraction-input PCI submission shape |
RendererSubmissionSchema | union of all supported submission shapes |
Always use the exported AJV-backed Draft 7 validator when parsing arbitrary input.
import * as errors from "@superbuilders/errors"
import { logger } from "@/logger"
import * as validate from "@superbuilders/validate"
import { RendererSubmissionSchema } from "@superbuilders/primer-tives/contracts/validation"
const parsed = RendererSubmissionSchema.parse(payload)
if (!parsed.success) {
logger.error({ error: parsed.error }, "submission payload invalid")
throw errors.wrap(parsed.error, "submission payload")
}
const submission = parsed.data
Semantic Submission Validation
Shape validation answers “does this look like a submission?” Semantic validation answers “is this submission valid for this exact interaction?”
function validateSubmission<K extends InteractionKind>(
interaction: InteractionFor<K>,
submission: SubmissionFor<K>
): SubmissionValidationResult<SubmissionFor<K>>
function correlateSubmission(
interaction: RendererInteraction,
submission: RendererSubmission
): SubmissionValidationResult<RendererSubmission>
function submissionValidationMessage(result: SubmissionValidationFailure): string
Result shape:
type SubmissionValidationResult<S> =
| { ok: true; value: S }
| { ok: false; issues: readonly string[] }
Validation checks:
| Interaction | Checks |
|---|---|
choice | min/max selection count, duplicate identifiers, unknown identifiers |
text-entry | typed shape |
extended-text single | exactly one value |
extended-text multiple | min/max value count, duplicate values |
order | min/max selection count, duplicate identifiers, unknown identifiers |
match | min/max association count, duplicate pairs, duplicate sources, duplicate targets, unknown sources, unknown targets |
portable-custom | PCI value schema |
The built-in standard interaction state methods call typed validateSubmission before submitting. Server and other hostile JSON boundaries should call correlateSubmission, which rejects interaction/submission mismatches before using validation.value.
import * as errors from "@superbuilders/errors"
import { logger } from "@/logger"
import {
correlateSubmission,
submissionValidationMessage,
validateSubmission
} from "@superbuilders/primer-tives/contracts/validation"
import { ErrInvalidSubmission } from "@superbuilders/primer-tives/errors"
const validation = correlateSubmission(interaction, submission)
if (!validation.ok) {
const message = submissionValidationMessage(validation)
logger.error({ issues: validation.issues }, "submission invalid")
throw errors.wrap(ErrInvalidSubmission, message)
}
Review Types
AnsweredFeedbackState.review is InteractionReview | null. Timeout feedback does not carry review data because no submission was graded.
type InteractionReview<Pcis extends PciId = PciId> =
| ChoiceReview
| TextEntryReview
| ExtendedTextReview
| OrderReview
| MatchReview
| PciReview<Pcis>
Review variants:
| Type | Data |
|---|---|
choice | correctKeys: string[] |
text-entry | `correctValue: ReviewScalarValue |
extended-text | correctValues: ReviewScalarValue[] |
order | correctOrder: string[] |
match | correctPairs: MatchPair[] |
portable-custom | pciId, fields: ReviewRecordField[] |
Review scalar values:
type ReviewScalarValue =
| { kind: "identifier"; value: string }
| { kind: "string"; value: string }
| { kind: "integer"; value: number }
| { kind: "float"; value: number }
| { kind: "pair"; source: string; target: string }
review is for renderer display and inspection. The correctness outcome lives on the feedback state's verdict ("correct" | "incorrect" | "timedOut").
PCI Registry
The current PCI registry contains one PCI.
const PCI_IDS = ["urn:primer:pci:fraction-input"] as const
type PciId = "urn:primer:pci:fraction-input"
type PciProps<K extends PciId> = PciRegistry[K]["props"]
type PciValue<K extends PciId> = PciRegistry[K]["value"]
Use isPciId(value) to narrow an arbitrary string to the current PciId union.
Fraction Input PCI
type FractionInputForm = "whole" | "proper" | "improper" | "mixed"
interface FractionInputProps {
form: FractionInputForm
requireSimplified: boolean
}
Submitted value:
type FractionInputSubmission =
| { form: "whole"; whole: string }
| { form: "proper"; numerator: string; denominator: string }
| { form: "improper"; numerator: string; denominator: string }
| { form: "mixed"; whole: string; numerator: string; denominator: string }
Example renderer branch:
if (state.phase === "interaction" && state.kind === "portable-custom") {
if (state.pciId === "urn:primer:pci:fraction-input") {
renderFractionInput({
mode: "pending",
properties: state.properties,
onValueChange: handleFractionValueChange
})
}
}
Rendering Portable Custom Interactions
Portable Custom Interactions (PCIs) split cleanly across the three architecture layers documented above. This section is the authoritative guide for integrators who were confused by the removed pciRenderers option in 9.0.0.
Step 1 — Declare capabilities at start (wire only)
When your subject scope can emit math PCIs, pass a literal supportedPcis array. This tells the Primer server which PCI ids your host can render. The SDK copies this array onto every advance request body unchanged.
const options = {
publishableKey: "pk_...",
subject: "math",
supportedPcis: ["urn:primer:pci:fraction-input"] as const,
logger
} satisfies PrimerOptionsWithManagedAuth<"math", readonly ["urn:primer:pci:fraction-input"]>
Nothing in this step imports React, mounts components, or registers render functions. You are declaring ids.
Step 2 — Render when the state machine says portable-custom
When the learner enters a PCI frame, the SDK returns PciInteractionState<K>:
if (state.phase === "interaction" && state.kind === "portable-custom") {
switch (state.pciId) {
case "urn:primer:pci:fraction-input": {
// Host UI: read state.properties (FractionInputProps)
// Collect learner input, then:
const value = buildFractionSubmissionFromHostUi()
state = await state.submit(value)
break
}
}
}
The state machine gives you:
| Field | Role |
|---|---|
state.pciId | Which PCI contract applies |
state.properties | Authoring-time PCI configuration (PciProps<K>) |
state.interaction | Full interaction contract including prompt and calibration |
state.revision | Recoverable yellow-path bundle when applicable |
state.submit(value) | Validates and sends PciValue<K> to the runtime |
Rendering is your switch on pciId. The SDK does not do it for you.
Step 3 — Optional PciRenderProps for component authors
If you extract a fraction-input widget, PciRenderProps<K> types its pending vs submitted modes. This is exported from @superbuilders/primer-tives/client/types for convenience. It is not passed to start().
import type { PciRenderProps } from "@superbuilders/primer-tives/client/types"
import type { PciValue } from "@superbuilders/primer-tives/contracts/pci"
function FractionInputHost(props: PciRenderProps<"urn:primer:pci:fraction-input">) {
if (props.mode === "pending") {
// wire DOM or framework bindings to props.onValueChange
return
}
// props.mode === "submitted" — show submission + optional review
}
@superbuilders/primer-renderer is the first-party React reference that uses these types internally. Third-party integrators may ignore that package entirely.
Server and client guards
| Layer | Behavior |
|---|---|
| Server | Drops frontier routes whose frame needs a PCI not listed in supportedPcis |
| SDK session | Fatals with ErrUnsupportedPci if an undeclared PCI frame arrives anyway |
Both guards assume you declared honest capabilities. Do not list PCIs you cannot render.
Framework-Agnostic Integration Examples
The same start() options work in every host environment. Only the rendering layer differs.
Vanilla DOM
import { start } from "@superbuilders/primer-tives/client/start"
import type { PciValue } from "@superbuilders/primer-tives/contracts/pci"
const state = await start({
publishableKey: "pk_...",
subject: "math",
supportedPcis: ["urn:primer:pci:fraction-input"],
logger
})
function mountFractionInput(
root: HTMLElement,
properties: { form: string; requireSimplified: boolean },
onValueChange: (value: PciValue<"urn:primer:pci:fraction-input"> | null) => void
): void {
// build <input> elements, call onValueChange when the learner edits
void root
void properties
void onValueChange
}
async function run(): Promise<void> {
let current = state
if (current.phase === "interaction" && current.kind === "portable-custom") {
if (current.pciId === "urn:primer:pci:fraction-input") {
let latest: PciValue<"urn:primer:pci:fraction-input"> | null = null
mountFractionInput(document.getElementById("pci-root")!, current.properties, function handle(v) {
latest = v
})
if (latest !== null) {
current = await current.submit(latest)
}
}
}
}
React (host renderer — not SDK)
import { start } from "@superbuilders/primer-tives/client/start"
import type { PciRenderProps, PrimerState } from "@superbuilders/primer-tives/client/types"
function FractionInput(props: PciRenderProps<"urn:primer:pci:fraction-input">) {
// React component — lives in YOUR app, not in primer-tives
return null
}
function PortableCustomInteraction({ state }: { state: PrimerState }) {
if (state.phase !== "interaction" || state.kind !== "portable-custom") {
return null
}
if (state.pciId === "urn:primer:pci:fraction-input") {
return <FractionInput mode="pending" properties={state.properties} onValueChange={() => {}} />
}
return null
}
start() options never reference <FractionInput />. React appears only in your render tree.
Vue / Svelte / other frameworks
Follow the same pattern as React:
start({ supportedPcis: [...] })with literal subject typing- Template or component switch on
state.pciId - Map
state.propertiesinto your framework's binding model - Call
state.submit(value)from an event handler
The SDK surface is identical across frameworks.
Migrating from 9.0.0 pciRenderers
Version 9.0.0 introduced pciRenderers: Record<pciId, (props) => unknown> on PrimerOptions. That option looked like a React component registry but the SDK never called those functions — it only read Object.keys(pciRenderers) and sent them as supportedPcis on the wire.
9.1.0 removes pciRenderers and restores the explicit capability array:
// 9.0.0 — removed in 9.1.0
const pciRenderers = {
"urn:primer:pci:fraction-input": function renderFractionInput(props) {
return renderFractionInputComponent(props)
}
}
await start({ publishableKey, subject: "math", pciRenderers, logger })
// 9.1.0+
await start({
publishableKey,
subject: "math",
supportedPcis: ["urn:primer:pci:fraction-input"],
logger
})
Migration checklist:
| If you had… | Do this in 9.1.0+ |
|---|---|
pciRenderers stub functions only for TypeScript | Delete them; pass supportedPcis ids |
pciRenderers pointing at real React components | Keep components in your renderer; pass supportedPcis ids to start() |
satisfies PrimerOptions<"math", typeof pciRenderers> | satisfies PrimerOptions<"math", readonly ["urn:primer:pci:fraction-input"]> |
@superbuilders/primer-renderer | It now passes SUPPORTED_PCI_IDS to start() again; PCI_CLIENT_REGISTRY stays internal to that package |
No wire protocol change. No state machine behavior change. This is a public options correction plus documentation clarity.
Host Renderer PCI Props (Optional)
Host renderer PCI props are optional convenience types for component authors. They are framework-agnostic TypeScript shapes. Do not pass them to start().
type PciPendingRenderProps<K extends PciId> = {
mode: "pending"
properties: PciProps<K>
onValueChange: (value: PciValue<K> | null) => void
}
type PciSubmittedRenderProps<K extends PciId> = {
mode: "submitted"
properties: PciProps<K>
submission: PciValue<K>
review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
}
type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRenderProps<K>
Subject PCI Helpers
Use @superbuilders/primer-tives/subject-pcis when renderer tooling needs the same subject-to-PCI contract as start.
import {
REQUIRED_PCIS_BY_SUBJECT,
missingPcisForSubject,
requiredPcisForSubject
} from "@superbuilders/primer-tives/subject-pcis"
import type {
HasRequiredPcis,
MissingRequiredPcis,
RequiredPciForSubject
} from "@superbuilders/primer-tives/subject-pcis"
const requiredForMath = requiredPcisForSubject("math")
const missingForMath = missingPcisForSubject("math", [])
requiredPcisForSubject(subject) requires a concrete Subject.
Errors
All SDK sentinels are exported from @superbuilders/primer-tives/errors and are compatible with errors.is() from @superbuilders/errors.
import * as errors from "@superbuilders/errors"
import { ErrTokenExpired } from "@superbuilders/primer-tives/errors"
if (errors.is(err, ErrTokenExpired)) {
renderSignInAgain()
}
Complete export set:
import {
ErrAuthCallbackInvalid,
ErrAuthCancelled,
ErrAuthConfigInvalid,
ErrAuthPopupBlocked,
ErrAuthStateMismatch,
ErrAuthUnavailable,
ErrBadRequest,
ErrConflict,
ErrCurriculumInstructionalUndeliverable,
ErrForbidden,
ErrFrameAlreadyAnswered,
ErrInvalidAccessToken,
ErrInvalidSubmission,
ErrJsonParse,
ErrMalformedAccessToken,
ErrNetwork,
ErrNoActiveFrame,
ErrNoRoutableContent,
ErrNotFound,
ErrNotSerializable,
ErrRateLimited,
ErrSdkUpgradeRequired,
ErrServerError,
ErrServiceUnavailable,
ErrSessionStateConflict,
ErrTimeout,
ErrTokenExpired,
ErrUnsupportedPci
} from "@superbuilders/primer-tives/errors"
Auth And Startup Errors
Auth and startup failures are represented as state whenever possible.
| Sentinel | Meaning | Typical handling |
|---|---|---|
ErrAuthUnavailable | SDK-managed auth cannot run in the current host environment. | Render unsupported-runtime or externally managed sign-in UI. |
ErrAuthConfigInvalid | Public auth configuration is invalid. | Log and treat as integration error. |
ErrAuthCallbackInvalid | Learner auth did not complete with an acceptable result. | Offer sign-in retry; log if unexpected. |
ErrAuthStateMismatch | Auth result does not match the initiated auth attempt. | Offer sign-in retry. |
ErrAuthPopupBlocked | Browser blocked learner auth UI. | Render sign-in instructions and retry from a direct user gesture. |
ErrAuthCancelled | Learner auth was closed or timed out. | Offer retry. |
ErrMalformedAccessToken | Provided or resolved token is not shaped like a learner access token. | Re-authenticate learner or fix token source. |
Runtime Error States
Runtime errors are represented as ErroredState or FatalState.
| Sentinel | State | Retriable | Meaning |
|---|---|---|---|
ErrNetwork | ErroredState | yes | Runtime communication failed before a usable Primer result existed. |
ErrTimeout | ErroredState | yes | Runtime work was aborted or exceeded the host's allowed time. |
ErrServerError | ErroredState | yes | Primer could not produce a normal runtime result. |
ErrServiceUnavailable | ErroredState | yes | Primer is temporarily unavailable. |
ErrRateLimited | ErroredState | yes | Runtime work is temporarily rate limited. |
ErrConflict | ErroredState | yes | The learner intent conflicts with another in-flight or current runtime action. |
ErrJsonParse | ErroredState | yes | Runtime data could not be interpreted as the SDK contract. |
ErrInvalidSubmission | InteractionState.rejection | no | Submitted value was invalid for the active interaction; the returned interaction remains live. |
ErrBadRequest | FatalState | no | Runtime request violates the SDK contract. |
ErrCurriculumInstructionalUndeliverable | FatalState | no | Curriculum routing reached an instructional with no deliverable frame content. |
ErrNoActiveFrame | FatalState | no | Primer had no open frame for the submitted intent. |
ErrFrameAlreadyAnswered | FatalState | no | The targeted frame was already answered. |
ErrSessionStateConflict | FatalState | no | The learner intent does not match the active frame type. |
ErrNoRoutableContent | FatalState | no | Catalog bootstrap has no routable content for the learner scope. |
ErrInvalidAccessToken | FatalState | no | Learner token is invalid. |
ErrTokenExpired | FatalState | no | Learner token expired. |
ErrForbidden | FatalState | no | Learner cannot continue in this runtime scope. |
ErrNotFound | FatalState | no | Runtime scope or state is unavailable. |
ErrSdkUpgradeRequired | FatalState | no | Installed SDK version is too old for Primer. |
ErrUnsupportedPci | FatalState | no | Renderer did not declare support for the presented PCI. |
ErrNotSerializable | thrown by toJSON | no | A live PrimerState was serialized. |
Error-Handling Recipes
Handle auth-needed state before rendering learning content:
let state = await start(options)
if (state.phase === "sign-in-required") {
renderSignInButton(state)
return
}
if (state.phase === "sign-in-failed") {
if (errors.is(state.error, ErrAuthCancelled)) {
renderTryAgain(state)
return
}
logger.error({ error: state.error }, "primer auth failed")
renderSignInButton(state)
return
}
if (state.phase === "auth-unavailable") {
renderUnsupportedBrowserMessage(state.error)
return
}
Bind login directly to the sign-in button:
function handleSignInClick(state: SignInRequiredState | SignInFailedState): void {
void state.login().then(function continueAfterLogin(nextState) {
renderPrimer(nextState)
})
}
Handle runtime errors through the state machine:
if (state.phase === "errored") {
if (state.retriable) {
state = await state.retry()
return
}
logger.error({ error: state.error }, "primer non-retriable state")
throw state.error
}
if (state.phase === "fatal") {
if (errors.is(state.error, ErrTokenExpired)) {
renderSignInAgain()
return
}
if (errors.is(state.error, ErrSdkUpgradeRequired)) {
renderSdkUpgradeMessage()
return
}
if (errors.is(state.error, ErrCurriculumInstructionalUndeliverable)) {
renderContentUnavailableMessage()
return
}
logger.error({ error: state.error }, "primer fatal state")
throw state.error
}
Handle invalid submissions by adopting the returned interaction state and rendering rejection:
const next = await state.submitChoice(selectedKeys)
if (next.phase === "interaction" && next.kind === "choice" && next.rejection !== null) {
renderSelectionError(next.rejection.content)
}
state = next
Logger
import type { PrimerLogger } from "@superbuilders/primer-tives/logger"
type PrimerLogger = import("pino").Logger
Use a Pino-compatible logger. Pino calls are object-first when attributes are present.
import { logger } from "@/logger"
import { start, type PrimerOptionsWithManagedAuth } from "@superbuilders/primer-tives/client/start"
const options = {
publishableKey,
subject: "science",
logger
} satisfies PrimerOptionsWithManagedAuth<"science">
const state = await start(options)
Testing
PrimerOptions.fetch exists so tests, host runtimes, and instrumentation can provide a fetch-compatible function. The SDK treats it exactly as the runtime communication function for start, transitions, submissions, retries, and timeouts.
The runtime exchange shape is not public SDK surface. Tests should assert SDK semantics after start resolves:
| Scenario | Assert |
|---|---|
| auth is needed | managed-auth start resolves to SignInRequiredState |
| auth login fails | login() resolves to SignInFailedState with the expected auth sentinel |
| auth cannot run | login() resolves to AuthUnavailableState |
| auth config is invalid | login() resolves to AuthConfigInvalidState |
| runtime scope is ready for an active learner | start resolves to FrontierState with at least one route |
| a frontier route is entered | state.enter(route) synchronously returns ObservationState or InteractionState |
| first runtime work fails recoverably | start resolves to ErroredState with retriable: true |
| first runtime work fails terminally | start resolves to FatalState |
| unsupported PCI is presented | start resolves to FatalState with ErrUnsupportedPci |
| curriculum instructional has no deliverable content | transition resolves to FatalState with ErrCurriculumInstructionalUndeliverable |
| standard submission is invalid | submit method resolves to InteractionState with rejection populated |
| concurrent submit/timeout conflict occurs | later call resolves to the first in-flight transition result |
| state is serialized | serialization throws ErrNotSerializable |
Example test shape:
import * as errors from "@superbuilders/errors"
import { start, type PrimerOptionsWithAccessToken } from "@superbuilders/primer-tives/client/start"
import { ErrUnsupportedPci } from "@superbuilders/primer-tives/errors"
declare const fetchMock: typeof globalThis.fetch
const options = {
publishableKey: "pk_test",
accessToken: "eyJ.test.token",
subject: "science",
fetch: fetchMock,
logger
} satisfies PrimerOptionsWithAccessToken<"science">
const state = await start(options)
if (state.phase === "fatal") {
if (errors.is(state.error, ErrUnsupportedPci)) {
renderRendererCapabilityError()
}
}
Security Model
The browser may hold:
publishable key
learner access token
The publishable key identifies the Primer frontend. It is not learner auth.
The access token authenticates the learner. Primer verifies it before producing learning state.
PCI support is a renderer capability declaration (PCI ids only). It is not negotiated implicitly. If a host cannot render a required PCI, it must not claim that PCI in supportedPcis.
Primer does not need these as public SDK inputs:
learner email
verified email
frontend secret key
student id
grade level
Integration Checklist
- Import
startandPrimerOptionsfrom@superbuilders/primer-tives/client/start. - Import shared renderer contracts from leaf paths under
@superbuilders/primer-tives/contracts/. - Define options with
satisfies PrimerOptionsWithManagedAuth<...>orsatisfies PrimerOptionsWithAccessToken<...>so subject and PCI requirements stay visible at the declaration site. - Pass
publishableKeyandloggertostart. - Either pass
accessTokenor handle managed-auth states. - Choose a public
subject. - Declare every required PCI id in
supportedPciswhen the subject scope requires it. - Await
startand render by switching onstate.phase. - If
state.phase === "sign-in-required"or"sign-in-failed", render sign-in UI and bindstate.login()directly to the click or tap handler. - Treat
FrontierStateas the central routing state: choose one route (the choice is the frontend's), then callstate.enter(route)synchronously. Neverawait state.enter(route). - For interaction states, render by switching on
state.kind, renderlessonmetadata as needed, and handle recoverablestate.revisionwith preserved input. - Use only the transition methods exposed by the current state.
- Handle
ErroredStatethroughretriable; callretry()only whenretriableistrue. - Handle
FatalStateas terminal for the current state object. - Never serialize
PrimerState. - Start a new state with
startafter reload, remount, account switch, or subject switch.
What This SDK Does Not Expose
The current SDK intentionally does not expose:
| Not exposed | Use instead |
|---|---|
| package-root exports | explicit public subpaths |
| backend-only SDK surface | browser/client semantic SDK only |
| separate auth API object | hosted-auth state variants with login() only on retryable sign-in states |
| hosted-auth popup configuration | fixed popup defaults and current page redirect URI |
| client wrapper object | start overloads returning live state |
snapshot() | live PrimerState only |
| serializable state | start a new state with start |
| implicit PCI negotiation | explicit supportedPcis |
| product mode option | one unified frontier learning surface |
renderer function registry on start() | declare supportedPcis ids; render in host UI |
| server-side route selection | frontend route choice via FrontierState.routes |
Final Invariants
start is the public lifecycle entrypoint
start returns AccessTokenStartState or ManagedStartState based on accessToken presence
accessToken present is used for learner runtime auth
accessToken present cannot produce hosted-auth states
accessToken absent may produce SignInRequiredState
SignInRequiredState.login and SignInFailedState.login are hosted-auth user-gesture transitions
AuthUnavailableState has no login operation
AuthConfigInvalidState has no login operation
subject is required and must be a concrete public subject
subject determines required renderer PCI capabilities
supportedPcis declares renderer PCI capabilities on the wire
start never invokes host renderer code
there is no mode selection; one unified learning surface
the frontier is the central runtime state
frontier routes are equally valid; the frontend chooses the route
frontier entry is synchronous and local; never await state.enter(route)
terminal actions and feedback advance resolve a fresh frontier or completion
PrimerState is the live learning state machine
only valid state variants expose learning transitions
standard interaction submissions are validated before runtime submission
fatal state is terminal for the current state object
live state is not serializable
Keep these concepts separate: publishable key is not learner auth; access token is not content authorization; subject selects scope but does not prove renderer capability; PCI support is explicit; PrimerState is live behavior, not data.