npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@superbuilders/primer-tives

v11.0.0

Published

Readme

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

| Entrypoint | Owns | | --- | --- | | @superbuilders/primer-tives/client | start, the option types (PrimerOptions, PrimerOptionsWithAccessToken, PrimerOptionsWithManagedAuth, StartOptions), the origin helpers (primerOrigin, primerAuthOrigin), and the exhaustive state matchers (matchPrimerState, matchInteraction, matchFeedback, matchErrorState) | | @superbuilders/primer-tives/types | PrimerState 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/contracts | Every 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/errors | Every SDK error sentinel (ErrMalformedAccessToken, ErrAuthCancelled, ErrUnsupportedPci, …) and ADVANCE_WIRE_ERRORS, the wire-error-code → sentinel/state mapping | | @superbuilders/primer-tives/version | SDK_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

| Concern | Owner | SDK surface | | --- | --- | --- | | Auth and session token cache | primer-tives | start, SignInRequiredState.login(), … | | Frontier routing and frame transitions | primer-tives + server | FrontierState.enter, advance, submit, … | | PCI capability negotiation on the wire | primer-tives + server | supportedPcis on PrimerOptions | | PCI widget UI (fraction inputs, etc.) | host renderer | PciInteractionState, optional PciRenderProps | | Reference React implementation | @superbuilders/primer-renderer (separate package) | not required for SDK integration |

Invariants integrators should memorize

  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:

| Result | Meaning | | --- | --- | | SignInRequiredState | Learner sign-in is needed before runtime learning can begin. Managed-auth mode only. | | SignInFailedState | Hosted sign-in failed but can be retried. Managed-auth mode only. | | AuthUnavailableState | Browser-hosted auth cannot run in the current runtime. Managed-auth mode only. | | AuthConfigInvalidState | Hosted-auth configuration is invalid and cannot be retried. Managed-auth mode only. | | FrontierState | A frontier of next-lesson routes is ready. The renderer chooses a route and enters it. | | ObservationState, InteractionState, or FeedbackState | A learning state is ready to render. | | CompletedState | The runtime scope is already complete. | | ErroredState | Startup or runtime communication failed but may be retriable. | | FatalState | Startup or runtime communication failed terminally for this state object. |

Always switch on state.phase. Do not assume the first state is renderable learning content.

Every learning state exposes state.journey, a read-only NON-NULL learner progress object. Auth states (sign-in-required, sign-in-failed, auth-unavailable, auth-config-invalid, not-entitled, placement-required) have no journey field at all; session-expired carries the last-known journey or null; errored/fatal carry an optional last-known journey for display continuity. The journey is pure progress data — course and lesson, each with { done, total } — and carries no mode: journey.lesson === null means the run is complete, anything else is active learning. Runtime identity is resolved by TimeBack auth and server-owned roster identity. Placement is server-owned and grade is not an SDK input, output, state field, export, or error.

interface Journey {
	readonly course: { readonly title: string; readonly progress: JourneyProgress }
	readonly lesson: { readonly title: string; readonly progress: JourneyProgress } | null
}

state.journey cannot be used to select content or change Primer routing. It is display data only.

import * as errors from "@superbuilders/errors"
import { logger } from "@/logger"
import { start } 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.

| Field | Required | Meaning | | --- | --- | --- | | publishableKey | Yes | Public key identifying the Primer frontend your runtime belongs to. The frontend's assigned course-grade is derived from this key server-side. | | supportedPcis | Yes | Renderer 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. | | origin | No | Primer origin. Defaults to https://primerlearn.dev. | | authOrigin | No | Hosted-auth origin override. Defaults to the resolved origin. | | accessToken | Mode-dependent | Learner access token. When present, start uses access-token mode. When absent, start uses managed hosted-auth mode. | | fetch | No | Fetch override for tests, instrumentation, or host runtime integration. | | abort | No | Abort controller for SDK runtime work. | | logger | Yes | Structured logger implementing debug, info, warn, and error. The 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.

| Shape | Semantics | | --- | --- | | accessToken present | start validates the token shape locally and returns AccessTokenStartState. This mode cannot return sign-in states. | | accessToken absent | start uses managed hosted-auth mode. It reads and writes the SDK-managed browser session token cache documented below. |

The public API exposes hosted auth as state behavior. SignInRequiredState.login() and SignInFailedState.login() are the sign-in transitions. AuthUnavailableState and AuthConfigInvalidState expose no login operation.

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:

| Situation | SDK behavior | | --- | --- | | Valid cached token exists | start uses it and enters the runtime without opening sign-in. | | No cached token exists | start returns SignInRequiredState. | | Cached token is malformed or expired | SDK clears the key and returns SignInRequiredState. | | Hosted sign-in succeeds | SDK validates the returned token, stores it at the documented key, then starts the runtime. | | Runtime rejects a managed cached token as expired or invalid | SDK clears the key and returns SessionExpiredState so the app can ask the learner to sign in again with progress context. |

Access-token mode bypasses this cache entirely. If accessToken is present in start options, PrimerTives does not read, write, or clear sessionStorage.

The cached value is a bearer credential. Browser-only consumers get reload convenience from this default, but any script that can read the page can read the token. Host applications must maintain normal XSS protections and should use access-token mode if they need to own token storage themselves.

Auth Semantics

An access token is expected to be JWS-shaped: it starts with eyJ and contains exactly two dots. If a provided token does not match that shape, start returns FatalState with ErrMalformedAccessToken.

SDK-managed auth may require browser capabilities and learner interaction. Auth failures are represented by explicit auth states:

| Sentinel | Meaning | | --- | --- | | ErrAuthUnavailable | SDK-managed auth requires browser functionality that is unavailable in the current runtime. | | ErrAuthConfigInvalid | SDK-managed auth was given invalid public configuration. | | ErrAuthCallbackInvalid | The auth result could not be accepted as a successful learner auth result. | | ErrAuthStateMismatch | The auth result did not match the auth attempt that initiated it. | | ErrAuthPopupBlocked | The browser blocked the learner auth window. | | ErrAuthCancelled | The learner auth interaction was closed or exceeded its allowed time. | | ErrMalformedAccessToken | The resolved token was not shaped like a learner access token. |

Applications should handle user-actionable auth failures directly and log unexpected failures before propagating them.

import * as errors from "@superbuilders/errors"
import { logger } from "@/logger"
import {
	ErrAuthCancelled,
	ErrAuthPopupBlocked,
	ErrAuthUnavailable
} from "@superbuilders/primer-tives/errors"

let state = await start(options)
if (state.phase === "sign-in-failed") {
	if (errors.is(state.error, ErrAuthPopupBlocked)) {
		renderPopupInstructions(state)
		return
	}
	if (errors.is(state.error, ErrAuthCancelled)) {
		renderTryAgain(state)
		return
	}
	logger.error({ error: state.error }, "primer auth failed")
	renderSignInButton(state)
	return
}

if (state.phase === "auth-unavailable") {
	renderUnsupportedBrowserMessage(state.error)
	return
}

if (state.phase === "auth-config-invalid") {
	logger.error({ error: state.error }, "primer auth configuration invalid")
	renderIntegrationError(state.error)
	return
}

if (state.phase === "sign-in-required") {
	renderSignInButton(state)
}

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 id | Host must render | | --- | --- | | "urn:primer:pci:fraction-input" | a fraction input widget (see Fraction Input PCI) | | any future registered id | the 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 state | Valid operation | Next result | | --- | --- | --- | | SignInRequiredState | login() | Promise<ManagedStartState> | | SignInFailedState | login() | Promise<ManagedStartState> | | SessionExpiredState | login() | Promise<ManagedStartState> | | AuthUnavailableState | none | terminal for hosted auth in this runtime | | AuthConfigInvalidState | none | terminal for the current configuration | | FrontierState | enter(route) | EnterNext (synchronous) | | ObservationState | advance() | Promise<ObservationAdvanceNext> | | ChoiceState | submitChoice(selectedKeys) or timeout() | Promise<SubmitNext> / Promise<TimeoutNext> | | TextEntryState | submitText(value) or timeout() | Promise<SubmitNext> / Promise<TimeoutNext> | | ExtendedTextSingleState | submitText(value) or timeout() | Promise<SubmitNext> / Promise<TimeoutNext> | | ExtendedTextMultipleState | submitTexts(values) or timeout() | Promise<SubmitNext> / Promise<TimeoutNext> | | OrderState | submitOrder(orderedKeys) or timeout() | Promise<SubmitNext> / Promise<TimeoutNext> | | MatchState | submitMatch(pairs) or timeout() | Promise<SubmitNext> / Promise<TimeoutNext> | | PciInteractionState | submit(value) or timeout() | Promise<SubmitNext> / Promise<TimeoutNext> | | FeedbackState | advance() | Promise<FeedbackAdvanceNext> | | CompletedState | none | terminal | | RetriableErroredState | retry() | Promise<RetryNext> | | NonRetriableErroredState | none | terminal for the failed intent | | FatalState | none | terminal |

PrimerState

type PrimerState<Pcis extends PciId = PciId, M extends AuthMode = "managed"> =
	| SignInRequiredState<Pcis>
	| SignInFailedState<Pcis>
	| SessionExpiredState<Pcis>
	| AuthUnavailableState
	| AuthConfigInvalidState
	| RuntimeState<Pcis, M>
	| CompletedState
	| ErroredState<Pcis, M>
	| FatalState

type RuntimeState<Pcis extends PciId = PciId, M extends AuthMode = "managed"> =
	| FrontierState<Pcis, M>
	| ObservationState<Pcis, M>
	| InteractionState<Pcis, M>
	| FeedbackState<Pcis, M>

PrimerState is live in-memory state. It contains transition closures, pending-operation guards, and retry behavior. Do not serialize it, store it, clone it through JSON, or pass it through host data. Calling JSON.stringify(state) throws ErrNotSerializable.

Start a new state by calling start again after a reload, remount, account switch, or 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:

| Field | Meaning | | --- | --- | | phase: "interaction" | State-machine discriminator. | | kind | Renderer-facing interaction kind. | | lesson | Public lesson metadata for the frame. | | body | Frame content. | | stimulus | Optional frame stimulus. | | interaction | Full interaction contract object. | | feedback | Recoverable feedback for the current attempt, or null on a fresh frame. | | rejection | Local/server submission validation feedback for the current attempt, or null. | | submit method | Kind-specific learner submission operation. | | timeout() | Records that the learner timed out or the host chose to end the attempt without a submission. A successful timeout resolves to timeout feedback before the next frame. |

Submission methods validate standard interaction payloads before runtime submission. Invalid standard submissions resolve back to InteractionState with rejection populated, so state = await state.submit...() is always safe.

Recoverable Feedback

Not every submission is terminal. A submission may resolve back to the same interaction phase with feedback populated (ContentInline[]) so the learner can revise and resubmit.

When a recoverable result arrives, the prior input is preserved in state.revision — uniformly across every interaction kind. Render state.revision.feedback, prefill the input from state.revision.previous, and let the learner submit again. state.revision.revisionsRemaining exposes the revision budget and state.revision.finalAttempt is true when the next submit is terminal. Terminal submissions resolve to FeedbackState instead.

Concurrent interaction operations are guarded:

| Situation | SDK behavior | | --- | --- | | Same submit payload while submit is pending | Returns the same pending result. | | Different submit payload while submit is pending | Returns the already in-flight submit result. | | Submit while timeout is pending | Returns the already in-flight timeout result. | | Timeout while submit is pending | Returns the already in-flight submit result. | | Repeated timeout while timeout is pending | Returns the same pending result. |

Interaction Calibration And Accuracy

Every interaction object carries aggregate calibration and accuracy data:

type InteractionCalibration = {
	readonly p0: number
	readonly p25: number
	readonly p50: number
	readonly p75: number
	readonly p100: number
}

type InteractionAccuracy =
	| { readonly correct: number; readonly total: number }
	| { readonly correct: 0; readonly total: 0 }

Access these fields from the interaction contract object:

state.interaction.calibration
state.interaction.accuracy

interaction.calibration is nullable. If Primer has no terminal samples for an interaction, calibration is null.

if (state.interaction.calibration !== null) {
	const medianMs = state.interaction.calibration.p50
}

interaction.accuracy is never nullable. If Primer has no terminal samples, accuracy is { correct: 0, total: 0 }.

const { correct, total } = state.interaction.accuracy
const observedAccuracy = total === 0 ? undefined : correct / total

Calibration is aggregate historical timing data for this interaction. It measures elapsed time from frame open to the first terminal green/red submitted response. All calibration values are milliseconds. Yellow-path recoverable responses are not terminal and are excluded. Timeouts are excluded. p100 is the literal maximum observed terminal duration.

Accuracy is aggregate correctness over the same terminal submitted response set. correct is the number of terminal responses graded correct. total is the number of terminal responses. Primer exposes counts rather than a ratio so hosts can choose their own smoothing and display rules.

Primer uses UUIDv7 event ids as the timing source for calibration. No timestamp fields are exposed through the SDK.

Observation states do not expose interaction calibration or accuracy because they have no interaction object.

Example game-feedback usage:

if (state.phase === "interaction") {
	const calibration = state.interaction.calibration
	const accuracy = state.interaction.accuracy

	if (calibration !== null) {
		const fastTarget = calibration.p25
		const typicalTarget = calibration.p50
		const slowTarget = calibration.p75
		configureRewardTiming({ fastTarget, typicalTarget, slowTarget })
	}

	const observedAccuracy = accuracy.total === 0 ? undefined : accuracy.correct / accuracy.total
	configureRewardRules({ observedAccuracy })
}

ChoiceState

interface ChoiceState<Pcis extends PciId = PciId> {
	readonly phase: "interaction"
	readonly kind: "choice"
	readonly lesson: LessonMetadata
	readonly body: ContentBlock[]
	readonly stimulus: RendererStimulus | null
	readonly interaction: Extract<StandardRendererInteraction, { type: "choice" }>
	readonly revision: Revision<{ readonly selectedKeys: string[] }> | null
	readonly options: RendererChoice[]
	readonly minChoices: number
	readonly maxChoices: number
	submitChoice(selectedKeys: string[]): Promise<PrimerState<Pcis>>
	timeout(): Promise<PrimerState<Pcis>>
}

Use minChoices and maxChoices to decide whether the UI should submit immediately or require an explicit submit action.

Valid submitChoice payloads use identifiers from state.options:

| Requirement | Error if violated | | --- | --- | | At least minChoices identifiers | ErrInvalidSubmission | | At most maxChoices identifiers | ErrInvalidSubmission | | Every identifier exists in options | ErrInvalidSubmission | | No duplicate identifiers | ErrInvalidSubmission |

TextEntryState

interface TextEntryState<Pcis extends PciId = PciId> {
	readonly phase: "interaction"
	readonly kind: "text-entry"
	readonly lesson: LessonMetadata
	readonly body: ContentBlock[]
	readonly stimulus: RendererStimulus | null
	readonly interaction: Extract<StandardRendererInteraction, { type: "text-entry" }>
	readonly revision: Revision<{ readonly value: string }> | null
	submitText(value: string): Promise<PrimerState<Pcis>>
	timeout(): Promise<PrimerState<Pcis>>
}

The interaction may include expectedLength, patternMask, and placeholderText. These are renderer hints. The SDK requires the submission to be a text-entry submission with a string value.

ExtendedTextState

Extended text has two cardinalities.

interface ExtendedTextSingleState<Pcis extends PciId = PciId> {
	readonly phase: "interaction"
	readonly kind: "extended-text"
	readonly cardinality: "single"
	readonly lesson: LessonMetadata
	readonly body: ContentBlock[]
	readonly stimulus: RendererStimulus | null
	readonly interaction: Extract<
		StandardRendererInteraction,
		{ type: "extended-text"; cardinality: "single" }
	>
	readonly revision: Revision<{ readonly value: string }> | null
	submitText(value: string): Promise<PrimerState<Pcis>>
	timeout(): Promise<PrimerState<Pcis>>
}

interface ExtendedTextMultipleState<Pcis extends PciId = PciId> {
	readonly phase: "interaction"
	readonly kind: "extended-text"
	readonly cardinality: "multiple"
	readonly lesson: LessonMetadata
	readonly body: ContentBlock[]
	readonly stimulus: RendererStimulus | null
	readonly interaction: Extract<
		StandardRendererInteraction,
		{ type: "extended-text"; cardinality: "multiple" }
	>
	readonly revision: Revision<{ readonly values: string[] }> | null
	readonly minStrings: number
	readonly maxStrings: number
	submitTexts(values: string[]): Promise<PrimerState<Pcis>>
	timeout(): Promise<PrimerState<Pcis>>
}

For single-cardinality extended text, call submitText(value). For multiple-cardinality extended text, call submitTexts(values).

Valid multiple-cardinality payloads:

| Requirement | Error if violated | | --- | --- | | At least minStrings values | ErrInvalidSubmission | | At most maxStrings values | ErrInvalidSubmission | | No duplicate values | ErrInvalidSubmission |

expectedLength, expectedLines, patternMask, and placeholderText are renderer hints.

OrderState

interface OrderState<Pcis extends PciId = PciId> {
	readonly phase: "interaction"
	readonly kind: "order"
	readonly lesson: LessonMetadata
	readonly body: ContentBlock[]
	readonly stimulus: RendererStimulus | null
	readonly interaction: Extract<StandardRendererInteraction, { type: "order" }>
	readonly revision: Revision<{ readonly orderedKeys: string[] }> | null
	readonly choices: RendererChoice[]
	readonly minChoices: number
	readonly maxChoices: number
	submitOrder(orderedKeys: string[]): Promise<PrimerState<Pcis>>
	timeout(): Promise<PrimerState<Pcis>>
}

Submit identifiers from state.choices in learner-selected order.

Valid submitOrder payloads:

| Requirement | Error if violated | | --- | --- | | At least minChoices identifiers | ErrInvalidSubmission | | At most maxChoices identifiers | ErrInvalidSubmission | | Every identifier exists in choices | ErrInvalidSubmission | | No duplicate identifiers | ErrInvalidSubmission |

MatchState

interface MatchPair {
	source: string
	target: string
}

interface MatchState<Pcis extends PciId = PciId> {
	readonly phase: "interaction"
	readonly kind: "match"
	readonly lesson: LessonMetadata
	readonly body: ContentBlock[]
	readonly stimulus: RendererStimulus | null
	readonly interaction: Extract<StandardRendererInteraction, { type: "match" }>
	readonly revision: Revision<{ readonly pairs: MatchPair[] }> | null
	readonly sourceChoices: RendererChoice[]
	readonly targetChoices: RendererChoice[]
	readonly minAssociations: number
	readonly maxAssociations: number
	submitMatch(pairs: MatchPair[]): Promise<PrimerState<Pcis>>
	timeout(): Promise<PrimerState<Pcis>>
}

Each pair connects one source identifier from state.sourceChoices to one target identifier from state.targetChoices.

Valid submitMatch payloads:

| Requirement | Error if violated | | --- | --- | | At least minAssociations pairs | ErrInvalidSubmission | | At most maxAssociations pairs | ErrInvalidSubmission | | Every source identifier exists in sourceChoices | ErrInvalidSubmission | | Every target identifier exists in targetChoices | ErrInvalidSubmission | | No duplicate source-target pairs | ErrInvalidSubmission | | No duplicate source identifiers | ErrInvalidSubmission | | No duplicate target identifiers | ErrInvalidSubmission |

PciInteractionState

Portable Custom Interaction state is typed by PCI id.

type PciInteractionState<Pcis extends PciId = PciId> = {
	[K in Pcis]: {
		readonly phase: "interaction"
		readonly kind: "portable-custom"
		readonly lesson: LessonMetadata
		readonly body: ContentBlock[]
		readonly stimulus: RendererStimulus | null
		readonly interaction: PciInteraction<K>
		readonly revision: Revision<{ readonly value: PciValue<K> }> | null
		readonly pciId: K
		readonly properties: PciProps<K>
		submit(value: PciValue<K>): Promise<PrimerState<Pcis>>
		timeout(): Promise<PrimerState<Pcis>>
	}
}[Pcis]

When the state is narrowed to a PCI id, submit accepts only that PCI's value type.

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

if (state.phase === "interaction" && state.kind === "portable-custom") {
	if (state.pciId === "urn:primer:pci:fraction-input") {
		const value: PciValue<"urn:primer:pci:fraction-input"> = readFractionInput(
			state.properties
		)
		state = await state.submit(value)
	}
}

FeedbackState

type FeedbackState<Pcis extends PciId = PciId> =
	| AnsweredFeedbackState<Pcis>
	| TimedOutFeedbackState<Pcis>

type AnsweredFeedbackState<Pcis extends PciId = PciId> = {
	[K in InteractionKind]: {
		readonly phase: "feedback"
		readonly verdict: "correct" | "incorrect"
		readonly kind: K
		readonly body: ContentBlock[]
		readonly stimulus: RendererStimulus | null
		readonly interaction: InteractionFor<K, Pcis>
		readonly submission: SubmissionFor<K, Pcis>
		readonly feedbackContent: ContentInline[]
		readonly review: ReviewFor<K, Pcis> | null
		advance(): Promise<PrimerState<Pcis>>
	}
}[InteractionKind]

interface TimedOutFeedbackState<Pcis extends PciId = PciId> {
	readonly phase: "feedback"
	readonly verdict: "timedOut"
	readonly body: ContentBlock[]
	readonly stimulus: RendererStimulus | null
	readonly interaction: RendererInteraction<Pcis>
	readonly feedbackContent: ContentInline[]
	advance(): Promise<PrimerState<Pcis>>
}

Feedback state is returned after a terminal learner submission or timeout. Switch on verdict — wrongness has one name: verdict !== "correct". Answered feedback ("correct" | "incorrect") includes the submitted value, feedback content, and optional review data. "timedOut" feedback has no submitted value (there is none) and is scored as wrong. Call advance() when the learner is ready to continue; it typically resolves to a fresh FrontierState (or CompletedState).

Repeated advance() calls while the first one is pending return the same pending result.

CompletedState

interface CompletedState {
	readonly phase: "completed"
}

Terminal state. There is no transition method.

ErroredState

type ErroredState<Pcis extends PciId = PciId> =
	| RetriableErroredState<Pcis>
	| NonRetriableErroredState

interface RetriableErroredState<Pcis extends PciId = PciId> {
	readonly phase: "errored"
	readonly error: Error
	readonly retriable: true
	retry(): Promise<PrimerState<Pcis>>
}

interface NonRetriableErroredState {
	readonly phase: "errored"
	readonly error: Error
	readonly retriable: false
}

ErroredState means the current learner intent could not complete, but the learning session itself is not necessarily terminal.

If retriable is true, retry() repeats the exact failed intent. If retriable is false, the state does not expose retry().

Local and server submission validation failures resolve back to the interaction phase with rejection populated. Renderer code can safely adopt the returned state and let the learner fix the payload in place.

FatalState

interface FatalState {
	readonly phase: "fatal"
	readonly error: Error
	readonly retriable: false
}

Fatal state means the SDK cannot recover by retrying the current learner intent. Render a terminal error UI, call start again with valid options, or send the learner through auth again depending on the sentinel.

Fatal sentinels:

| Sentinel | Meaning | | --- | --- | | ErrBadRequest | Primer rejected the runtime request as invalid for the SDK contract. | | ErrWireContractViolation | A successful response violated the wire contract (version skew or server bug). | | ErrInvalidPublishableKey | The publishable key is missing or unknown — integrator configuration; the token is NOT cleared. | | ErrInvalidAccessToken | The learner token was rejected. | | ErrTokenExpired | The learner token expired. | | ErrNoRoutableContent | The catalog has no routable bootstrap content for the learner's scope. | | ErrSdkUpgradeRequired | The installed SDK is too old for the current Primer runtime. | | ErrUnsupportedPci | Primer presented a PCI that the host did not declare in supportedPcis. |

Stale-offer responses are NOT fatal: when a frame_event answers with offer_not_found, offer_superseded, frame_already_completed, or event_kind_mismatch, the SDK silently issues continue once and resolves to the fresh state — multi-tab races self-heal. A second consecutive stale response lands in a retriable errored state instead of looping.

Local in-flight races (submit while timeout is pending, conflicting payloads) resolve to the first in-flight transition's pending promise. Local and server ErrInvalidSubmission results are converted back into interaction states with rejection populated. ErrContentUngradeable (authored content cannot be graded) and infrastructure failures land in retriable errored states.

The frontier is one-shot: the first state.enter(route) call wins, every subsequent enter(route) on that FrontierState returns the same state object, and the open beacon fires exactly once for the entered route.

Contracts

Import renderer-facing data types and validation helpers from 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:

| Type | Key fields | | --- | --- | | choice | prompt, options, minChoices, maxChoices | | text-entry | prompt, base, expectedLength, patternMask, placeholderText | | extended-text single | prompt, format, expectedLines, expectedLength, patternMask, placeholderText | | extended-text multiple | single fields plus minStrings, maxStrings | | order | prompt, choices, minChoices, maxChoices | | match | prompt, sourceChoices, targetChoices, minAssociations, maxAssociations | | portable-custom | prompt, pciId, properties |

Choice objects:

interface RendererChoice {
	identifier: string
	content: ContentInline[]
}

Submissions

type RendererSubmission<Pcis extends PciId = PciId> =
	| { type: "choice"; selectedKeys: string[] }
	| { type: "text-entry"; value: string }
	| { type: "extended-text"; values: string[] }
	| { type: "order"; orderedKeys: string[] }
	| { type: "match"; pairs: MatchPair[] }
	| PciSubmission<Pcis>

Public schemas:

| Schema | Validates | | --- | --- | | MatchPairSchema | { source, target } match pair shape | | ChoiceSubmissionSchema | choice submission shape | | TextEntrySubmissionSchema | text-entry submission shape | | ExtendedTextSubmissionSchema | extended-text submission shape | | OrderSubmissionSchema | order submission shape | | MatchSubmissionSchema | match submission shape | | FractionInputPciSubmissionSchema | fraction-input PCI submission shape | | RendererSubmissionSchema | union of all supported submission shapes |

Always use the exported AJV-backed Draft 7 validator when parsing arbitrary input.

import * as errors from "@superbuilders/errors"
import { logger } from "@/logger"
import * as validate from "@superbuilders/validate"
import { RendererSubmissionSchema } from "@superbuilders/primer-tives/contracts"

const parsed = RendererSubmissionSchema.parse(payload)
if (!parsed.success) {
	logger.error({ error: parsed.error }, "submission payload invalid")
	throw errors.wrap(parsed.error, "submission payload")
}

const submission = parsed.data

Semantic Submission Validation

Shape validation answers “does this look like a submission?” Semantic validation answers “is this submission valid for this exact interaction?”

function validateSubmission<K extends InteractionKind>(
	interaction: InteractionFor<K>,
	submission: SubmissionFor<K>
): SubmissionValidationResult<SubmissionFor<K>>

function correlateSubmission(
	interaction: RendererInteraction,
	submission: RendererSubmission
): SubmissionValidationResult<RendererSubmission>

function submissionValidationMessage(result: SubmissionValidationFailure): string

Result shape:

type SubmissionValidationResult<S> =
	| { ok: true; value: S }
	| { ok: false; issues: readonly string[] }

Validation checks:

| Interaction | Checks | | --- | --- | | choice | min/max selection count, duplicate identifiers, unknown identifiers | | text-entry | typed shape | | extended-text single | exactly one value | | extended-text multiple | min/max value count, duplicate values | | order | min/max selection count, duplicate identifiers, unknown identifiers | | match | min/max association count, duplicate pairs, duplicate sources, duplicate targets, unknown sources, unknown targets | | portable-custom | PCI value schema |

The built-in standard interaction state methods call typed validateSubmission before submitting. Server and other hostile JSON boundaries should call correlateSubmission, which rejects interaction/submission mismatches before using validation.value.

import * as errors from "@superbuilders/errors"
import { logger } from "@/logger"
import {
	correlateSubmission,
	submissionValidationMessage,
	validateSubmission
} from "@superbuilders/primer-tives/contracts"
import { ErrInvalidSubmission } from "@superbuilders/primer-tives/errors"

const validation = correlateSubmission(interaction, submission)
if (!validation.ok) {
	const message = submissionValidationMessage(validation)
	logger.error({ issues: validation.issues }, "submission invalid")
	throw errors.wrap(ErrInvalidSubmission, message)
}

Review Types

AnsweredFeedbackState.review is InteractionReview | null. Timeout feedback does not carry review data because no submission was graded.

type InteractionReview<Pcis extends PciId = PciId> =
	| ChoiceReview
	| TextEntryReview
	| ExtendedTextReview
	| OrderReview
	| MatchReview
	| PciReview<Pcis>

Review variants:

| Type | Data | | --- | --- | | choice | correctKeys: string[] | | text-entry | correctValue: ReviewScalarValue | null | | extended-text | correctValues: ReviewScalarValue[] | | order | correctOrder: string[] | | match | correctPairs: MatchPair[] | | portable-custom | pciId, fields: ReviewRecordField[] |

Review scalar values:

type ReviewScalarValue =
	| { kind: "identifier"; value: string }
	| { kind: "string"; value: string }
	| { kind: "integer"; value: number }
	| { kind: "float"; value: number }
	| { kind: "pair"; source: string; target: string }

review is for renderer display and inspection. The correctness outcome lives on the feedback state's verdict ("correct" | "incorrect" | "timedOut").

PCI Registry

The current PCI registry contains one PCI.

const PCI_IDS = ["urn:primer:pci:fraction-input"] as const
type PciId = "urn:primer:pci:fraction-input"
type PciProps<K extends PciId> = PciRegistry[K]["props"]
type PciValue<K extends PciId> = PciRegistry[K]["value"]

Use isPciId(value) to narrow an arbitrary string to the current PciId union.

Fraction Input PCI

type FractionInputForm = "whole" | "proper" | "improper" | "mixed"

interface FractionInputProps {
	form: FractionInputForm
	requireSimplified: boolean
}

Submitted value:

type FractionInputSubmission =
	| { form: "whole"; whole: string }
	| { form: "proper"; numerator: string; denominator: string }
	| { form: "improper"; numerator: string; denominator: string }
	| { form: "mixed"; whole: string; numerator: string; denominator: string }

Example renderer branch:

if (state.phase === "interaction" && state.kind === "portable-custom") {
	if (state.pciId === "urn:primer:pci:fraction-input") {
		renderFractionInput({
			mode: "pending",
			properties: state.properties,
			onValueChange: handleFractionValueChange
		})
	}
}

Rendering Portable Custom Interactions

Portable Custom Interactions (PCIs) split cleanly across the three architecture layers documented above. This section is the authoritative guide for integrators 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:

| Field | Role | | --- | --- | | state.pciId | Which PCI contract applies | | state.properties | Authoring-time PCI configuration (PciProps<K>) | | state.interaction | Full interaction contract including prompt and calibration | | state.revision | Recoverable yellow-path bundle when applicable | | state.submit(value) | Validates and sends PciValue<K> to the runtime |

Rendering is your switch on pciId. The SDK does not do it for you.

Step 3 — Optional PciRenderProps for component authors

If you extract a fraction-input widget, PciRenderProps<K> types its pending vs submitted modes. This is exported from `@superbuilders/pr