Primer Tives SDK

@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.

SubpathOwns
@superbuilders/primer-tives/client/startstart, PrimerOptions, auth-specific start option types
@superbuilders/primer-tives/client/typesPrimerState, all state interfaces, optional host-renderer PCI prop types (PciRenderProps, not start inputs)
@superbuilders/primer-tives/client/matchersmatchPrimerState, matchInteraction, matchFeedback, matchErrorState
@superbuilders/primer-tives/contracts/contentContent blocks and plain-text helpers
@superbuilders/primer-tives/contracts/typesRenderer interaction, stimulus, and submission types
@superbuilders/primer-tives/contracts/validationSubmission schemas and validation helpers
@superbuilders/primer-tives/contracts/pciPCI ids, props, values, registry
@superbuilders/primer-tives/contracts/reviewInteraction review types
@superbuilders/primer-tives/contracts/lesson-stageLessonStage, LESSON_STAGES, isLessonStage
@superbuilders/primer-tives/contracts/advance-wireAdvance wire request/response types
@superbuilders/primer-tives/errorsEvery SDK error sentinel
@superbuilders/primer-tives/loggerPrimerLogger interface
@superbuilders/primer-tives/subjectSubject, SUBJECTS
@superbuilders/primer-tives/subject-pcisSubject-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

ConcernOwnerSDK surface
Auth and session token cacheprimer-tivesstart, SignInRequiredState.login(), …
Frontier routing and frame transitionsprimer-tives + serverFrontierState.enter, advance, submit, …
PCI capability negotiation on the wireprimer-tives + serversupportedPcis on PrimerOptions
PCI widget UI (fraction inputs, etc.)host rendererPciInteractionState, optional PciRenderProps
Reference React implementation@superbuilders/primer-renderer (separate package)not required for SDK integration

Invariants integrators should memorize

  1. start() never invokes renderer functions. There is no renderer registry on the state machine. supportedPcis is a readonly array of PCI id strings sent on the wire.
  2. PCI rendering is orthogonal to the state machine. When state.kind === "portable-custom", the SDK gives you pciId, properties, and submit(value). How you paint that on screen is entirely host code.
  3. PciRenderProps is an optional host-renderer contract, exported for component authors. Import it when typing your fraction-input component. Do not pass it to start().
  4. The SDK has no React dependency. package.json depends on errors, validate, and pino only. Examples in this README may show React for familiarity; the same start() 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 start options (you do not pass functions)
  • negotiate PCI support implicitly — you declare supportedPcis explicitly
  • 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:

ResultMeaning
SignInRequiredStateLearner sign-in is needed before runtime learning can begin. Managed-auth mode only.
SignInFailedStateHosted sign-in failed but can be retried. Managed-auth mode only.
AuthUnavailableStateBrowser-hosted auth cannot run in the current runtime. Managed-auth mode only.
AuthConfigInvalidStateHosted-auth configuration is invalid and cannot be retried. Managed-auth mode only.
FrontierStateA frontier of next-lesson routes is ready. The renderer chooses a route and enters it.
ObservationState, InteractionState, or FeedbackStateA learning state is ready to render.
CompletedStateThe runtime scope is already complete.
ErroredStateStartup or runtime communication failed but may be retriable.
FatalStateStartup 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
}
FieldRequiredMeaning
publishableKeyYesPublic key identifying the Primer frontend your runtime belongs to.
originNoPrimer origin. Defaults to https://primerlearn.dev.
authOriginNoHosted-auth origin override. Defaults to origin.
accessTokenMode-dependentLearner access token. When present, start uses access-token mode. When absent, start uses managed hosted-auth mode.
subjectYesPublic content scope: "math" or "science".
supportedPcisSubject-dependentRenderer PCI capability declaration (ids only). Required when the chosen scope can emit required PCIs. Sent on every advance request; not a UI registry.
fetchNoFetch override for tests, instrumentation, or host runtime integration.
abortNoAbort controller for SDK runtime work.
loggerYesStructured 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.

ShapeSemantics
accessToken presentstart validates the token shape locally and returns AccessTokenStartState. This mode cannot return sign-in states.
accessToken absentstart 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:

SituationSDK behavior
Valid cached token existsstart uses it and enters the runtime without opening sign-in.
No cached token existsstart returns SignInRequiredState.
Cached token is malformed or expiredSDK clears the key and returns SignInRequiredState.
Hosted sign-in succeedsSDK validates the returned token, stores it at the documented key, then starts the runtime.
Runtime rejects a managed cached token as expired or invalidSDK 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:

SentinelMeaning
ErrAuthUnavailableSDK-managed auth requires browser functionality that is unavailable in the current runtime.
ErrAuthConfigInvalidSDK-managed auth was given invalid public configuration.
ErrAuthCallbackInvalidThe auth result could not be accepted as a successful learner auth result.
ErrAuthStateMismatchThe auth result did not match the auth attempt that initiated it.
ErrAuthPopupBlockedThe browser blocked the learner auth window.
ErrAuthCancelledThe learner auth interaction was closed or exceeded its allowed time.
ErrMalformedAccessTokenThe 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:

SubjectRequired 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 stateValid operationNext result
SignInRequiredStatelogin()Promise<ManagedStartState>
SignInFailedStatelogin()Promise<ManagedStartState>
SessionExpiredStatelogin()Promise<ManagedStartState>
AuthUnavailableStatenoneterminal for hosted auth in this runtime
AuthConfigInvalidStatenoneterminal for the current configuration
FrontierStateenter(route)EnterNext (synchronous)
ObservationStateadvance()Promise<ObservationAdvanceNext>
ChoiceStatesubmitChoice(selectedKeys) or timeout()Promise<SubmitNext> / Promise<TimeoutNext>
TextEntryStatesubmitText(value) or timeout()Promise<SubmitNext> / Promise<TimeoutNext>
ExtendedTextSingleStatesubmitText(value) or timeout()Promise<SubmitNext> / Promise<TimeoutNext>
ExtendedTextMultipleStatesubmitTexts(values) or timeout()Promise<SubmitNext> / Promise<TimeoutNext>
OrderStatesubmitOrder(orderedKeys) or timeout()Promise<SubmitNext> / Promise<TimeoutNext>
MatchStatesubmitMatch(pairs) or timeout()Promise<SubmitNext> / Promise<TimeoutNext>
PciInteractionStatesubmit(value) or timeout()Promise<SubmitNext> / Promise<TimeoutNext>
FeedbackStateadvance()Promise<FeedbackAdvanceNext>
CompletedStatenoneterminal
RetriableErroredStateretry()Promise<RetryNext>
NonRetriableErroredStatenoneterminal for the failed intent
FatalStatenoneterminal

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:

FieldMeaning
phase: "interaction"State-machine discriminator.
kindRenderer-facing interaction kind.
lessonPublic lesson metadata for the frame.
bodyFrame content.
stimulusOptional frame stimulus.
interactionFull interaction contract object.
feedbackRecoverable feedback for the current attempt, or null on a fresh frame.
rejectionLocal/server submission validation feedback for the current attempt, or null.
submit methodKind-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:

SituationSDK behavior
Same submit payload while submit is pendingReturns the same pending result.
Different submit payload while submit is pendingReturns the already in-flight submit result.
Submit while timeout is pendingReturns the already in-flight timeout result.
Timeout while submit is pendingReturns the already in-flight submit result.
Repeated timeout while timeout is pendingReturns 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:

RequirementError if violated
At least minChoices identifiersErrInvalidSubmission
At most maxChoices identifiersErrInvalidSubmission
Every identifier exists in optionsErrInvalidSubmission
No duplicate identifiersErrInvalidSubmission

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:

RequirementError if violated
At least minStrings valuesErrInvalidSubmission
At most maxStrings valuesErrInvalidSubmission
No duplicate valuesErrInvalidSubmission

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:

RequirementError if violated
At least minChoices identifiersErrInvalidSubmission
At most maxChoices identifiersErrInvalidSubmission
Every identifier exists in choicesErrInvalidSubmission
No duplicate identifiersErrInvalidSubmission

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:

RequirementError if violated
At least minAssociations pairsErrInvalidSubmission
At most maxAssociations pairsErrInvalidSubmission
Every source identifier exists in sourceChoicesErrInvalidSubmission
Every target identifier exists in targetChoicesErrInvalidSubmission
No duplicate source-target pairsErrInvalidSubmission
No duplicate source identifiersErrInvalidSubmission
No duplicate target identifiersErrInvalidSubmission

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:

SentinelMeaning
ErrBadRequestPrimer rejected the runtime request as invalid for the SDK contract.
ErrWireContractViolationA successful response violated the wire contract (version skew or server bug).
ErrInvalidPublishableKeyThe publishable key is missing or unknown — integrator configuration; the token is NOT cleared.
ErrInvalidAccessTokenThe learner token was rejected.
ErrTokenExpiredThe learner token expired.
ErrNoRoutableContentThe catalog has no routable bootstrap content for the learner's scope.
ErrSdkUpgradeRequiredThe installed SDK is too old for the current Primer runtime.
ErrUnsupportedPciPrimer 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:

TypeKey fields
choiceprompt, options, minChoices, maxChoices
text-entryprompt, base, expectedLength, patternMask, placeholderText
extended-text singleprompt, format, expectedLines, expectedLength, patternMask, placeholderText
extended-text multiplesingle fields plus minStrings, maxStrings
orderprompt, choices, minChoices, maxChoices
matchprompt, sourceChoices, targetChoices, minAssociations, maxAssociations
portable-customprompt, 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:

SchemaValidates
MatchPairSchema{ source, target } match pair shape
ChoiceSubmissionSchemachoice submission shape
TextEntrySubmissionSchematext-entry submission shape
ExtendedTextSubmissionSchemaextended-text submission shape
OrderSubmissionSchemaorder submission shape
MatchSubmissionSchemamatch submission shape
FractionInputPciSubmissionSchemafraction-input PCI submission shape
RendererSubmissionSchemaunion 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:

InteractionChecks
choicemin/max selection count, duplicate identifiers, unknown identifiers
text-entrytyped shape
extended-text singleexactly one value
extended-text multiplemin/max value count, duplicate values
ordermin/max selection count, duplicate identifiers, unknown identifiers
matchmin/max association count, duplicate pairs, duplicate sources, duplicate targets, unknown sources, unknown targets
portable-customPCI 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:

TypeData
choicecorrectKeys: string[]
text-entry`correctValue: ReviewScalarValue
extended-textcorrectValues: ReviewScalarValue[]
ordercorrectOrder: string[]
matchcorrectPairs: MatchPair[]
portable-custompciId, 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:

FieldRole
state.pciIdWhich PCI contract applies
state.propertiesAuthoring-time PCI configuration (PciProps<K>)
state.interactionFull interaction contract including prompt and calibration
state.revisionRecoverable 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

LayerBehavior
ServerDrops frontier routes whose frame needs a PCI not listed in supportedPcis
SDK sessionFatals 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:

  1. start({ supportedPcis: [...] }) with literal subject typing
  2. Template or component switch on state.pciId
  3. Map state.properties into your framework's binding model
  4. 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 TypeScriptDelete them; pass supportedPcis ids
pciRenderers pointing at real React componentsKeep 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-rendererIt 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.

SentinelMeaningTypical handling
ErrAuthUnavailableSDK-managed auth cannot run in the current host environment.Render unsupported-runtime or externally managed sign-in UI.
ErrAuthConfigInvalidPublic auth configuration is invalid.Log and treat as integration error.
ErrAuthCallbackInvalidLearner auth did not complete with an acceptable result.Offer sign-in retry; log if unexpected.
ErrAuthStateMismatchAuth result does not match the initiated auth attempt.Offer sign-in retry.
ErrAuthPopupBlockedBrowser blocked learner auth UI.Render sign-in instructions and retry from a direct user gesture.
ErrAuthCancelledLearner auth was closed or timed out.Offer retry.
ErrMalformedAccessTokenProvided 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.

SentinelStateRetriableMeaning
ErrNetworkErroredStateyesRuntime communication failed before a usable Primer result existed.
ErrTimeoutErroredStateyesRuntime work was aborted or exceeded the host's allowed time.
ErrServerErrorErroredStateyesPrimer could not produce a normal runtime result.
ErrServiceUnavailableErroredStateyesPrimer is temporarily unavailable.
ErrRateLimitedErroredStateyesRuntime work is temporarily rate limited.
ErrConflictErroredStateyesThe learner intent conflicts with another in-flight or current runtime action.
ErrJsonParseErroredStateyesRuntime data could not be interpreted as the SDK contract.
ErrInvalidSubmissionInteractionState.rejectionnoSubmitted value was invalid for the active interaction; the returned interaction remains live.
ErrBadRequestFatalStatenoRuntime request violates the SDK contract.
ErrCurriculumInstructionalUndeliverableFatalStatenoCurriculum routing reached an instructional with no deliverable frame content.
ErrNoActiveFrameFatalStatenoPrimer had no open frame for the submitted intent.
ErrFrameAlreadyAnsweredFatalStatenoThe targeted frame was already answered.
ErrSessionStateConflictFatalStatenoThe learner intent does not match the active frame type.
ErrNoRoutableContentFatalStatenoCatalog bootstrap has no routable content for the learner scope.
ErrInvalidAccessTokenFatalStatenoLearner token is invalid.
ErrTokenExpiredFatalStatenoLearner token expired.
ErrForbiddenFatalStatenoLearner cannot continue in this runtime scope.
ErrNotFoundFatalStatenoRuntime scope or state is unavailable.
ErrSdkUpgradeRequiredFatalStatenoInstalled SDK version is too old for Primer.
ErrUnsupportedPciFatalStatenoRenderer did not declare support for the presented PCI.
ErrNotSerializablethrown by toJSONnoA 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:

ScenarioAssert
auth is neededmanaged-auth start resolves to SignInRequiredState
auth login failslogin() resolves to SignInFailedState with the expected auth sentinel
auth cannot runlogin() resolves to AuthUnavailableState
auth config is invalidlogin() resolves to AuthConfigInvalidState
runtime scope is ready for an active learnerstart resolves to FrontierState with at least one route
a frontier route is enteredstate.enter(route) synchronously returns ObservationState or InteractionState
first runtime work fails recoverablystart resolves to ErroredState with retriable: true
first runtime work fails terminallystart resolves to FatalState
unsupported PCI is presentedstart resolves to FatalState with ErrUnsupportedPci
curriculum instructional has no deliverable contenttransition resolves to FatalState with ErrCurriculumInstructionalUndeliverable
standard submission is invalidsubmit method resolves to InteractionState with rejection populated
concurrent submit/timeout conflict occurslater call resolves to the first in-flight transition result
state is serializedserialization 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

  1. Import start and PrimerOptions from @superbuilders/primer-tives/client/start.
  2. Import shared renderer contracts from leaf paths under @superbuilders/primer-tives/contracts/.
  3. Define options with satisfies PrimerOptionsWithManagedAuth<...> or satisfies PrimerOptionsWithAccessToken<...> so subject and PCI requirements stay visible at the declaration site.
  4. Pass publishableKey and logger to start.
  5. Either pass accessToken or handle managed-auth states.
  6. Choose a public subject.
  7. Declare every required PCI id in supportedPcis when the subject scope requires it.
  8. Await start and render by switching on state.phase.
  9. If state.phase === "sign-in-required" or "sign-in-failed", render sign-in UI and bind state.login() directly to the click or tap handler.
  10. Treat FrontierState as the central routing state: choose one route (the choice is the frontend's), then call state.enter(route) synchronously. Never await state.enter(route).
  11. For interaction states, render by switching on state.kind, render lesson metadata as needed, and handle recoverable state.revision with preserved input.
  12. Use only the transition methods exposed by the current state.
  13. Handle ErroredState through retriable; call retry() only when retriable is true.
  14. Handle FatalState as terminal for the current state object.
  15. Never serialize PrimerState.
  16. Start a new state with start after reload, remount, account switch, or subject switch.

What This SDK Does Not Expose

The current SDK intentionally does not expose:

Not exposedUse instead
package-root exportsexplicit public subpaths
backend-only SDK surfacebrowser/client semantic SDK only
separate auth API objecthosted-auth state variants with login() only on retryable sign-in states
hosted-auth popup configurationfixed popup defaults and current page redirect URI
client wrapper objectstart overloads returning live state
snapshot()live PrimerState only
serializable statestart a new state with start
implicit PCI negotiationexplicit supportedPcis
product mode optionone unified frontier learning surface
renderer function registry on start()declare supportedPcis ids; render in host UI
server-side route selectionfrontend 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.