@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) -> Promise<PrimerState>
SignInRequiredState.login() -> Promise<PrimerState>
SignInFailedState.login() -> Promise<PrimerState>
SessionExpiredState.login() -> Promise<PrimerState>
start resolves directly to a live learning state when learner auth is ready — for an authenticated learner that first state is the served frame itself (observation or interaction). "Frontier" is server-side routing vocabulary; the SDK materializes a served route directly as its frame state, with no intermediate state and nothing to enter. 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.
pnpm add @superbuilders/primer-tives
Version
The current SDK version is 15.1.1 (SDK_MAJOR 15). 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 { intent }. supportedPcis: readonly PciId[] is a LOCAL capability declaration for the SDK's own frame classifier — it never rides the wire.
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 route. 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 and its option types (PrimerOptions, PrimerOptionsWithAccessToken, PrimerOptionsWithManagedAuth) |
@superbuilders/primer-tives/types | PrimerState and every named state interface (SignInRequiredState, SignInFailedState, SessionExpiredState, AuthUnavailableState, AuthConfigInvalidState, NotEntitledState, PlacementRequiredState, PlacementPendingState, ObservationState, InteractionState and its six kinds, FeedbackState, CompletedState, ErroredState, FatalState), plus Journey, JourneyProgress, AdvanceEvent, and the optional host-renderer prop type PciRenderProps |
@superbuilders/primer-tives/contracts | Shared data contracts: content (ContentBlock, ContentInline, blocksToPlainText, inlinesToPlainText), renderer types (RendererInteraction, RendererStimulus, RendererChoice, RendererSubmission, MatchPair, PciInteraction, InteractionFor, InteractionKind, SubmissionFor, ReviewFor), the PCI type vocabulary (PciId, PciProps, PciValue, FractionInputForm, FractionInputProps, FractionInputSubmission, FractionInputPropsSchema, FractionInputSubmissionSchema), reviews (InteractionReview, ChoiceReview, MatchReview, OrderReview, ReviewRecordField, ReviewScalarValue), the advance wire types (AdvanceRequest, AdvanceIntent, AdvanceResult, AdvanceErrorCode, FrontierResult, RecoverableResult, GradedResult, GradedNext, CompletedResult, WireFrame, WireGradedFrame, WireFrontierRoute, WireJourney, WireXp, WireAdvanceEvent, WireRevision), and submission validation (correlateSubmission, submissionValidationMessage, SubmissionValidationResult, SubmissionValidationFailure, RendererSubmissionDraft07Schema) |
@superbuilders/primer-tives/errors | Every SDK error sentinel, ADVANCE_WIRE_ERRORS (the wire-error-code → sentinel/state registry), and AuthRelayReason |
@superbuilders/primer-tives/version | SDK_VERSION, SDK_MAJOR |
The PrimerLogger type is pino's Logger; it is not re-exported — import it directly from pino (import type { Logger } from "pino"). There is no subject entrypoint — content scope is owned by the frontend keyed by your publishableKey and resolved server-side. Grade is not an SDK input, output, state field, export, or error.
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 → advance / submit / timeout / retry / login │
│ declares supportedPcis: PciId[] for LOCAL frame classification │
│ never calls host renderer functions │
└──────────────────────────────────────────────────────────────────────┘
│
▼ every advance request body is exactly:
{ intent }
┌──────────────────────────────────────────────────────────────────────┐
│ Layer 2 — Primer server │
│ derives the learner's course-grade from the frontend (publishableKey)│
│ serves the single canonical next route; never filters by PCI │
│ grades submissions, advances curriculum, returns the 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 │
└──────────────────────────────────────────────────────────────────────┘
Invariants integrators should memorize
start()never invokes renderer functions. There is no renderer registry on the state machine.supportedPcisis a readonly array of PCI id strings the SDK uses to classify incoming frames locally.- PCI rendering is orthogonal to the state machine. When
state.kind === "portable-custom", the SDK gives youpciId,properties, andsubmit(value). How you paint that on screen is entirely host code. PciRenderPropsis an optional host-renderer contract, exported for component authors. Import it when typing your PCI component. Do not pass it tostart().- The SDK has no React dependency.
package.jsondepends onerrors,validate, andpinoonly. Examples in this README may show React for familiarity; the samestart()options work in any runtime. - States are live closures. Every state object carries a
toJSONthat throwsErrNotSerializable— do notJSON.stringify, structured-clone, or persist aPrimerState. Hold it in memory and replace it on every transition. - Transitions are memoized. Calling
advance(),submit*(),timeout(),retry(), orlogin()twice on the same state returns the same in-flight promise — a double-clicked button cannot double-submit.
What the SDK does not do
PrimerTives intentionally does not:
- render Portable Custom Interactions or standard interactions
- require React, Vue, Svelte, or any UI framework
- ship UI components in the npm package
- call functions you pass in
startoptions (you do not pass functions) - negotiate PCI support implicitly — you declare
supportedPcisexplicitly - offer route choice — the server serves the single canonical next route, and the SDK materializes it directly as a frame state
- expose a
snapshot()or any serialization of live state
Quick Start
A host declares which Portable Custom Interactions (PCIs) it can render in supportedPcis. This is a LOCAL capability declaration — the SDK's frame classifier uses the list to decide renderability (an undeclared PCI resolves to the unsupported-PCI fatal path); nothing about capability rides the wire and the server never filters by it. 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.
import { start } from "@superbuilders/primer-tives/client"
import type { PrimerState } from "@superbuilders/primer-tives/types"
import { logger } from "@/logger"
const state: PrimerState<"urn:primer:pci:fraction-input"> = await start({
publishableKey: "pk_...",
supportedPcis: ["urn:primer:pci:fraction-input"],
logger
})
Start options
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
}
publishableKey— identifies the frontend; the server derives the learner's course-grade from it.supportedPcis— required, no default. A "supports everything" host passes all ids at the call site.origin— Primer API origin; defaults tohttps://primerlearn.dev.authOrigin— origin for the hosted-auth popup; defaults toorigin.fetch— optional fetch override (testing, instrumentation). The SDK otherwise usesglobalThis.fetch.abort— optionalAbortController; aborting cancels in-flight transport.logger— requiredpinologger.
Two auth modes
Both modes are first-class. start has two overloads selected by the presence of accessToken; both return Promise<PrimerState<Pcis>>.
// Managed auth: Primer owns sign-in. No token is passed; the SDK uses its
// cached session token, or returns sign-in-required whose login() runs the
// hosted OAuth popup. Mid-session expiry produces session-expired with login().
const managed = await start({ publishableKey, supportedPcis, logger })
// Access-token mode: the host owns auth and feeds a token. The SDK never
// offers sign-in for a host-owned token: an invalid or expired token resolves
// to a fatal state ("invalid-access-token" / "token-expired"), and mid-session
// rejection is fatal rather than session-expired. Sign-in phases can still
// appear in the static type — the mode split is behavioral, not type-level.
const hostOwned = await start({ publishableKey, supportedPcis, logger, accessToken })
login() must be called synchronously inside a user gesture (click/tap handler) so the hosted-auth popup survives popup blockers.
The State Machine
PrimerState is the exhaustive union of every phase the runtime can be in. This is the real declaration (client/types/runtime-state.ts):
type PrimerState<Pcis extends PciId = PciId> =
| SignInRequiredState<Pcis>
| SignInFailedState<Pcis>
| SessionExpiredState<Pcis>
| AuthUnavailableState
| AuthConfigInvalidState
| RuntimeState<Pcis> // ObservationState | InteractionState | FeedbackState
| CompletedState
| ErroredState<Pcis>
| NotEntitledState
| PlacementPendingState
| PlacementRequiredState
| FatalState
Fourteen phases, discriminated by state.phase:
phase | State | Transition methods | Meaning |
|---|---|---|---|
sign-in-required | SignInRequiredState | login() | Managed auth needs the learner to sign in |
sign-in-failed | SignInFailedState | login() | Hosted sign-in failed; retriable via a new gesture |
session-expired | SessionExpiredState | login() | Managed session expired mid-run; journey is last-known display context |
auth-unavailable | AuthUnavailableState | — | Hosted auth cannot run in this runtime (no storage/popup capability) |
auth-config-invalid | AuthConfigInvalidState | — | Hosted auth public configuration is invalid; terminal for the config |
not-entitled | NotEntitledState | — | Authenticated learner denied access; message is display-ready |
placement-required | PlacementRequiredState | — | A server-side placement decision is needed first; message is display-ready |
placement-pending | PlacementPendingState | — | Background placement in progress; message is display-ready |
observation | ObservationState | advance() | Content-only frame; advancing records completion |
interaction | InteractionState | submit*(), timeout() | Answerable frame; six kinds discriminated by state.kind |
feedback | FeedbackState | advance() | Graded result; verdict is "correct" | "incorrect" | "timedOut" |
completed | CompletedState | — | Current runtime scope finished; carries journey |
errored | ErroredState | retry() | Retriable failure; retryAfterMs carries server backoff hints |
fatal | FatalState | — | Terminal failure; code names the error class |
Frame states resolve directly from the server's routing: advance() from an observation or feedback resolves to the next frame state (observation or interaction), completed, or a failure state. There is no intermediate routing state on the client.
The runtime loop
Hold one PrimerState, render by phase, replace it with the resolved value of a transition. The union is exhaustive — satisfies never in the default arm makes the compiler prove you handled every phase.
import type { PrimerState } from "@superbuilders/primer-tives/types"
function render(state: PrimerState, replace: (next: PrimerState) => void) {
switch (state.phase) {
case "sign-in-required":
case "sign-in-failed":
return <button onClick={() => void state.login().then(replace)}>Sign in</button>
case "session-expired":
return <button onClick={() => void state.login().then(replace)}>Sign in again</button>
case "auth-unavailable":
case "auth-config-invalid":
return <ErrorScreen message={state.error.message} />
case "not-entitled":
case "placement-required":
case "placement-pending":
return <NoticeScreen message={state.message} />
case "observation":
return <Frame body={state.body} stimulus={state.stimulus}
onContinue={() => void state.advance().then(replace)} />
case "interaction":
return <Interaction state={state} replace={replace} />
case "feedback":
return <Feedback state={state}
onContinue={() => void state.advance().then(replace)} />
case "completed":
return <CompletionScreen journey={state.journey} />
case "errored":
return <RetryScreen code={state.code}
onRetry={() => void state.retry().then(replace)} />
case "fatal":
return <ErrorScreen message={state.error.message} />
default:
return state satisfies never
}
}
Transition promises never reject for expected conditions — auth loss, entitlement denial, server errors, and grading all resolve to states in the union. A rejected transition promise is a bug or an environment failure; treat it as fatal in host code.
Frame States
Every frame state carries journey (display progress), events (the deltas the advance that produced this state caused — see Journey and events), body: ContentBlock[], and stimulus: RendererStimulus | null (currently always an image when present).
Observation
interface ObservationState<Pcis extends PciId = PciId> extends NonSerializable {
readonly phase: "observation"
readonly journey: Journey
readonly events: AdvanceEvent[]
readonly body: ContentBlock[]
readonly stimulus: RendererStimulus | null
advance(): Promise<ObservationAdvanceNext<Pcis>>
}
advance() records the frame completion server-side and resolves directly to the next frame state, completed, or a failure state.
Interaction
Six kinds, discriminated by state.kind (and cardinality for extended text). All interaction states share revision, rejection, a kind-specific submit method, and timeout().
kind | State | Submit | Kind-specific fields |
|---|---|---|---|
choice | ChoiceState | submitChoice(selectedKeys: string[]) | options: RendererChoice[], minChoices, maxChoices |
text-entry | TextEntryState | submitText(value: string) | — |
extended-text (cardinality: "single") | ExtendedTextSingleState | submitText(value: string) | — |
extended-text (cardinality: "multiple") | ExtendedTextMultipleState | submitTexts(values: string[]) | interaction.minStrings, interaction.maxStrings |
order | OrderState | submitOrder(orderedKeys: string[]) | choices: RendererChoice[], minChoices, maxChoices |
match | MatchState | submitMatch(pairs: MatchPair[]) | sourceChoices, targetChoices, minAssociations, maxAssociations |
portable-custom | PciInteractionState | submit(value: PciValue<K>) | pciId: K, properties: PciProps<K> |
Each state's interaction field carries the full renderer interaction contract (prompt and constraints); presentation hints like placeholderText and expectedLines live there.
Submission rejection. A locally invalid submission (fails correlateSubmission against the interaction) does not travel: submit resolves to a rebuilt interaction state whose rejection: { content: ContentInline[] } carries the validation feedback. A server-side invalid_submission produces the same shape. Render rejection.content and let the learner try again — the state is still live.
Revision. When the server grades an attempt as recoverable, the same frame comes back as a fresh interaction state with revision populated:
type Revision<Previous> = {
readonly feedback: ContentInline[]
readonly previous: Previous // the learner's prior answer, typed per kind
readonly revisionsRemaining: number
readonly finalAttempt: boolean
}
Prefill the control from revision.previous, render revision.feedback, and disable "one more try" affordances when finalAttempt is true.
Timeout. timeout() reports that the learner ran out of time on the frame. It resolves like a submit: the server grades the frame with verdict "timedOut" and returns a feedback state with no submission and no review. timeout() and the submit method share the same memoization slot — whichever fires first wins, and the loser returns the winner's promise.
Feedback
type FeedbackState<Pcis extends PciId = PciId> =
| AnsweredFeedbackState<Pcis> // verdict: "correct" | "incorrect"
| TimedOutFeedbackState<Pcis> // verdict: "timedOut"
Answered feedback carries interaction, the learner's submission, feedbackContent: ContentInline[], and review: InteractionReview | null (the correct answer, when the server discloses it). Timed-out feedback carries interaction and feedbackContent only — there is no submission and no review to show.
advance() follows the server's embedded total next: if the graded write already served the next route, it resolves locally with zero network round trips; if next was pending, it performs exactly one continue request. Either way the resolved value is the next frame state, completed, or a failure state.
Completed
interface CompletedState extends NonSerializable {
readonly phase: "completed"
readonly journey: Journey
readonly events: AdvanceEvent[]
}
Terminal for the current runtime scope. journey carries the final display progress.
Journey and events
type JourneyProgress = { done: number; total: number }
type Journey = {
course: { title: string; progress: JourneyProgress }
lesson: { id: string; title: string; progress: JourneyProgress } | null
xp: { total: number }
}
Display-only learner progress: it never selects content and never changes Primer routing. lesson is null between lessons. lesson.id is the course lesson's uuid — the identity to key lesson-boundary detection and dedup on (titles are display-only and not unique). xp.total is everything earned as of this response — the award-ledger sum plus the current lesson attempt's live accrual, composed server-side — always present; a fresh run carries 0. Every frame, feedback, and completed state carries the journey; session-expired, errored, and fatal carry journey: Journey | null (last known, if any).
type AdvanceEvent =
| { kind: "xp_awarded"; id: string; completionId: string; lessonTitle: string; awardedXp: number }
| { kind: "lesson_completed"; id: string; lessonTitle: string }
events on a frame/feedback/completed state lists the deltas the advance that produced it caused, so the renderer can celebrate what just happened without diffing journeys. Each event is a projection of a persisted server ledger row and id is that row's uuidv7 — stable across retries, safe to use as a render key or dedup cursor. xp_awarded.completionId is the award row's deciding-completion column and equals the lesson_completed.id it belongs to — the exact join from an XP delta to its completion. The running total lives ONLY in journey.xp; an event carries only its delta. A recoverable re-render exposes an empty events list.
Failure States
Errored (retriable)
interface ErroredState<Pcis extends PciId = PciId> extends NonSerializable {
readonly phase: "errored"
readonly code: RetriableErrorCode
readonly journey: Journey | null
readonly error: Error
readonly retriable: true
readonly retryAfterMs: number
retry(): Promise<RetryNext<Pcis>>
}
type RetriableErrorCode =
| "network"
| "timeout"
| "json-parse"
| "server-error"
| "service-unavailable"
| "rate-limited"
| "dynamic-target-not-ready"
retry() re-executes the failed intent and is memoized. retryAfterMs is 0 unless the server sent a Retry-After hint. The dynamic-target-not-ready code is the polling contract for background content generation: keep the learner where they are and call retry() on an interval until it resolves to a real state.
Fatal (terminal)
interface FatalState extends NonSerializable {
readonly phase: "fatal"
readonly code: FatalCode
readonly journey: Journey | null
readonly error: Error
readonly retriable: false
}
type FatalCode =
| "bad-request"
| "invalid-json"
| "wire-contract-violation"
| "invalid-publishable-key"
| "missing-publishable-key"
| "invalid-access-token"
| "missing-authorization"
| "unsupported-issuer"
| "token-expired"
| "sdk-upgrade-required"
| "unsupported-pci"
| "content-ungradeable"
| "routing-misconfigured"
| "origin-not-allowed"
| "forbidden"
| "not-found"
| "target-escalated"
No transition methods. The learner-visible remedy is a reload or operator intervention. not-found typically means a misconfigured origin pointing at a route that 404s.
Learner-access states
not-entitled, placement-required, and placement-pending are authenticated-but-denied states. Each carries message: string — display-ready copy from the wire-error registry (or the server's more specific detail when it sends one) — and error for classification. Render message; there is nothing to branch on beyond the phase.
Self-healing (invisible to hosts)
Four wire errors never surface as states: offer_not_found, offer_superseded, frame_already_completed, and event_kind_mismatch mean the client's frame reference went stale (another tab answered, the offer rotated). The SDK responds by re-fetching the current state with one continue — the transition you called simply resolves to wherever the learner actually is.
Reauth
A token_expired / invalid_token wire error during a managed session resolves to session-expired (with login()); in access-token mode it resolves to fatal with the corresponding code, because the host owns the token.
Errors
Classify failures with errors.is from @superbuilders/errors against the exported sentinels — never by message string.
import * as errors from "@superbuilders/errors"
import { ErrUnsupportedPci } from "@superbuilders/primer-tives/errors"
if (state.phase === "fatal" && errors.is(state.error, ErrUnsupportedPci)) {
// this deployment served a PCI the host did not declare
}
Complete sentinel export set
From @superbuilders/primer-tives/errors (43 sentinels, regenerated from errors/sentinels.ts):
Auth: ErrAuthCallbackInvalid, ErrAuthCancelled, ErrAuthCodeExchangeFailed, ErrAuthConfigInvalid, ErrAuthPopupBlocked, ErrAuthStateMismatch, ErrAuthUnavailable, ErrInvalidAccessToken, ErrMalformedAccessToken, ErrMissingAuthorization, ErrTokenExpired, ErrUnsupportedIssuer.
Wire/server: ErrBadRequest, ErrContentUngradeable, ErrDynamicTargetNotReady, ErrEventKindMismatch, ErrForbidden, ErrFrameAlreadyCompleted, ErrFrontendUnassigned, ErrInvalidJson, ErrInvalidPublishableKey, ErrInvalidSubmission, ErrMissingPublishableKey, ErrNotEnrolled, ErrOfferNotFound, ErrOfferSuperseded, ErrOriginNotAllowed, ErrPlacementPending, ErrPlacementRequired, ErrRosterIdentityMissing, ErrDuplicateActiveRun, ErrPlacementIntegrity, ErrSdkUpgradeRequired, ErrServerError, ErrTargetEscalated.
Transport/local: ErrJsonParse, ErrNetwork, ErrNotFound, ErrNotSerializable, ErrRateLimited, ErrServiceUnavailable, ErrTimeout, ErrUnsupportedPci, ErrWireContractViolation.
Also exported: ADVANCE_WIRE_ERRORS (below) and the AuthRelayReason type ("expired" | "code_exchange_failed" | "token_invalid" — the three reasons the auth relay actually emits).
The wire-error registry
ADVANCE_WIRE_ERRORS is the single classification authority: one row per wire error code, carrying the HTTP status, the client sentinel, the state-machine routing category, and the default user-facing message. The server emits from it; the client parses and routes with it. All 27 codes (regenerated from errors/advance-wire-errors.ts):
| Wire code | HTTP | Sentinel | Routing |
|---|---|---|---|
invalid_json | 400 | ErrInvalidJson | fatal invalid-json |
invalid_request | 400 | ErrBadRequest | fatal bad-request |
invalid_submission | 400 | ErrInvalidSubmission | rejection (rebuilt interaction state) |
sdk_upgrade_required | 400 | ErrSdkUpgradeRequired | fatal sdk-upgrade-required |
token_expired | 401 | ErrTokenExpired | reauth (fallback fatal token-expired) |
missing_publishable_key | 401 | ErrMissingPublishableKey | fatal missing-publishable-key |
invalid_publishable_key | 401 | ErrInvalidPublishableKey | fatal invalid-publishable-key |
missing_authorization | 401 | ErrMissingAuthorization | fatal missing-authorization |
invalid_token | 401 | ErrInvalidAccessToken | reauth (fallback fatal invalid-access-token) |
unsupported_issuer | 401 | ErrUnsupportedIssuer | fatal unsupported-issuer |
auth_unavailable | 503 | ErrAuthUnavailable | retriable service-unavailable |
origin_not_allowed | 403 | ErrOriginNotAllowed | fatal origin-not-allowed |
frontend_unassigned | 403 | ErrFrontendUnassigned | learner-access not-entitled |
roster_identity_missing | 403 | ErrRosterIdentityMissing | learner-access not-entitled |
not_enrolled | 403 | ErrNotEnrolled | learner-access not-entitled |
placement_required | 409 | ErrPlacementRequired | learner-access placement-required |
placement_pending | 409 | ErrPlacementPending | learner-access placement-pending |
offer_not_found | 409 | ErrOfferNotFound | self-heal |
offer_superseded | 409 | ErrOfferSuperseded | self-heal |
frame_already_completed | 409 | ErrFrameAlreadyCompleted | self-heal |
event_kind_mismatch | 409 | ErrEventKindMismatch | self-heal |
duplicate_active_run | 500 | ErrDuplicateActiveRun | fatal routing-misconfigured |
placement_integrity | 500 | ErrPlacementIntegrity | fatal routing-misconfigured |
content_ungradeable | 500 | ErrContentUngradeable | fatal content-ungradeable |
dynamic_target_not_ready | 503 | ErrDynamicTargetNotReady | retriable dynamic-target-not-ready |
target_escalated | 409 | ErrTargetEscalated | fatal target-escalated |
internal_server_error | 500 | ErrServerError | retriable server-error |
Responses whose body carries no recognized wire code fall back by HTTP status (400 → ErrBadRequest, 401 → ErrInvalidAccessToken, 403 → ErrForbidden, 404 → ErrNotFound, 429 → ErrRateLimited, 502/503/504 → ErrServiceUnavailable, anything else → ErrServerError). This fallback is load-bearing for origin_not_allowed: the server reflects no ACAO header to a blocked origin, so a browser usually cannot read the coded 403 body (the fetch often rejects outright and classifies as ErrNetwork); the coded ErrOriginNotAllowed classification is reached by non-browser callers and same-origin tooling.
PCI: Portable Custom Interactions
The PCI type vocabulary is registry-derived:
PciId— the union of registered PCI ids (currently"urn:primer:pci:fraction-input").PciProps<K>— the authored properties for PCIK(for fraction-input:{ form: FractionInputForm; requireSimplified: boolean }).PciValue<K>— the submission value for PCIK(for fraction-input: the discriminated union overform).
When the state machine reaches a portable-custom interaction, render your own component from state.properties and call state.submit(value). PciRenderProps<K> is an optional prop-typing contract for that component:
type PciRenderProps<K extends PciId> =
| { mode: "pending"; properties: PciProps<K>; onValueChange: (value: PciValue<K> | null) => void }
| { mode: "submitted"; properties: PciProps<K>; submission: PciValue<K>
review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null }
FractionInputPropsSchema and FractionInputSubmissionSchema are compiled validators for the fraction-input contract, exported for hosts that validate authored content or learner values at their own boundaries.
Content
type ContentInline =
| { type: "text"; value: string }
| { type: "italic"; value: string }
| { type: "latex"; value: string }
type ContentBlock = { type: "paragraph"; children: ContentInline[] }
latex values are LaTeX source for the host's math renderer. blocksToPlainText / inlinesToPlainText flatten content for logging, accessibility labels, or plain-text surfaces.
Submission Validation
The SDK validates every submission locally before it travels (that is where interaction-state rejection comes from). The same machinery is exported for hosts that validate at their own boundaries:
correlateSubmission(interaction, submission)— validates a submission against its interaction; returnsSubmissionValidationResult({ ok: true, value }or{ ok: false, issues }).submissionValidationMessage(failure)— flattens a failure's issues into one string.RendererSubmissionDraft07Schema— the raw JSON-schema (draft-07) forRendererSubmission, for embedding in external validators or tooling.
Wire Protocol (reference)
The SDK speaks one endpoint: POST /api/v0/advance with body { intent }.
type AdvanceIntent =
| { kind: "continue" }
| { kind: "frame_event"; offerId: string; event:
{ kind: "complete" } | { kind: "submit"; submission: RendererSubmission } | { kind: "timeout" } }
Responses are a total union on outcome:
frontier— a served route:{ journey, route: { frame, offerId, revision } }. The SDK materializes it directly as the frame state and fires the frame-open beacon exactly once.recoverable— the same frame re-served with a requiredrevision; already open, so no beacon.graded— verdict + feedback (+ submission/review when answered) + embedded totalnext.completed— the runtime scope is done.
Wire types (AdvanceRequest, AdvanceResult, AdvanceErrorCode, …) are exported from contracts for server and tooling code. Hosts driving the state machine never touch them.
Transport stamps X-Primer-SDK-Version on every request; the server accepts only its own SDK_MAJOR and rejects others with sdk_upgrade_required.
Logging
Pass a pino logger in start() options — it is required. The SDK logs transitions, transport failures, self-heals, and auth flow at appropriate levels; it never logs learner submissions at info or above. PrimerLogger is not a re-exported type: use import type { Logger } from "pino".
Testing your integration
This package does not ship a test suite. The seams for host-side testing:
fetchinstart()options accepts any fetch-compatible function, so a test can serve canned wire responses and drive the state machine deterministically without a server.- The state semantics worth asserting from a host: every transition returns a NEW state object (replace, never mutate); repeated calls to the same transition return the same promise (memoization);
toJSONthrowsErrNotSerializable; an undeclared PCI in a served frame resolves to fatalunsupported-pci.
Integration checklist
- Import
startfromclient, states fromtypes, data contracts fromcontracts, sentinels fromerrors. - Pass
publishableKey,supportedPcis, and apinologger. AddaccessTokenonly if the host owns auth. - Hold exactly one
PrimerStateand replace it on every resolved transition. Never serialize it. - Switch on
state.phasewith asatisfies neverdefault; switch onstate.kindinsideinteractionthe same way. - Call
login()synchronously inside a user gesture. - Render
rejection.contenton rejected submissions andrevision.feedback/revision.previouson recoverable frames. - Render
state.messagefornot-entitled,placement-required, andplacement-pending. - Poll
retry()onerroredwith codedynamic-target-not-ready; offer a retry button for the rest. - Classify
state.errorwitherrors.isagainst sentinels — never by message. - Treat
fatalas terminal: reload or operator intervention.