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 10.0.0 (SDK_MAJOR 10). The host declares only supportedPcis; the course-grade a learner runs is owned by the Primer frontend identified by publishableKey and resolved server-side, so PrimerOptions carries no subject, courseId, or gradeLevel, and the advance request body is exactly { supportedPcis, intent }. supportedPcis: readonly PciId[] is the only host-declared capability input.

Every graded response carries a 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, and advance() executes exactly one real continue to resolve it.

Hosted Primer gates the SDK major and rejects a mismatched major with sdk_upgrade_required; first-party renderer deployments ship the server and SDK from the same monorepo build, so the X-Primer-SDK-Version header always agrees with the server's SDK_MAJOR.

Entrypoints

There is no package-root export. The public surface is five semantic entrypoints, each a barrel that owns one concern. Import from the entrypoint that owns the surface you need.

EntrypointOwns
@superbuilders/primer-tives/clientstart, the option types (PrimerOptions, PrimerOptionsWithAccessToken, PrimerOptionsWithManagedAuth, StartOptions), the origin helpers (primerOrigin, primerAuthOrigin), and the exhaustive state matchers (matchPrimerState, matchInteraction, matchFeedback, matchErrorState)
@superbuilders/primer-tives/typesPrimerState and every state interface (SignInRequiredState, FrontierState, ObservationState, InteractionState, FeedbackState, CompletedState, FatalState, …), plus the optional host-renderer PCI prop types (PciRenderProps, PciPendingRenderProps, PciSubmittedRenderProps) — none of which are start inputs
@superbuilders/primer-tives/contractsEvery shared data and validation contract: content (ContentBlock, ContentInline, plain-text helpers), renderer types (RendererInteraction, RendererStimulus, RendererChoice, RendererSubmission, MatchPair), calibration, the PCI registry (PciId, PciProps, PciValue, PCI_IDS, PCI_REGISTRY, isPciId) and PCI schemas, reviews (InteractionReview), LessonStage, GradeLevel (standalone display vocabulary — not a start input or wire field), the advance wire types (AdvanceRequest, AdvanceResult, WireFrame, AdvanceErrorCode, …), and submission validation (validateSubmission, schemas, per-kind validators)
@superbuilders/primer-tives/errorsEvery SDK error sentinel (ErrMalformedAccessToken, ErrAuthCancelled, ErrUnsupportedPci, …) and ADVANCE_WIRE_ERRORS, the wire-error-code → sentinel/state mapping
@superbuilders/primer-tives/versionSDK_VERSION, SDK_MAJOR

The PrimerLogger type is pino's Logger; import it directly from pino (import type { Logger as PrimerLogger } from "pino"). There is no subject or subject-pcis entrypoint — the content scope is owned by the frontend keyed by your publishableKey and resolved server-side.

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 is exactly:
                          { supportedPcis, intent }
┌──────────────────────────────────────────────────────────────────────┐
│  Layer 2 — Primer server                                             │
│  derives the learner's course-grade from the frontend (publishableKey) │
│  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. start() accepts supportedPcis: readonly PciId[] — a list of ids, never components. You render every PCI yourself in your own UI layer when the state machine reaches PciInteractionState.

Quick Start

A host declares which Portable Custom Interactions (PCIs) it can render in supportedPcis. This is wire capability negotiation only — the Primer server uses the list to avoid serving a frame whose interaction the host cannot paint, and you still render the PCI in your own UI layer when the state machine reaches PciInteractionState. The course-grade a learner runs is not a host input: it is owned by the Primer frontend identified by your publishableKey and resolved server-side, so there is no subject, courseId, or gradeLevel to pass.

If the frontend's assigned course can emit the fraction-input PCI, declare it:

import { start } from "@superbuilders/primer-tives/client"
import type {
	PrimerOptionsWithAccessToken,
	PrimerOptionsWithManagedAuth
} from "@superbuilders/primer-tives/client"
import { logger } from "@/logger"

const options = {
	publishableKey: "pk_...",
	supportedPcis: ["urn:primer:pci:fraction-input"],
	logger
} satisfies PrimerOptionsWithManagedAuth<"urn:primer:pci:fraction-input">

let state = await start(options)

Primer exposes one unified learning surface. There is no product-mode option and no content-scope option: every learner moves through the same frontier-driven curriculum state machine for the course-grade their frontend is assigned, and the server owns all progression policy.

If your frontend's course needs no Portable Custom Interactions, declare an empty capability set. supportedPcis is required, but an empty array is valid for a host that renders only the standard interactions:

const options = {
	publishableKey: "pk_...",
	supportedPcis: [],
	logger
} satisfies PrimerOptionsWithManagedAuth

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,
	supportedPcis: ["urn:primer:pci:fraction-input"],
	logger
} satisfies PrimerOptionsWithAccessToken<"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/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().

Whether a frontend's course can emit a required PCI is a server-side property of the assigned course-grade, not something the host chooses. Declare every PCI id your renderer supports and let the server decide what is routable for that frontend; the same empty-capability shape is valid for a host that renders only the standard interactions:

const options = {
	publishableKey: "pk_...",
	supportedPcis: [],
	logger
} satisfies PrimerOptionsWithManagedAuth

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 Pcis extends PciId = PciId>(
	options: PrimerOptionsWithAccessToken<Pcis>
): Promise<AccessTokenStartState<Pcis>>

function start<const Pcis extends PciId = PciId>(
	options: PrimerOptionsWithManagedAuth<Pcis>
): Promise<ManagedStartState<Pcis>>

start is generic over a single type parameter Pcis extends PciId — the union of PCI ids your host declared in supportedPcis. The const modifier infers that union from the literal array you pass, so the rest of the state machine (PciInteractionState, submissions, reviews) is narrowed to exactly the PCIs you support. There is no Subject type parameter and no content-scope parameter: the course-grade is owned by the frontend keyed by publishableKey and resolved server-side. AccessTokenStartState<Pcis> is PrimerState<Pcis, "access-token"> and ManagedStartState<Pcis> is PrimerState<Pcis, "managed">; the access-token overload can never resolve to a sign-in state, while the managed overload can.

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 } from "@superbuilders/primer-tives/client"
import type { PrimerOptionsWithAccessToken } from "@superbuilders/primer-tives/client"
import { ErrAuthUnavailable, ErrMalformedAccessToken } from "@superbuilders/primer-tives/errors"

const options = {
	publishableKey,
	accessToken,
	supportedPcis: ["urn:primer:pci:fraction-input"],
	logger
} satisfies PrimerOptionsWithAccessToken<"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<Pcis extends PciId = PciId> = {
	readonly publishableKey: string
	readonly supportedPcis: readonly Pcis[]
	readonly origin?: string
	readonly authOrigin?: string | undefined
	readonly fetch?: typeof globalThis.fetch
	readonly abort?: AbortController
	readonly logger: PrimerLogger
}

type PrimerOptionsWithAccessToken<Pcis extends PciId = PciId> = PrimerOptions<Pcis> & {
	readonly accessToken: string
}

type PrimerOptionsWithManagedAuth<Pcis extends PciId = PciId> = PrimerOptions<Pcis> & {
	readonly accessToken?: undefined
}

PrimerOptions is generic over a single parameter Pcis extends PciId — the union of PCI ids the host can render. There is no Subject type parameter, no subject field, and no courseId/gradeLevel field; the course-grade is owned by the frontend keyed by publishableKey and resolved server-side.

FieldRequiredMeaning
publishableKeyYesPublic key identifying the Primer frontend your runtime belongs to. The frontend's assigned course-grade is derived from this key server-side.
supportedPcisYesRenderer PCI capability declaration (ids only), readonly Pcis[]. Always required; pass [] for a host that renders only the standard interactions. Sent on every advance request; not a UI registry and never invoked.
originNoPrimer origin. Defaults to https://primerlearn.dev.
authOriginNoHosted-auth origin override. Defaults to the resolved origin.
accessTokenMode-dependentLearner access token. When present, start uses access-token mode. When absent, start uses managed hosted-auth mode.
fetchNoFetch override for tests, instrumentation, or host runtime integration.
abortNoAbort controller for SDK runtime work.
loggerYesStructured logger implementing debug, info, warn, and error. The type is pino's Logger (re-exported as PrimerLogger).

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.

supportedPcis must be a concrete literal array at the options declaration site so the const type parameter on start can infer the exact PCI union. Construct options with satisfies PrimerOptionsWithManagedAuth<"urn:primer:pci:fraction-input">, satisfies PrimerOptionsWithAccessToken<"urn:primer:pci:fraction-input">, or — for a host that renders only the standard interactions — the default satisfies PrimerOptionsWithManagedAuth with supportedPcis: []. Do not widen supportedPcis to a broad PciId[] if you want the downstream PciInteractionState, submission, and review types narrowed to exactly the PCIs you support.

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)
}

PCI Capability Contract

supportedPcis is the host's declaration of which Portable Custom Interactions its renderer can paint. It is the only capability input to start — there is no subject, no content-scope option, and no per-call course selection. The course-grade a learner runs is owned by the Primer frontend identified by publishableKey and resolved server-side; the host never names it.

PciId is the union of every registered PCI id, derived from the PciRegistry:

import { PCI_IDS, isPciId, type PciId } from "@superbuilders/primer-tives/contracts"

const ids = PCI_IDS                       // readonly ["urn:primer:pci:fraction-input"]
type RuntimePciId = PciId                 // "urn:primer:pci:fraction-input"

Currently exactly one PCI is registered:

PCI idHost must render
"urn:primer:pci:fraction-input"a fraction input widget (see Fraction Input PCI)
any future registered idthe corresponding widget

The contract is a capability negotiation, not a content request:

the host renders exactly the PCIs it lists in supportedPcis
the server serves a frontend's course only the frames the host can render

Order does not matter and an empty list is valid. supportedPcis is a capability declaration for wire negotiation, not a content request and not a component registry — the SDK never calls host renderer code. The const Pcis type parameter on start infers the exact union from your literal array, so the rest of the state machine is typed to precisely the PCIs you declared.

A host that renders only the standard interactions passes an empty array:

const options = {
	publishableKey,
	supportedPcis: [],
	logger
} satisfies PrimerOptionsWithManagedAuth

await start(options)

A host that can render the fraction-input PCI declares it; the resulting state machine is narrowed to that id:

const options = {
	publishableKey,
	supportedPcis: ["urn:primer:pci:fraction-input"],
	logger
} satisfies PrimerOptionsWithManagedAuth<"urn:primer:pci:fraction-input">

await start(options)

The server uses the declared supportedPcis list to avoid serving the frontend's course frames whose portable custom interactions the host cannot render: a frontier route whose frame needs an undeclared PCI is filtered out before it reaches the SDK. The SDK keeps ErrUnsupportedPci as an invariant guard — if the server ever returns an undeclared PCI anyway, the affected route resolves to a FatalState with code "unsupported-pci".

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"
import type { PrimerState } from "@superbuilders/primer-tives/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/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 frontend reassignment.

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 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"

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 the @superbuilders/primer-tives/contracts entrypoint.

import { blocksToPlainText, inlinesToPlainText } from "@superbuilders/primer-tives/contracts"
import { LESSON_STAGES, isLessonStage } from "@superbuilders/primer-tives/contracts"
import { isPciId, PCI_IDS } from "@superbuilders/primer-tives/contracts"
import {
	ChoiceSubmissionSchema,
	ExtendedTextSubmissionSchema,
	FractionInputPciSubmissionSchema,
	MatchPairSchema,
	MatchSubmissionSchema,
	OrderSubmissionSchema,
	RendererSubmissionSchema,
	TextEntrySubmissionSchema,
	correlateSubmission,
	submissionValidationMessage,
	validateSubmission
} from "@superbuilders/primer-tives/contracts"

import type { ContentBlock, ContentInline, ContentSpan } from "@superbuilders/primer-tives/contracts"
import type { LessonStage } from "@superbuilders/primer-tives/contracts"
import type {
	FractionInputForm,
	FractionInputProps,
	FractionInputSubmission,
	PciId,
	PciProps,
	PciRegistry,
	PciUrn,
	PciValue
} from "@superbuilders/primer-tives/contracts"
import type { InteractionReview } from "@superbuilders/primer-tives/contracts"
import type {
	InteractionFor,
	InteractionKind,
	MatchPair,
	PciInteraction,
	PciSubmission,
	RendererChoice,
	RendererInteraction,
	RendererStimulus,
	RendererSubmission,
	ReviewFor,
	SubmissionFor,
	StandardRendererInteraction
} from "@superbuilders/primer-tives/contracts"

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 RendererStimulus {
	kind: "image"
	alt: ContentInline[]
	src: string
	mime: "image/png" | "image/jpeg" | "image/webp"
}

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"

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"
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 wiring PCI rendering into a host.

Step 1 — Declare capabilities at start (wire only)

When your renderer can paint a PCI, list it in supportedPcis. This tells the Primer server which PCI ids your host can render, so it only routes the frontend's course frames you can display. The SDK copies this array onto every advance request body unchanged. The course-grade itself is owned by the frontend keyed by publishableKey and never appears in options.

const options = {
	publishableKey: "pk_...",
	supportedPcis: ["urn:primer:pci:fraction-input"] as const,
	logger
} satisfies PrimerOptionsWithManagedAuth<"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/types for convenience. It is not passed to start().

import type { PciRenderProps } from "@superbuilders/primer-tives/types"
import type { PciValue } from "@superbuilders/primer-tives/contracts"

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"
import type { PciValue } from "@superbuilders/primer-tives/contracts"

const state = await start({
	publishableKey: "pk_...",
	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"
import type { PciRenderProps, PrimerState } from "@superbuilders/primer-tives/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 PCI 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.

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>

PCI Registry Helpers

Use @superbuilders/primer-tives/contracts when renderer tooling needs the same PCI registry the SDK and server share. The registry is the single source of truth for which Portable Custom Interactions exist, their authoring props, and their submission value shapes.

import {
	PCI_IDS,
	PCI_REGISTRY,
	isPciId
} from "@superbuilders/primer-tives/contracts"
import type {
	PciId,
	PciProps,
	PciValue,
	PciRegistry
} from "@superbuilders/primer-tives/contracts"

const everyPciId = PCI_IDS                                           // readonly ["urn:primer:pci:fraction-input"]
const fractionEntry = PCI_REGISTRY["urn:primer:pci:fraction-input"] // { propsSchema, props, valueSchema, value }
const ok = isPciId("urn:primer:pci:fraction-input")                 // true

PCI_IDS is the readonly list of registered ids; PciId is their union. PciProps<K> and PciValue<K> give the authoring props and submission value types for a given id K (for example, PciProps<"urn:primer:pci:fraction-input"> is FractionInputProps). PCI_REGISTRY[id] carries both the Draft-07 JSON schema and the compiled @superbuilders/validate schema for the props and for the submission value — exactly what submit(value) validates against locally before it reaches the wire. isPciId(value) narrows an arbitrary string to a registered PciId. There is no subject-to-PCI mapping in 10.0.0: the server decides which PCIs a frontend's assigned course can emit and routes only the renderable frames.

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 {
	ADVANCE_WIRE_ERRORS,
	ErrAuthCallbackInvalid,
	ErrAuthCancelled,
	ErrAuthConfigInvalid,
	ErrAuthPopupBlocked,
	ErrAuthStateMismatch,
	ErrAuthUnavailable,
	ErrBadRequest,
	ErrConflict,
	ErrContentUngradeable,
	ErrEventKindMismatch,
	ErrForbidden,
	ErrFrameAlreadyCompleted,
	ErrInvalidAccessToken,
	ErrInvalidPublishableKey,
	ErrInvalidSubmission,
	ErrJsonParse,
	ErrMalformedAccessToken,
	ErrNetwork,
	ErrNoRoutableContent,
	ErrNotEntitled,
	ErrNotFound,
	ErrNotSerializable,
	ErrOfferNotFound,
	ErrOfferSuperseded,
	ErrPlacementPending,
	ErrPlacementRequired,
	ErrRateLimited,
	ErrRoutingMisconfigured,
	ErrSdkUpgradeRequired,
	ErrServerError,
	ErrServiceUnavailable,
	ErrTimeout,
	ErrTokenExpired,
	ErrUnsupportedPci,
	ErrWireContractViolation
} from "@superbuilders/primer-tives/errors"

ADVANCE_WIRE_ERRORS is the wire-error-code → { http, sentinel, state, code } mapping; every sentinel above is one of its targets or a client-side runtime sentinel.

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.
ErrInvalidPublishableKeyFatalStatenoPublishable key is missing or not valid for a Primer frontend.
ErrRoutingMisconfiguredFatalStatenoServer routing configuration is invalid for the learner scope.
ErrContentUngradeableFatalStatenoPrimer reached content it could not grade.
ErrWireContractViolationFatalStatenoA runtime response violated the SDK wire contract.
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, ErrNoRoutableContent)) {
		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 { Logger as PrimerLogger } from "pino"

The logger you pass to start is a 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"

const options = {
	publishableKey,
	supportedPcis: ["urn:primer:pci:fraction-input"],
	logger
} satisfies PrimerOptionsWithManagedAuth<"urn:primer:pci:fraction-input">

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
no routable content for the learner scopetransition resolves to FatalState with ErrNoRoutableContent
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"
import { ErrUnsupportedPci } from "@superbuilders/primer-tives/errors"

declare const fetchMock: typeof globalThis.fetch

const options = {
	publishableKey: "pk_test",
	accessToken: "eyJ.test.token",
	supportedPcis: [],
	fetch: fetchMock,
	logger
} satisfies PrimerOptionsWithAccessToken<never>

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.
  2. Import shared renderer contracts from the @superbuilders/primer-tives/contracts entrypoint.
  3. Define options with satisfies PrimerOptionsWithManagedAuth<...> or satisfies PrimerOptionsWithAccessToken<...> so 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. Declare every required PCI id in supportedPcis.
  7. Await start and render by switching on state.phase.
  8. 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.
  9. 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).
  10. For interaction states, render by switching on state.kind, render lesson metadata as needed, and handle recoverable state.revision with preserved input.
  11. Use only the transition methods exposed by the current state.
  12. Handle ErroredState through retriable; call retry() only when retriable is true.
  13. Handle FatalState as terminal for the current state object.
  14. Never serialize PrimerState.
  15. Start a new state with start after reload, remount, account switch, or frontend reassignment.

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
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; PCI support is explicit; PrimerState is live behavior, not data.