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

v5.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. 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 5.0.0.

Entrypoints

There is no package-root export. Import from the public subpath that owns the surface you need.

| Subpath | Owns | | --- | --- | | @superbuilders/primer-tives/client | start, PrimerOptions, auth-specific start option types, PrimerState, all state interfaces, PCI render props | | @superbuilders/primer-tives/contracts | Content, stimulus, interaction, submission, review, PCI types, schemas, validation helpers | | @superbuilders/primer-tives/errors | Every SDK error sentinel | | @superbuilders/primer-tives/logger | PrimerLogger interface | | @superbuilders/primer-tives/subject | Subject, SUBJECTS | | @superbuilders/primer-tives/subject-pcis | Subject-required PCI helpers and type helpers | | @superbuilders/primer-tives/grade-level | GradeLevel, GRADE_LEVELS |

Quick Start

Math content can require the fraction-input PCI capability, so a math renderer must declare it.

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

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

let state = await start(options)

If your application already has a learner access token, pass it directly. start uses that token for the learning runtime.

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

let state = await start(options)

When managed-auth start returns SignInRequiredState, render sign-in UI and call login() directly from a user gesture:

import type { ManagedStartState, SignInRequiredState } from "@superbuilders/primer-tives/client"

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().

Vocabulary and science currently have no required PCI capabilities, so supportedPcis can be omitted for those subjects.

const options = {
	publishableKey: "pk_...",
	subject: "vocabulary",
	logger
} satisfies PrimerOptionsWithManagedAuth<"vocabulary">

let state = await start(options)

Omitting subject means the SDK asks Primer for the all-subject runtime scope. Because that scope can include math, it requires the union of all subject-required PCIs.

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

let state = await start(options)

start(options)

function start<
	const S extends Subject | undefined = undefined,
	const Supported extends readonly PciId[] = []
>(options: PrimerOptionsWithAccessToken<S, Supported>): Promise<AccessTokenStartState>

function start<
	const S extends Subject | undefined = undefined,
	const Supported extends readonly PciId[] = []
>(options: PrimerOptionsWithManagedAuth<S, Supported>): Promise<ManagedStartState>

start is the first SDK lifecycle operation. Its return type depends on whether accessToken is present:

| Result | Meaning | | --- | --- | | SignInRequiredState | Learner sign-in is needed before runtime learning can begin. Managed-auth mode only. | | SignInFailedState | Hosted sign-in failed but can be retried. Managed-auth mode only. | | AuthUnavailableState | Browser-hosted auth cannot run in the current runtime. Managed-auth mode only. | | AuthConfigInvalidState | Hosted-auth configuration is invalid and cannot be retried. Managed-auth mode only. | | 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.

import * as errors from "@superbuilders/errors"
import { logger } from "@/logger"
import { start, type PrimerOptionsWithAccessToken } from "@superbuilders/primer-tives/client"
import { ErrAuthUnavailable, ErrMalformedAccessToken } from "@superbuilders/primer-tives/errors"

const options = {
	publishableKey,
	accessToken,
	subject: "math",
	supportedPcis: ["urn:primer:pci:fraction-input"],
	logger
} satisfies PrimerOptionsWithAccessToken<"math", readonly ["urn:primer:pci:fraction-input"]>

let state = await start(options)
if (state.phase === "fatal") {
	if (errors.is(state.error, ErrMalformedAccessToken)) {
		renderSignInAgain()
		return
	}
	logger.error({ error: state.error }, "primer fatal state")
	throw state.error
}

PrimerOptions

type PrimerOptions<S extends Subject | undefined = undefined, Supported extends readonly PciId[] = []> = {
	readonly publishableKey: string
	readonly origin?: string
	readonly subject?: S
	readonly supportedPcis: subject-dependent
	readonly fetch?: typeof globalThis.fetch
	readonly abort?: AbortController
	readonly logger: PrimerLogger
}

type PrimerOptionsWithAccessToken<S, Supported> = PrimerOptions<S, Supported> & {
	readonly accessToken: string
}

type PrimerOptionsWithManagedAuth<S, Supported> = PrimerOptions<S, Supported> & {
	readonly accessToken?: undefined
}

| Field | Required | Meaning | | --- | --- | --- | | publishableKey | Yes | Public key identifying the Primer frontend your runtime belongs to. | | origin | No | Primer origin. Defaults to https://primerlearn.dev. | | accessToken | Mode-dependent | Learner access token. When present, start uses access-token mode. When absent, start uses managed hosted-auth mode. | | subject | No | Public content scope: "math", "vocabulary", or "science". Omitted means all-subject scope. | | supportedPcis | Subject-dependent | Renderer capabilities for Portable Custom Interactions. Required when the chosen scope can emit required PCIs. | | fetch | No | Fetch override for tests, instrumentation, or host runtime integration. | | abort | No | Abort controller for SDK runtime work. | | logger | Yes | Structured logger implementing debug, info, warn, and error. |

The presence or absence of accessToken selects startup auth semantics.

The SDK uses Primer's production runtime by default.

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

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

When subject is present, it must be a concrete literal subject at the options declaration site. Do not pass a broad Subject value into PrimerOptions; narrow it in host control flow, then construct options with satisfies PrimerOptionsWithManagedAuth<"math", ...>, satisfies PrimerOptionsWithAccessToken<"math", ...>, or the corresponding literal subject variant.

Managed Auth Token Persistence

Managed hosted auth is designed for browser-only consumers that do not have their own server-side token broker. When accessToken is absent, PrimerTives owns a session-scoped browser token cache with one fixed storage location:

sessionStorage["primer:access-token:<publishableKey>"]

This is intentionally zero config. There is no storage selector and no persistence flag. Managed-auth mode always uses globalThis.sessionStorage; if sessionStorage is unavailable, start returns AuthUnavailableState.

The cache stores only the final Primer access token returned by hosted Timeback 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 SignInFailedState so the app can ask the learner to sign in again. |

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.

The canonical managed sign-in endpoint is /api/auth/timeback/start. Legacy /auth/timeback URLs are not part of the PrimerTives 4.0.5 contract.

Auth Semantics

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

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

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

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

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

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

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

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

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

Subject And PCI Contract

subject selects content scope and determines required renderer capabilities.

import { SUBJECTS } from "@superbuilders/primer-tives/subject"
import type { Subject } from "@superbuilders/primer-tives/subject"

const subjects = SUBJECTS
type RuntimeSubject = Subject

Current public subjects:

| Subject | Required PCI support | | --- | --- | | "math" | "urn:primer:pci:fraction-input" | | "vocabulary" | none | | "science" | none | | omitted subject | union of all subject-required PCIs, currently "urn:primer:pci:fraction-input" |

The type-level rule is a subset check:

required PCIs for selected scope <= supportedPcis

Order does not matter. Extra supported PCIs are allowed. supportedPcis is a renderer capability declaration, not a content request.

This fails at compile time because math can emit a required PCI:

await start({
	publishableKey,
	subject: "math",
	logger
})

This passes:

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

await start(options)

The runtime also protects the trust boundary. If Primer presents a portable custom interaction that is not declared in supportedPcis, the SDK returns FatalState with ErrUnsupportedPci.

Runtime Loop

Every renderer should switch on state.phase. Interaction rendering should then switch on state.kind.

import { logger } from "@/logger"
import type { PrimerState } from "@superbuilders/primer-tives/client"

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 "observation":
				renderFrame(state.body, state.stimulus)
				state = await state.advance()
				break
			case "interaction":
				state = await renderAndSubmitInteraction(state)
				break
			case "feedback":
				if (state.feedbackKind === "submitted") {
					renderSubmittedFeedback(state.feedbackContent, state.assessmentOutcome, state.review)
				}
				if (state.feedbackKind === "timedOut") {
					renderTimeoutFeedback(state.feedbackContent)
				}
				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> | | AuthUnavailableState | none | terminal for hosted auth in this runtime | | AuthConfigInvalidState | none | terminal for the current configuration | | ObservationState | advance() | Promise<PrimerState> | | ChoiceState | submitChoice(selectedKeys) or timeout() | Promise<PrimerState> | | TextEntryState | submitText(value) or timeout() | Promise<PrimerState> | | ExtendedTextSingleState | submitText(value) or timeout() | Promise<PrimerState> | | ExtendedTextMultipleState | submitTexts(values) or timeout() | Promise<PrimerState> | | OrderState | submitOrder(orderedKeys) or timeout() | Promise<PrimerState> | | MatchState | submitMatch(pairs) or timeout() | Promise<PrimerState> | | PciInteractionState | submit(value) or timeout() | Promise<PrimerState> | | FeedbackState | advance() | Promise<PrimerState> | | CompletedState | none | terminal | | RetriableErroredState | retry() | Promise<PrimerState> | | NonRetriableErroredState | none | terminal for the failed intent | | FatalState | none | terminal |

PrimerState

type PrimerState<Pcis extends PciId = PciId> =
	| SignInRequiredState<Pcis>
	| SignInFailedState<Pcis>
	| AuthUnavailableState
	| AuthConfigInvalidState
	| RuntimeState<Pcis>
	| CompletedState
	| ErroredState<Pcis>
	| FatalState

type RuntimeState<Pcis extends PciId = PciId> =
	| ObservationState<Pcis>
	| InteractionState<Pcis>
	| FeedbackState<Pcis>

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

Start a new state by calling start again after a reload, remount, account switch, or subject switch.

SignInRequiredState

interface SignInRequiredState<Pcis extends PciId = PciId> {
	readonly phase: "sign-in-required"
	login(): Promise<ManagedStartState<Pcis>>
}

SignInRequiredState means learner sign-in is required before learning content can be rendered. It has no error field because no sign-in attempt has failed. Render a sign-in button or equivalent learner action. Call login() only from that user action.

Correct browser-safe pattern:

function handleSignInClick(state: SignInRequiredState): void {
	void state.login().then(function continueAfterLogin(nextState) {
		renderPrimer(nextState)
	})
}

React renderers should use the same direct-call rule:

function SignInButton({ state }: { state: SignInRequiredState }) {
	async function handleClick() {
		const nextState = await state.login()
		renderPrimer(nextState)
	}

	return <button type="button" onClick={handleClick}>Sign in to continue</button>
}

For Chrome popup blocking, state.login() must be the first async-producing operation in the click or tap handler. This is correct:

async function handleClick() {
	const nextState = await state.login()
	renderPrimer(nextState)
}

This is not browser-safe:

async function handleClick() {
	await recordAnalyticsClick()
	const nextState = await state.login()
	renderPrimer(nextState)
}

If login() fails in a retryable hosted-auth way, it resolves to SignInFailedState. If hosted auth cannot run in the current runtime, it resolves to AuthUnavailableState. If the public hosted-auth configuration is invalid, it resolves to AuthConfigInvalidState.

SignInFailedState

interface SignInFailedState<Pcis extends PciId = PciId> {
	readonly phase: "sign-in-failed"
	readonly error: Error
	login(): Promise<ManagedStartState<Pcis>>
}

SignInFailedState means a hosted sign-in attempt failed but another user gesture may retry it. Render the error and bind login() to a retry button.

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.

ObservationState

interface ObservationState<Pcis extends PciId = PciId> {
	readonly phase: "observation"
	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. | | body | Frame content. | | stimulus | Optional frame stimulus. | | interaction | Full interaction contract object. | | 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 return ErroredState with ErrInvalidSubmission.

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 ErroredState with ErrConflict. | | Submit while timeout is pending | Returns ErroredState with ErrConflict. | | Timeout while submit is pending | Returns ErroredState with ErrConflict. | | Repeated timeout while timeout is pending | Returns the same pending result. |

ChoiceState

interface ChoiceState<Pcis extends PciId = PciId> {
	readonly phase: "interaction"
	readonly kind: "choice"
	readonly body: ContentBlock[]
	readonly stimulus: RendererStimulus | null
	readonly interaction: Extract<StandardRendererInteraction, { type: "choice" }>
	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:

| 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 body: ContentBlock[]
	readonly stimulus: RendererStimulus | null
	readonly interaction: Extract<StandardRendererInteraction, { type: "text-entry" }>
	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 body: ContentBlock[]
	readonly stimulus: RendererStimulus | null
	readonly interaction: Extract<
		StandardRendererInteraction,
		{ type: "extended-text"; cardinality: "single" }
	>
	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 body: ContentBlock[]
	readonly stimulus: RendererStimulus | null
	readonly interaction: Extract<
		StandardRendererInteraction,
		{ type: "extended-text"; cardinality: "multiple" }
	>
	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 body: ContentBlock[]
	readonly stimulus: RendererStimulus | null
	readonly interaction: Extract<StandardRendererInteraction, { type: "order" }>
	readonly choices: RendererChoice[]
	readonly minChoices: number
	readonly maxChoices: number
	submitOrder(orderedKeys: string[]): Promise<PrimerState<Pcis>>
	timeout(): Promise<PrimerState<Pcis>>
}

Submit identifiers 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 body: ContentBlock[]
	readonly stimulus: RendererStimulus | null
	readonly interaction: Extract<StandardRendererInteraction, { type: "match" }>
	readonly sourceChoices: RendererMatchChoice[]
	readonly targetChoices: RendererMatchChoice[]
	readonly minAssociations: number
	readonly maxAssociations: number
	submitMatch(pairs: MatchPair[]): Promise<PrimerState<Pcis>>
	timeout(): Promise<PrimerState<Pcis>>
}

Each pair connects one source identifier to one target identifier.

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 | | Every choice respects its matchMin | ErrInvalidSubmission | | Every choice respects its matchMax when matchMax is nonzero | ErrInvalidSubmission |

matchMax: 0 means unbounded.

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 body: ContentBlock[]
		readonly stimulus: RendererStimulus | null
		readonly interaction: PciInteraction<K>
		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 AssessmentOutcomeValue = "correct" | "incorrect" | "revisionRequested"

type AssessmentOutcome = {
	cardinality: "single"
	baseType: "identifier"
	value: AssessmentOutcomeValue
}

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

interface SubmittedFeedbackState<Pcis extends PciId = PciId> {
	readonly phase: "feedback"
	readonly feedbackKind: "submitted"
	readonly body: ContentBlock[]
	readonly stimulus: RendererStimulus | null
	readonly interaction: RendererInteraction<Pcis>
	readonly submission: RendererSubmission<Pcis>
	readonly assessmentOutcome: AssessmentOutcome
	readonly feedbackContent: ContentInline[]
	readonly review: InteractionReview<Pcis> | null
	advance(): Promise<PrimerState<Pcis>>
}

interface TimedOutFeedbackState<Pcis extends PciId = PciId> {
	readonly phase: "feedback"
	readonly feedbackKind: "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 feedbackKind. Submitted feedback includes the submitted value, assessmentOutcome.value, feedback content, and optional review data. Timeout feedback has no submitted value and no correctness outcome. Call advance() when the learner is ready to continue.

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().

ErrInvalidSubmission is non-retriable because the submitted value was invalid for the active interaction. Renderer code should fix the payload before submitting again from a fresh interaction state.

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. | | ErrInvalidAccessToken | The learner token was rejected. | | ErrTokenExpired | The learner token expired. | | ErrForbidden | The learner is not allowed to continue in this runtime scope. | | ErrNotFound | The requested runtime scope or state could not be found. | | ErrSdkUpgradeRequired | The installed SDK is too old for the current Primer runtime. | | ErrUnsupportedPci | Primer presented a PCI that the renderer did not declare in supportedPcis. |

/contracts

The contracts subpath contains renderer-facing data types and validation helpers.

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

import type {
	ContentBlock,
	ContentInline,
	ContentSpan,
	FractionInputForm,
	FractionInputProps,
	FractionInputSubmission,
	ImageStimulus,
	InteractionReview,
	MatchPair,
	PciId,
	PciInteraction,
	PciProps,
	PciRegistry,
	PciSubmission,
	PciUrn,
	PciValue,
	RendererChoice,
	RendererInteraction,
	RendererMatchChoice,
	RendererStimulus,
	RendererSubmission,
	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 ImageStimulus {
	kind: "image"
	alt: ContentInline[]
	src: string
}

type RendererStimulus = ImageStimulus

RendererStimulus is currently image-only. Always switch on stimulus.kind anyway.

Interactions

type RendererInteraction<Pcis extends PciId = PciId> =
	| StandardRendererInteraction
	| PciInteraction<Pcis>

Standard interactions:

| Type | Key fields | | --- | --- | | choice | prompt, options, shuffle, 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, shuffle, minChoices, maxChoices | | match | prompt, sourceChoices, targetChoices, shuffle, minAssociations, maxAssociations | | portable-custom | prompt, pciId, properties |

Choice objects:

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

Match choice objects:

interface RendererMatchChoice {
	identifier: string
	content: ContentInline[]
	matchMax: number
	matchMin: number
}

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 validateSubmissionForInteraction(
	interaction: RendererInteraction,
	submission: RendererSubmission
): SubmissionValidationResult

function submissionValidationMessage(result: SubmissionValidationFailure): string

Result shape:

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

Validation checks:

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

The built-in standard interaction state methods call this before submitting. Custom renderer utilities that build RendererSubmission values directly should call it too.

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

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

Review Types

SubmittedFeedbackState.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 assessment outcome lives on SubmittedFeedbackState.assessmentOutcome.value.

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

PCI Render Props

PCI render props are convenience types for renderer components.

type PciPendingRenderProps<K extends PciId> = {
	mode: "pending"
	properties: PciProps<K>
	onValueChange: (value: PciValue<K> | null) => void
}

type PciSubmittedRenderProps<K extends PciId> = {
	mode: "submitted"
	properties: PciProps<K>
	submission: PciValue<K>
	review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
}

type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRenderProps<K>

Subject PCI Helpers

Use @superbuilders/primer-tives/subject-pcis when renderer tooling needs the same subject-to-PCI contract as start.

import {
	REQUIRED_PCIS_BY_SUBJECT,
	missingPcisForSubject,
	requiredPcisForSubject
} from "@superbuilders/primer-tives/subject-pcis"
import type {
	HasRequiredPcis,
	MissingRequiredPcis,
	RequiredPciForSubject
} from "@superbuilders/primer-tives/subject-pcis"

const requiredForMath = requiredPcisForSubject("math")
const missingForMath = missingPcisForSubject("math", [])

requiredPcisForSubject(undefined) returns the all-subject required PCI union.

Errors

All SDK sentinels are exported from @superbuilders/primer-tives/errors and are compatible with errors.is() from @superbuilders/errors.

import * as errors from "@superbuilders/errors"
import { ErrTokenExpired } from "@superbuilders/primer-tives/errors"

if (errors.is(err, ErrTokenExpired)) {
	renderSignInAgain()
}

Complete export set:

import {
	ErrAuthCallbackInvalid,
	ErrAuthCancelled,
	ErrAuthConfigInvalid,
	ErrAuthPopupBlocked,
	ErrAuthStateMismatch,
	ErrAuthUnavailable,
	ErrBadRequest,
	ErrConflict,
	ErrForbidden,
	ErrInvalidAccessToken,
	ErrInvalidSubmission,
	ErrJsonParse,
	ErrMalformedAccessToken,
	ErrNetwork,
	ErrNotFound,
	ErrNotSerializable,
	ErrRateLimited,
	ErrSdkUpgradeRequired,
	ErrServerError,
	ErrServiceUnavailable,
	ErrTimeout,
	ErrTokenExpired,
	ErrUnsupportedPci
} from "@superbuilders/primer-tives/errors"

Auth And Startup Errors

Auth and startup failures are represented as state whenever possible.

| Sentinel | Meaning | Typical handling | | --- | --- | --- | | ErrAuthUnavailable | SDK-managed auth cannot run in the current host environment. | Render unsupported-runtime or externally managed sign-in UI. | | ErrAuthConfigInvalid | Public auth configuration is invalid. | Log and treat as integration error. | | ErrAuthCallbackInvalid | Learner auth did not complete with an acceptable result. | Offer sign-in retry; log if unexpected. | | ErrAuthStateMismatch | Auth result does not match the initiated auth attempt. | Offer sign-in retry. | | ErrAuthPopupBlocked | Browser blocked learner auth UI. | Render sign-in instructions and retry from a direct user gesture. | | ErrAuthCancelled | Learner auth was closed or timed out. | Offer retry. | | ErrMalformedAccessToken | Provided or resolved token is not shaped like a learner access token. | Re-authenticate learner or fix token source. |

Runtime Error States

Runtime errors are represented as ErroredState or FatalState.

| Sentinel | State | Retriable | Meaning | | --- | --- | --- | --- | | ErrNetwork | ErroredState | yes | Runtime communication failed before a usable Primer result existed. | | ErrTimeout | ErroredState | yes | Runtime work was aborted or exceeded the host's allowed time. | | ErrServerError | ErroredState | yes | Primer could not produce a normal runtime result. | | ErrServiceUnavailable | ErroredState | yes | Primer is temporarily unavailable. | | ErrRateLimited | ErroredState | yes | Runtime work is temporarily rate limited. | | ErrConflict | ErroredState | yes | The learner intent conflicts with another in-flight or current runtime action. | | ErrJsonParse | ErroredState | yes | Runtime data could not be interpreted as the SDK contract. | | ErrInvalidSubmission | ErroredState | no | Renderer submitted a value that is invalid for the active interaction. | | ErrBadRequest | FatalState | no | Runtime request violates the SDK contract. | | ErrInvalidAccessToken | FatalState | no | Learner token is invalid. | | ErrTokenExpired | FatalState | no | Learner token expired. | | ErrForbidden | FatalState | no | Learner cannot continue in this runtime scope. | | ErrNotFound | FatalState | no | Runtime scope or state is unavailable. | | ErrSdkUpgradeRequired | FatalState | no | Installed SDK version is too old for Primer. | | ErrUnsupportedPci | FatalState | no | Renderer did not declare support for the presented PCI. | | ErrNotSerializable | thrown by toJSON | no | A live PrimerState was serialized. |

Error-Handling Recipes

Handle auth-needed state before rendering learning content:

let state = await start(options)

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

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

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

Bind login directly to the sign-in button:

function handleSignInClick(state: SignInRequiredState | SignInFailedState): void {
	void state.login().then(function continueAfterLogin(nextState) {
		renderPrimer(nextState)
	})
}

Handle runtime errors through the state machine:

if (state.phase === "errored") {
	if (state.retriable) {
		state = await state.retry()
		return
	}
	logger.error({ error: state.error }, "primer non-retriable state")
	throw state.error
}

if (state.phase === "fatal") {
	if (errors.is(state.error, ErrTokenExpired)) {
		renderSignInAgain()
		return
	}
	if (errors.is(state.error, ErrSdkUpgradeRequired)) {
		renderSdkUpgradeMessage()
		return
	}
	logger.error({ error: state.error }, "primer fatal state")
	throw state.error
}

Handle invalid submissions by fixing renderer state, not by retrying the same invalid intent:

const next = await state.submitChoice(selectedKeys)
if (next.phase === "errored") {
	if (errors.is(next.error, ErrInvalidSubmission)) {
		renderSelectionError(next.error.message)
		return
	}
}
state = next

Logger

import type { PrimerLogger } from "@superbuilders/primer-tives/logger"

type PrimerLogger = import("pino").Logger

Use a Pino-compatible logger. Pino calls are object-first when attributes are present.

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

const options = {
	publishableKey,
	subject: "vocabulary",
	logger
} satisfies PrimerOptionsWithManagedAuth<"vocabulary">

const state = await start(options)

Grade Levels

import { GRADE_LEVELS } from "@superbuilders/primer-tives/grade-level"
import type { GradeLevel } from "@superbuilders/primer-tives/grade-level"

const gradeLevels = GRADE_LEVELS
type RuntimeGradeLevel = GradeLevel
const GRADE_LEVELS = ["K", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"] as const
type GradeLevel = (typeof GRADE_LEVELS)[number]

Grade level is not a start option. Treat grade-level identifiers as content/runtime data when Primer presents them, not as an SDK lifecycle input.

Testing

PrimerOptions.fetch exists so tests, host runtimes, and instrumentation can provide a fetch-compatible function. The SDK treats it exactly as the runtime communication function for start, transitions, submissions, retries, and timeouts.

The runtime exchange shape is not public SDK surface. Tests should assert SDK semantics after start resolves:

| Scenario | Assert | | --- | --- | | auth is needed | managed-auth start resolves to SignInRequiredState | | auth login fails | login() resolves to SignInFailedState with the expected auth sentinel | | auth cannot run | login() resolves to AuthUnavailableState | | auth config is invalid | login() resolves to AuthConfigInvalidState | | first runtime work fails recoverably | start resolves to ErroredState with retriable: true | | first runtime work fails terminally | start resolves to FatalState | | unsupported PCI is presented | start resolves to FatalState with ErrUnsupportedPci | | standard submission is invalid | submit method resolves to ErroredState with ErrInvalidSubmission | | concurrent submit/timeout conflict occurs | transition resolves to ErroredState with ErrConflict | | state is serialized | serialization 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",
	subject: "vocabulary",
	fetch: fetchMock,
	logger
} satisfies PrimerOptionsWithAccessToken<"vocabulary">

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. It is not negotiated implicitly. If a renderer cannot handle 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 @superbuilders/primer-tives/contracts.
  3. Define options with satisfies PrimerOptionsWithManagedAuth<...> or satisfies PrimerOptionsWithAccessToken<...> so subject and PCI requirements stay visible at the declaration site.
  4. Pass publishableKey and logger to start.
  5. Either pass accessToken or handle managed-auth states.
  6. Choose a public subject, or omit subject for all-subject runtime scope.
  7. Declare every required renderer PCI in supportedPcis.
  8. Await start and render by switching on state.phase.
  9. If state.phase === "sign-in-required" or "sign-in-failed", render sign-in UI and bind state.login() directly to the click or tap handler.
  10. For interaction states, render by switching on state.kind.
  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 subject switch.

What This SDK Does Not Expose

The current SDK intentionally does not expose:

| Not exposed | Use instead | | --- | --- | | package-root exports | explicit public subpaths | | backend-only SDK surface | browser/client semantic SDK only | | separate auth API object | hosted-auth state variants with login() only on retryable sign-in states | | hosted-auth popup configuration | fixed popup defaults and current page redirect URI | | client wrapper object | start overloads returning live state | | snapshot() | live PrimerState only | | serializable state | start a new state with start | | public "all" subject value | omit subject | | implicit PCI negotiation | explicit supportedPcis | | grade-level lifecycle option | runtime content/state handling |

Final Invariants

start is the public lifecycle entrypoint
start returns AccessTokenStartState or ManagedStartState based on accessToken presence
accessToken present is used for learner runtime auth
accessToken present cannot produce hosted-auth states
accessToken absent may produce SignInRequiredState
SignInRequiredState.login and SignInFailedState.login are hosted-auth user-gesture transitions
AuthUnavailableState has no login operation
AuthConfigInvalidState has no login operation
subject is optional; omitted means all-subject runtime scope
subject determines required renderer PCI capabilities
supportedPcis declares renderer PCI capabilities
PrimerState is the live learning state machine
only valid state variants expose learning transitions
standard interaction submissions are validated before runtime submission
fatal state is terminal for the current state object
live state is not serializable

Keep these concepts separate: publishable key is not learner auth; access token is not content authorization; subject selects scope but does not prove renderer capability; PCI support is explicit; PrimerState is live behavior, not data.