@superbuilders/primer-tives 2.2.1 → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +813 -446
  2. package/dist/client/auth/access-token.d.ts +10 -0
  3. package/dist/client/auth/access-token.d.ts.map +1 -0
  4. package/dist/client/auth/browser.d.ts +20 -0
  5. package/dist/client/auth/browser.d.ts.map +1 -0
  6. package/dist/client/auth/callback.d.ts +10 -0
  7. package/dist/client/auth/callback.d.ts.map +1 -0
  8. package/dist/client/auth/hosted-popup.d.ts +14 -0
  9. package/dist/client/auth/hosted-popup.d.ts.map +1 -0
  10. package/dist/client/auth/provider.d.ts +14 -0
  11. package/dist/client/auth/provider.d.ts.map +1 -0
  12. package/dist/client/auth/storage.d.ts +9 -0
  13. package/dist/client/auth/storage.d.ts.map +1 -0
  14. package/dist/client/create.d.ts +22 -25
  15. package/dist/client/create.d.ts.map +1 -1
  16. package/dist/client/create.type-test.d.ts +2 -0
  17. package/dist/client/create.type-test.d.ts.map +1 -0
  18. package/dist/client/index.d.ts +1 -1
  19. package/dist/client/index.d.ts.map +1 -1
  20. package/dist/client/index.js +336 -74
  21. package/dist/client/index.js.map +14 -10
  22. package/dist/client/runtime-subject.d.ts +4 -0
  23. package/dist/client/runtime-subject.d.ts.map +1 -0
  24. package/dist/client/session.d.ts +2 -2
  25. package/dist/client/session.d.ts.map +1 -1
  26. package/dist/client/transport.d.ts +6 -4
  27. package/dist/client/transport.d.ts.map +1 -1
  28. package/dist/errors.d.ts +7 -3
  29. package/dist/errors.d.ts.map +1 -1
  30. package/dist/errors.js +14 -6
  31. package/dist/errors.js.map +3 -3
  32. package/dist/subject-pcis.d.ts +11 -5
  33. package/dist/subject-pcis.d.ts.map +1 -1
  34. package/dist/subject-pcis.js +39 -0
  35. package/dist/subject-pcis.js.map +11 -0
  36. package/dist/subject.d.ts +1 -2
  37. package/dist/subject.d.ts.map +1 -1
  38. package/dist/subject.js.map +1 -1
  39. package/dist/version.d.ts +1 -1
  40. package/package.json +2 -6
  41. package/dist/server/create-server.d.ts +0 -17
  42. package/dist/server/create-server.d.ts.map +0 -1
  43. package/dist/server/exchange.d.ts +0 -16
  44. package/dist/server/exchange.d.ts.map +0 -1
  45. package/dist/server/index.d.ts +0 -3
  46. package/dist/server/index.d.ts.map +0 -1
  47. package/dist/server/index.js +0 -173
  48. package/dist/server/index.js.map +0 -12
package/README.md CHANGED
@@ -2,378 +2,346 @@
2
2
 
3
3
  TypeScript SDK primitives for the Primer adaptive learning runtime.
4
4
 
5
- The package gives you two runtime surfaces:
5
+ The public lifecycle is one async call:
6
6
 
7
- - A **server SDK** for trusted backend code. It exchanges an already-verified learner email for a short-lived Primer browser token.
8
- - A **browser SDK** for interactive UI code. It drives Primer's `/advance` state machine and returns typed states for observations, interactions, feedback, completion, retriable errors, and fatal errors.
7
+ ```txt
8
+ create(options) -> Promise<PrimerState>
9
+ ```
9
10
 
10
- Primer intentionally does not expose a public student-management lifecycle through this SDK. Your app proves identity; Primer resolves the internal student and routing state behind the token.
11
+ `create` resolves learner auth, opens the first Primer learning state, and returns the live state machine object your renderer drives.
11
12
 
12
13
  ```sh
13
14
  bun add @superbuilders/primer-tives
14
15
  ```
15
16
 
16
- The SDK uses sentinel errors from `@superbuilders/errors`. Use `errors.try()` to capture failures and `errors.is()` to classify them.
17
-
18
- ## Package Version
19
-
20
- The current SDK version is `2.2.1`.
17
+ ## Version
21
18
 
22
- The browser SDK sends `SDK_VERSION` as `X-Primer-SDK-Version` on `/api/v0/advance`. If the server requires a newer SDK, the browser state becomes `fatal` with `ErrSdkUpgradeRequired`.
19
+ The current SDK version is `3.5.1`.
23
20
 
24
21
  ## Entrypoints
25
22
 
26
- There is no package-root export. Pick the subpath for the runtime boundary you are on.
27
-
28
- | Subpath | Runtime | Owns |
29
- | --- | --- | --- |
30
- | `@superbuilders/primer-tives/server` | Backend only | `createPrimerServer`, `PrimerServer`, `PrimerServerConfig`, `GetTokenInput` |
31
- | `@superbuilders/primer-tives/client` | Browser | `create`, `Client`, `Config`, `PrimerState`, every state interface, PCI render props |
32
- | `@superbuilders/primer-tives/contracts` | Shared | Content, stimulus, interaction, submission, review, PCI types, Zod schemas, validation helpers |
33
- | `@superbuilders/primer-tives/errors` | Shared | Every SDK error sentinel |
34
- | `@superbuilders/primer-tives/logger` | Shared | `PrimerLogger` interface |
35
- | `@superbuilders/primer-tives/subject` | Shared | `Subject`, `SubjectScope`, `SUBJECTS` |
36
- | `@superbuilders/primer-tives/subject-pcis` | Shared | Required PCI helpers for subject scopes |
37
- | `@superbuilders/primer-tives/grade-level` | Shared | `GradeLevel`, `GRADE_LEVELS` |
38
-
39
- ## Architecture
23
+ There is no package-root export. Import from the public subpath that owns the surface you need.
40
24
 
41
- Primer's runtime has three identities in play:
42
-
43
- | Identity | Owner | Where it lives |
44
- | --- | --- | --- |
45
- | Host user | Your app | Your auth/user tables |
46
- | Verified email | Your backend proves it; Primer hashes it | Sent only from trusted backend to Primer |
47
- | Primer student | Primer | Internal DB, scoped to a Primer frontend |
48
-
49
- The SDK only asks your backend for `verifiedEmail`. That name is deliberate. It means your backend has already verified email ownership through your own auth system. Primer does not send a verification email in this flow.
50
-
51
- The backend token flow is:
52
-
53
- 1. Your app authenticates a user.
54
- 2. Your app verifies the user's email.
55
- 3. Your backend calls `primer.getToken({ verifiedEmail: user.email })` with a Primer `sk_...` secret key.
56
- 4. Primer normalizes and hashes the email server-side.
57
- 5. Primer resolves or creates the internal frontend-scoped student.
58
- 6. Primer returns a short-lived browser access token.
59
- 7. Your browser UI passes that token to `create({ accessToken, ... })`.
25
+ | Subpath | Owns |
26
+ | --- | --- |
27
+ | `@superbuilders/primer-tives/client` | `create`, `PrimerOptions`, `PrimerState`, all state interfaces, PCI render props |
28
+ | `@superbuilders/primer-tives/contracts` | Content, stimulus, interaction, submission, review, PCI types, schemas, validation helpers |
29
+ | `@superbuilders/primer-tives/errors` | Every SDK error sentinel |
30
+ | `@superbuilders/primer-tives/logger` | `PrimerLogger` interface |
31
+ | `@superbuilders/primer-tives/subject` | `Subject`, `SUBJECTS` |
32
+ | `@superbuilders/primer-tives/subject-pcis` | Subject-required PCI helpers and type helpers |
33
+ | `@superbuilders/primer-tives/grade-level` | `GradeLevel`, `GRADE_LEVELS` |
60
34
 
61
- Primer stores a secret-keyed SHA-512/256 digest of normalized email, not raw email. The browser never sees your Primer secret key and never sends the email to `/advance`.
35
+ ## Quick Start
62
36
 
63
- ## End-To-End Backend Example
37
+ Math content can require the fraction-input PCI capability, so a math renderer must declare it.
64
38
 
65
39
  ```ts
66
- import * as errors from "@superbuilders/errors"
67
40
  import * as logger from "@superbuilders/slog"
68
- import { createPrimerServer } from "@superbuilders/primer-tives/server"
69
- import { ErrBadRequest, ErrInvalidSecretKey, ErrNetwork, ErrTimeout } from "@superbuilders/primer-tives/errors"
41
+ import { create } from "@superbuilders/primer-tives/client"
70
42
 
71
- const primer = createPrimerServer({
43
+ let state = await create({
72
44
  origin: "https://primerlearn.dev",
73
- secretKey: process.env.PRIMER_SECRET_KEY,
45
+ publishableKey: "pk_...",
46
+ subject: "math",
47
+ supportedPcis: ["urn:primer:pci:fraction-input"],
74
48
  logger
75
49
  })
76
-
77
- async function getPrimerAccessToken(user: { email: string; emailVerified: boolean }): Promise<string> {
78
- if (!user.emailVerified) {
79
- logger.error("user email not verified")
80
- throw errors.new("email not verified")
81
- }
82
-
83
- const result = await errors.try(primer.getToken({ verifiedEmail: user.email }))
84
- if (result.error) {
85
- if (errors.is(result.error, ErrInvalidSecretKey)) {
86
- logger.error("primer secret key invalid", { error: result.error })
87
- throw errors.wrap(result.error, "primer token")
88
- }
89
- if (errors.is(result.error, ErrBadRequest)) {
90
- logger.error("primer token request rejected", { error: result.error })
91
- throw errors.wrap(result.error, "primer token")
92
- }
93
- if (errors.is(result.error, ErrNetwork) || errors.is(result.error, ErrTimeout)) {
94
- logger.error("primer token transport failed", { error: result.error })
95
- throw errors.wrap(result.error, "primer token")
96
- }
97
- logger.error("primer token failed", { error: result.error })
98
- throw errors.wrap(result.error, "primer token")
99
- }
100
-
101
- return result.data
102
- }
103
50
  ```
104
51
 
105
- ## End-To-End Browser Example
52
+ If your application already has a learner access token, pass it directly. The SDK will use that token and skip SDK-managed auth.
106
53
 
107
54
  ```ts
108
- import * as logger from "@superbuilders/slog"
109
- import { create, type PrimerState } from "@superbuilders/primer-tives/client"
110
-
111
- const client = create({
55
+ let state = await create({
112
56
  origin: "https://primerlearn.dev",
57
+ publishableKey: "pk_...",
113
58
  accessToken,
114
59
  subject: "math",
115
60
  supportedPcis: ["urn:primer:pci:fraction-input"],
116
61
  logger
117
62
  })
63
+ ```
118
64
 
119
- let state: PrimerState = await client.start()
120
-
121
- while (state.phase !== "completed" && state.phase !== "fatal") {
122
- switch (state.phase) {
123
- case "observation": {
124
- renderFrame(state.body, state.stimulus)
125
- state = await state.advance()
126
- break
127
- }
128
- case "interaction": {
129
- state = await renderAndSubmitInteraction(state)
130
- break
131
- }
132
- case "feedback": {
133
- renderFeedback(state.feedbackContent, state.isCorrect, state.review)
134
- state = await state.advance()
135
- break
136
- }
137
- case "errored": {
138
- if (state.retriable) {
139
- state = await state.retry()
140
- break
141
- }
142
- throw state.error
143
- }
144
- }
145
- }
65
+ Vocabulary and science currently have no required PCI capabilities, so `supportedPcis` can be omitted for those subjects.
146
66
 
147
- if (state.phase === "fatal") {
148
- throw state.error
149
- }
67
+ ```ts
68
+ let state = await create({
69
+ origin: "https://primerlearn.dev",
70
+ publishableKey: "pk_...",
71
+ subject: "vocabulary",
72
+ logger
73
+ })
150
74
  ```
151
75
 
152
- # `/server`
153
-
154
- The server subpath is backend-only. It wraps the token endpoint and hides raw HTTP response details.
76
+ 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.
155
77
 
156
78
  ```ts
157
- import { createPrimerServer } from "@superbuilders/primer-tives/server"
158
- import type { GetTokenInput, PrimerServer, PrimerServerConfig } from "@superbuilders/primer-tives/server"
79
+ let state = await create({
80
+ origin: "https://primerlearn.dev",
81
+ publishableKey: "pk_...",
82
+ supportedPcis: ["urn:primer:pci:fraction-input"],
83
+ logger
84
+ })
159
85
  ```
160
86
 
161
- ## `createPrimerServer(config)`
87
+ ## `create(options)`
162
88
 
163
89
  ```ts
164
- interface PrimerServerConfig {
165
- readonly origin: string
166
- readonly secretKey: string
167
- readonly fetch?: typeof globalThis.fetch
168
- readonly abort?: AbortController
169
- readonly logger: PrimerLogger
170
- }
171
-
172
- function createPrimerServer(config: PrimerServerConfig): PrimerServer
90
+ function create<
91
+ const S extends Subject | undefined = undefined,
92
+ const Supported extends readonly PciId[] = []
93
+ >(options: PrimerOptions<S, Supported>): Promise<PrimerState>
173
94
  ```
174
95
 
175
- Config fields:
96
+ `create` can fail in two different ways:
176
97
 
177
- | Field | Required | Description |
178
- | --- | --- | --- |
179
- | `origin` | Yes | Primer deployment origin, for example `https://primerlearn.dev`. |
180
- | `secretKey` | Yes | Primer `sk_...` frontend secret. Keep it on the backend. |
181
- | `fetch` | No | Custom fetch for tests, proxies, tracing, or platform adapters. Defaults to `globalThis.fetch`. |
182
- | `abort` | No | `AbortController`; its signal is passed to every server SDK request. |
183
- | `logger` | Yes | Structured logger implementing `debug`, `info`, `warn`, `error`. |
98
+ | Moment | Behavior |
99
+ | --- | --- |
100
+ | Auth or local token resolution fails | `create` rejects with an SDK sentinel error. |
101
+ | The learning runtime cannot produce a normal first state | `create` resolves to `ErroredState` or `FatalState`. |
184
102
 
185
- ## `PrimerServer`
103
+ This distinction matters. Use `errors.try(create(...))` for startup failures, then handle `state.phase` for learning-state failures.
186
104
 
187
105
  ```ts
188
- interface PrimerServer {
189
- getToken(input: GetTokenInput): Promise<string>
190
- }
191
-
192
- type GetTokenInput = {
193
- readonly verifiedEmail: string
106
+ import * as errors from "@superbuilders/errors"
107
+ import * as logger from "@superbuilders/slog"
108
+ import { create } from "@superbuilders/primer-tives/client"
109
+ import { ErrAuthPopupBlocked, ErrMalformedAccessToken } from "@superbuilders/primer-tives/errors"
110
+
111
+ const result = await errors.try(
112
+ create({
113
+ origin,
114
+ publishableKey,
115
+ accessToken,
116
+ subject: "math",
117
+ supportedPcis: ["urn:primer:pci:fraction-input"],
118
+ logger
119
+ })
120
+ )
121
+ if (result.error) {
122
+ if (errors.is(result.error, ErrAuthPopupBlocked)) {
123
+ renderStartButtonAgain()
124
+ return
125
+ }
126
+ if (errors.is(result.error, ErrMalformedAccessToken)) {
127
+ renderSignInAgain()
128
+ return
129
+ }
130
+ logger.error("primer create failed", { error: result.error })
131
+ throw result.error
194
132
  }
195
- ```
196
-
197
- `getToken` returns the browser access token string directly. It does not return a session object because the browser only needs the token.
198
-
199
- The SDK sends this wire body:
200
133
 
201
- ```json
202
- {
203
- "verified_email": "student@example.com"
204
- }
134
+ let state = result.data
205
135
  ```
206
136
 
207
- The raw API response includes token metadata and internal student identity, but the SDK deliberately returns only `access_token`. That keeps the public SDK focused on the one value the app has to pass to the browser runtime.
208
-
209
- ## `verifiedEmail`
210
-
211
- `verifiedEmail` must be server-trusted. The SDK does not validate that the user owns this address. It only validates that Primer accepts the request shape.
212
-
213
- Correct usage:
137
+ ## `PrimerOptions`
214
138
 
215
139
  ```ts
216
- const accessToken = await primer.getToken({ verifiedEmail: authenticatedUser.email })
140
+ type PrimerOptions<S extends Subject | undefined = undefined, Supported extends readonly PciId[] = []> = {
141
+ readonly origin: string
142
+ readonly publishableKey: string
143
+ readonly accessToken?: string
144
+ readonly subject?: S
145
+ readonly supportedPcis: subject-dependent
146
+ readonly fetch?: typeof globalThis.fetch
147
+ readonly abort?: AbortController
148
+ readonly logger: PrimerLogger
149
+ }
217
150
  ```
218
151
 
219
- Only do this after your own auth system has verified `authenticatedUser.email`.
220
-
221
- Incorrect usage:
152
+ | Field | Required | Meaning |
153
+ | --- | --- | --- |
154
+ | `origin` | Yes | Primer deployment origin. |
155
+ | `publishableKey` | Yes | Public key identifying the Primer frontend your runtime belongs to. |
156
+ | `accessToken` | No | Learner access token. When present, SDK-managed auth is skipped. |
157
+ | `subject` | No | Public content scope: `"math"`, `"vocabulary"`, or `"science"`. Omitted means all-subject scope. |
158
+ | `supportedPcis` | Subject-dependent | Renderer capabilities for Portable Custom Interactions. Required when the chosen scope can emit required PCIs. |
159
+ | `fetch` | No | Fetch override for tests, instrumentation, or host runtime integration. |
160
+ | `abort` | No | Abort controller for SDK runtime work. |
161
+ | `logger` | Yes | Structured logger implementing `debug`, `info`, `warn`, and `error`. |
222
162
 
223
- ```ts
224
- const accessToken = await primer.getToken({ verifiedEmail: request.body.email })
225
- ```
163
+ The presence or absence of `accessToken` selects auth semantics.
226
164
 
227
- That treats an untrusted browser payload as identity proof and defeats the security model.
165
+ | Shape | Semantics |
166
+ | --- | --- |
167
+ | `accessToken` present | The SDK validates the token shape locally and uses it for learning runtime state. |
168
+ | `accessToken` absent | The SDK resolves a learner token through SDK-managed browser auth, then starts learning runtime state. |
228
169
 
229
- ## Server Error Sentinels
170
+ The public API does not expose separate auth plumbing, auth result objects, or any auth lifecycle method. Auth is part of `create`.
230
171
 
231
- Import sentinels from `/errors`, not `/server`.
172
+ ## Auth Semantics
232
173
 
233
- ```ts
234
- import { ErrInvalidSecretKey, ErrBadRequest } from "@superbuilders/primer-tives/errors"
235
- ```
174
+ An access token is expected to be JWS-shaped: it starts with `eyJ` and contains exactly two dots. If a provided or SDK-managed token does not match that shape, `create` rejects with `ErrMalformedAccessToken`.
236
175
 
237
- `getToken` can surface:
176
+ SDK-managed auth may require browser capabilities and learner interaction. These failures reject `create`:
238
177
 
239
- | Sentinel | Raised when |
178
+ | Sentinel | Meaning |
240
179
  | --- | --- |
241
- | `ErrInvalidSecretKey` | Primer returned HTTP 401. The `sk_...` key is missing, malformed, inactive, or unknown. |
242
- | `ErrBadRequest` | Primer returned HTTP 400. The request body was invalid, usually a malformed email. |
243
- | `ErrServerError` | Primer returned another unexpected non-2xx status. |
244
- | `ErrJsonParse` | Primer returned 2xx but the response body was not valid JSON or lacked `access_token`. |
245
- | `ErrNetwork` | `fetch` rejected before a response arrived. |
246
- | `ErrTimeout` | Fetch was aborted. |
180
+ | `ErrAuthUnavailable` | SDK-managed auth requires browser functionality that is unavailable in the current runtime. |
181
+ | `ErrAuthConfigInvalid` | SDK-managed auth was given invalid public configuration. |
182
+ | `ErrAuthCallbackInvalid` | The auth result could not be accepted as a successful learner auth result. |
183
+ | `ErrAuthStateMismatch` | The auth result did not match the auth attempt that initiated it. |
184
+ | `ErrAuthPopupBlocked` | The browser blocked the learner auth window. |
185
+ | `ErrAuthCancelled` | The learner auth interaction was closed or exceeded its allowed time. |
186
+ | `ErrMalformedAccessToken` | The resolved token was not shaped like a learner access token. |
247
187
 
248
- Recommended pattern:
188
+ Applications should handle user-actionable auth failures directly and log unexpected failures before propagating them.
249
189
 
250
190
  ```ts
251
191
  import * as errors from "@superbuilders/errors"
252
192
  import * as logger from "@superbuilders/slog"
253
- import { ErrBadRequest, ErrInvalidSecretKey } from "@superbuilders/primer-tives/errors"
193
+ import {
194
+ ErrAuthCancelled,
195
+ ErrAuthPopupBlocked,
196
+ ErrAuthUnavailable
197
+ } from "@superbuilders/primer-tives/errors"
254
198
 
255
- const result = await errors.try(primer.getToken({ verifiedEmail }))
199
+ const result = await errors.try(create(options))
256
200
  if (result.error) {
257
- if (errors.is(result.error, ErrInvalidSecretKey)) {
258
- logger.error("primer secret key invalid", { error: result.error })
259
- throw errors.wrap(result.error, "primer token")
201
+ if (errors.is(result.error, ErrAuthPopupBlocked)) {
202
+ renderPopupInstructions()
203
+ return
260
204
  }
261
- if (errors.is(result.error, ErrBadRequest)) {
262
- logger.error("primer token request rejected", { error: result.error })
263
- throw errors.wrap(result.error, "primer token")
205
+ if (errors.is(result.error, ErrAuthCancelled)) {
206
+ renderTryAgain()
207
+ return
264
208
  }
265
- logger.error("primer token failed", { error: result.error })
266
- throw errors.wrap(result.error, "primer token")
209
+ if (errors.is(result.error, ErrAuthUnavailable)) {
210
+ renderUnsupportedBrowserMessage()
211
+ return
212
+ }
213
+ logger.error("primer auth failed", { error: result.error })
214
+ throw result.error
267
215
  }
268
-
269
- const accessToken = result.data
270
216
  ```
271
217
 
272
- # `/client`
218
+ ## Subject And PCI Contract
273
219
 
274
- The client subpath owns the browser runtime state machine.
220
+ `subject` selects content scope and determines required renderer capabilities.
275
221
 
276
222
  ```ts
277
- import { create } from "@superbuilders/primer-tives/client"
278
- import type { Client, Config, PrimerState } from "@superbuilders/primer-tives/client"
223
+ import { SUBJECTS } from "@superbuilders/primer-tives/subject"
224
+ import type { Subject } from "@superbuilders/primer-tives/subject"
225
+
226
+ const subjects = SUBJECTS
227
+ type RuntimeSubject = Subject
279
228
  ```
280
229
 
281
- ## `create(config)`
230
+ Current public subjects:
282
231
 
283
- ```ts
284
- function create<const S extends SubjectScope, const Pcis extends PciId = never>(
285
- config: Config<S, Pcis>
286
- ): Client<PciId>
287
- ```
232
+ | Subject | Required PCI support |
233
+ | --- | --- |
234
+ | `"math"` | `"urn:primer:pci:fraction-input"` |
235
+ | `"vocabulary"` | none |
236
+ | `"science"` | none |
237
+ | omitted subject | union of all subject-required PCIs, currently `"urn:primer:pci:fraction-input"` |
288
238
 
289
- Config shape:
239
+ The type-level rule is a subset check:
290
240
 
291
- ```ts
292
- type Config<S extends SubjectScope, Pcis extends PciId = never> = {
293
- readonly accessToken: string
294
- readonly subject: S
295
- readonly origin: string
296
- readonly fetch?: typeof globalThis.fetch
297
- readonly abort?: AbortController
298
- readonly logger: PrimerLogger
299
- } & PciConfigForSubject<S, Pcis>
241
+ ```txt
242
+ required PCIs for selected scope <= supportedPcis
300
243
  ```
301
244
 
302
- Fields:
245
+ Order does not matter. Extra supported PCIs are allowed. `supportedPcis` is a renderer capability declaration, not a content request.
303
246
 
304
- | Field | Required | Description |
305
- | --- | --- | --- |
306
- | `accessToken` | Yes | Token string returned by `primer.getToken`. |
307
- | `subject` | Yes | `"math"`, `"vocabulary"`, `"science"`, or `"all"`. |
308
- | `origin` | Yes | Primer deployment origin. |
309
- | `supportedPcis` | Conditionally | Literal array of PCI URNs your renderer supports. Required when the chosen subject can emit required PCIs. |
310
- | `fetch` | No | Custom fetch implementation. |
311
- | `abort` | No | `AbortController`; signal passed to `/advance`. |
312
- | `logger` | Yes | Structured logger. |
313
-
314
- `create` performs local checks before any network request:
247
+ This fails at compile time because math can emit a required PCI:
315
248
 
316
- - `accessToken` must look like a JWS: starts with `eyJ` and has two dots. Otherwise it throws `ErrMalformedAccessToken`.
317
- - `supportedPcis` must include all PCIs required by the selected subject. Missing support throws `ErrMissingRequiredPci` immediately and logs `renderer missing required pcis`.
249
+ ```ts
250
+ await create({
251
+ origin,
252
+ publishableKey,
253
+ subject: "math",
254
+ logger
255
+ })
256
+ ```
318
257
 
319
- ## `Client`
258
+ This passes:
320
259
 
321
260
  ```ts
322
- interface Client<Pcis extends PciId = PciId> {
323
- start(): Promise<PrimerState<Pcis>>
324
- }
261
+ await create({
262
+ origin,
263
+ publishableKey,
264
+ subject: "math",
265
+ supportedPcis: ["urn:primer:pci:fraction-input"],
266
+ logger
267
+ })
325
268
  ```
326
269
 
327
- `start()` sends the first observation request. It is idempotent: multiple calls return the same pending promise.
328
-
329
- ## Subject Scopes
270
+ If `subject` is a broad dynamic `Subject` value, TypeScript requires support for the union of all PCIs required by the possible subjects.
330
271
 
331
272
  ```ts
332
- import { SUBJECTS } from "@superbuilders/primer-tives/subject"
333
- import type { Subject, SubjectScope } from "@superbuilders/primer-tives/subject"
273
+ declare const subject: Subject
334
274
 
335
- const SUBJECTS = ["math", "vocabulary", "science"] as const
336
- type Subject = (typeof SUBJECTS)[number]
337
- type SubjectScope = Subject | "all"
275
+ await create({
276
+ origin,
277
+ publishableKey,
278
+ subject,
279
+ supportedPcis: ["urn:primer:pci:fraction-input"],
280
+ logger
281
+ })
338
282
  ```
339
283
 
340
- | Scope | Behavior |
341
- | --- | --- |
342
- | `"math"` | Runtime is scoped to math content and math-required PCIs. |
343
- | `"vocabulary"` | Runtime is scoped to vocabulary content. |
344
- | `"science"` | Runtime is scoped to science content. |
345
- | `"all"` | Runtime can draw from all subjects available to the Primer frontend. |
346
-
347
- To change subjects, create a new client. Placement state is server-owned and subject-scoped; the browser does not manage cursors.
284
+ 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`.
348
285
 
349
- ## Required PCIs
286
+ ## Runtime Loop
350
287
 
351
- Math currently requires the fraction-input PCI. Vocabulary and science currently require none.
288
+ Every renderer should switch on `state.phase`. Interaction rendering should then switch on `state.kind`.
352
289
 
353
290
  ```ts
354
- import { missingPcisForSubject, requiredPcisForSubject } from "@superbuilders/primer-tives/subject-pcis"
291
+ import * as logger from "@superbuilders/slog"
292
+ import type { PrimerState } from "@superbuilders/primer-tives/client"
355
293
 
356
- requiredPcisForSubject("math")
357
- // ["urn:primer:pci:fraction-input"]
358
- ```
294
+ async function runPrimer(initialState: PrimerState): Promise<void> {
295
+ let state = initialState
359
296
 
360
- For math or `all`, pass a `supportedPcis` literal array that includes `"urn:primer:pci:fraction-input"`.
297
+ while (state.phase !== "completed" && state.phase !== "fatal") {
298
+ switch (state.phase) {
299
+ case "observation":
300
+ renderFrame(state.body, state.stimulus)
301
+ state = await state.advance()
302
+ break
303
+ case "interaction":
304
+ state = await renderAndSubmitInteraction(state)
305
+ break
306
+ case "feedback":
307
+ renderFeedback(state.feedbackContent, state.isCorrect, state.review)
308
+ state = await state.advance()
309
+ break
310
+ case "errored":
311
+ if (state.retriable) {
312
+ state = await state.retry()
313
+ break
314
+ }
315
+ logger.error("primer state error", { error: state.error })
316
+ throw state.error
317
+ }
318
+ }
361
319
 
362
- ```ts
363
- const client = create({
364
- origin,
365
- accessToken,
366
- subject: "math",
367
- supportedPcis: ["urn:primer:pci:fraction-input"],
368
- logger
369
- })
320
+ if (state.phase === "fatal") {
321
+ logger.error("primer fatal state", { error: state.error })
322
+ throw state.error
323
+ }
324
+ }
370
325
  ```
371
326
 
372
- The array values are not sent to the server. They exist so TypeScript and client-side runtime preflight can prove that the renderer has declared support for required PCI URNs before `/advance` is called.
327
+ State transitions:
373
328
 
374
- # `PrimerState`
375
-
376
- `PrimerState` is a discriminated union over `phase`.
329
+ | Current state | Valid operation | Next result |
330
+ | --- | --- | --- |
331
+ | `ObservationState` | `advance()` | `Promise<PrimerState>` |
332
+ | `ChoiceState` | `submitChoice(selectedKeys)` or `timeout()` | `Promise<PrimerState>` |
333
+ | `TextEntryState` | `submitText(value)` or `timeout()` | `Promise<PrimerState>` |
334
+ | `ExtendedTextSingleState` | `submitText(value)` or `timeout()` | `Promise<PrimerState>` |
335
+ | `ExtendedTextMultipleState` | `submitTexts(values)` or `timeout()` | `Promise<PrimerState>` |
336
+ | `OrderState` | `submitOrder(orderedKeys)` or `timeout()` | `Promise<PrimerState>` |
337
+ | `MatchState` | `submitMatch(pairs)` or `timeout()` | `Promise<PrimerState>` |
338
+ | `PciInteractionState` | `submit(value)` or `timeout()` | `Promise<PrimerState>` |
339
+ | `FeedbackState` | `advance()` | `Promise<PrimerState>` |
340
+ | `CompletedState` | none | terminal |
341
+ | `ErroredState` | `retry()` when `retriable` is `true` | `Promise<PrimerState>` |
342
+ | `FatalState` | none | terminal |
343
+
344
+ ## `PrimerState`
377
345
 
378
346
  ```ts
379
347
  type PrimerState<Pcis extends PciId = PciId> =
@@ -385,12 +353,27 @@ type PrimerState<Pcis extends PciId = PciId> =
385
353
  | FatalState
386
354
  ```
387
355
 
388
- Every state is intentionally non-serializable. It contains closures for advancing, submitting, retrying, and deduplicating in-flight calls. Do not store it in localStorage, persist it through JSON, or treat it as data. Keep it in memory and call `client.start()` again after reload.
356
+ `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`.
357
+
358
+ Recreate state by calling `create` again after a reload, remount, account switch, or subject switch.
359
+
360
+ ## Common State Fields
361
+
362
+ Learning states that render content expose `body` and `stimulus`.
363
+
364
+ ```ts
365
+ interface RenderableState {
366
+ readonly body: ContentBlock[]
367
+ readonly stimulus: RendererStimulus | null
368
+ }
369
+ ```
370
+
371
+ `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.
389
372
 
390
373
  ## `ObservationState`
391
374
 
392
375
  ```ts
393
- interface ObservationState<Pcis extends PciId = PciId> extends NonSerializable {
376
+ interface ObservationState<Pcis extends PciId = PciId> {
394
377
  readonly phase: "observation"
395
378
  readonly body: ContentBlock[]
396
379
  readonly stimulus: RendererStimulus | null
@@ -398,56 +381,78 @@ interface ObservationState<Pcis extends PciId = PciId> extends NonSerializable {
398
381
  }
399
382
  ```
400
383
 
401
- Render `body` and `stimulus`, then call `advance()` when the learner is ready to continue. Observation frames have no interaction to submit.
384
+ Render the frame, then call `advance()` when the learner is ready to continue. Observation states have no answer to submit.
402
385
 
403
- ## `InteractionState`
386
+ Repeated `advance()` calls while the first one is pending return the same pending result.
404
387
 
405
- `InteractionState` is a union over `kind`.
388
+ ## `InteractionState`
406
389
 
407
- | `kind` | State type | Submit method |
408
- | --- | --- | --- |
409
- | `choice` | `ChoiceState` | `submitChoice(selectedKeys: string[])` |
410
- | `text-entry` | `TextEntryState` | `submitText(value: string)` |
411
- | `extended-text` single | `ExtendedTextSingleState` | `submitText(value: string)` |
412
- | `extended-text` multiple | `ExtendedTextMultipleState` | `submitTexts(values: string[])` |
413
- | `order` | `OrderState` | `submitOrder(orderedKeys: string[])` |
414
- | `match` | `MatchState` | `submitMatch(pairs: MatchPair[])` |
415
- | `portable-custom` | `PciInteractionState` | `submit(value: PciValue<K>)` |
390
+ ```ts
391
+ type InteractionState<Pcis extends PciId = PciId> =
392
+ | ChoiceState<Pcis>
393
+ | TextEntryState<Pcis>
394
+ | ExtendedTextState<Pcis>
395
+ | OrderState<Pcis>
396
+ | MatchState<Pcis>
397
+ | PciInteractionState<Pcis>
398
+ ```
416
399
 
417
400
  Every interaction state includes:
418
401
 
419
- - `phase: "interaction"`
420
- - `body`
421
- - `stimulus`
422
- - `interaction`
423
- - a typed submit method
424
- - `timeout(): Promise<PrimerState<Pcis>>`
402
+ | Field | Meaning |
403
+ | --- | --- |
404
+ | `phase: "interaction"` | State-machine discriminator. |
405
+ | `kind` | Renderer-facing interaction kind. |
406
+ | `body` | Frame content. |
407
+ | `stimulus` | Optional frame stimulus. |
408
+ | `interaction` | Full interaction contract object. |
409
+ | submit method | Kind-specific learner submission operation. |
410
+ | `timeout()` | Records that the learner timed out or the host chose to end the attempt without a submission. |
411
+
412
+ Submission methods validate standard interaction payloads before runtime submission. Invalid standard submissions return `ErroredState` with `ErrInvalidSubmission`.
425
413
 
426
- Interaction submissions are validated client-side before transport. Invalid payloads return an `errored` state with `ErrInvalidSubmission`; they are not sent to the server.
414
+ Concurrent interaction operations are guarded:
427
415
 
428
- ### Choice
416
+ | Situation | SDK behavior |
417
+ | --- | --- |
418
+ | Same submit payload while submit is pending | Returns the same pending result. |
419
+ | Different submit payload while submit is pending | Returns `ErroredState` with `ErrConflict`. |
420
+ | Submit while timeout is pending | Returns `ErroredState` with `ErrConflict`. |
421
+ | Timeout while submit is pending | Returns `ErroredState` with `ErrConflict`. |
422
+ | Repeated timeout while timeout is pending | Returns the same pending result. |
423
+
424
+ ## `ChoiceState`
429
425
 
430
426
  ```ts
431
- interface ChoiceState<Pcis extends PciId = PciId> extends NonSerializable {
427
+ interface ChoiceState<Pcis extends PciId = PciId> {
432
428
  readonly phase: "interaction"
433
429
  readonly kind: "choice"
434
430
  readonly body: ContentBlock[]
435
431
  readonly stimulus: RendererStimulus | null
436
432
  readonly interaction: Extract<StandardRendererInteraction, { type: "choice" }>
437
433
  readonly options: RendererChoice[]
438
- readonly maxChoices: number
439
434
  readonly minChoices: number
435
+ readonly maxChoices: number
440
436
  submitChoice(selectedKeys: string[]): Promise<PrimerState<Pcis>>
441
437
  timeout(): Promise<PrimerState<Pcis>>
442
438
  }
443
439
  ```
444
440
 
445
- Use `minChoices` and `maxChoices` to decide whether to auto-submit on click or require a Submit button. Primer's grade onboarding prompt is currently delivered as a normal single-choice interaction.
441
+ Use `minChoices` and `maxChoices` to decide whether the UI should submit immediately or require an explicit submit action.
442
+
443
+ Valid `submitChoice` payloads:
446
444
 
447
- ### Text Entry
445
+ | Requirement | Error if violated |
446
+ | --- | --- |
447
+ | At least `minChoices` identifiers | `ErrInvalidSubmission` |
448
+ | At most `maxChoices` identifiers | `ErrInvalidSubmission` |
449
+ | Every identifier exists in `options` | `ErrInvalidSubmission` |
450
+ | No duplicate identifiers | `ErrInvalidSubmission` |
451
+
452
+ ## `TextEntryState`
448
453
 
449
454
  ```ts
450
- interface TextEntryState<Pcis extends PciId = PciId> extends NonSerializable {
455
+ interface TextEntryState<Pcis extends PciId = PciId> {
451
456
  readonly phase: "interaction"
452
457
  readonly kind: "text-entry"
453
458
  readonly body: ContentBlock[]
@@ -458,25 +463,37 @@ interface TextEntryState<Pcis extends PciId = PciId> extends NonSerializable {
458
463
  }
459
464
  ```
460
465
 
461
- `interaction` may include `expectedLength`, `patternMask`, and `placeholderText` for UI hints.
466
+ 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.
462
467
 
463
- ### Extended Text
468
+ ## `ExtendedTextState`
464
469
 
465
470
  Extended text has two cardinalities.
466
471
 
467
472
  ```ts
468
- interface ExtendedTextSingleState<Pcis extends PciId = PciId> extends NonSerializable {
473
+ interface ExtendedTextSingleState<Pcis extends PciId = PciId> {
469
474
  readonly phase: "interaction"
470
475
  readonly kind: "extended-text"
471
476
  readonly cardinality: "single"
477
+ readonly body: ContentBlock[]
478
+ readonly stimulus: RendererStimulus | null
479
+ readonly interaction: Extract<
480
+ StandardRendererInteraction,
481
+ { type: "extended-text"; cardinality: "single" }
482
+ >
472
483
  submitText(value: string): Promise<PrimerState<Pcis>>
473
484
  timeout(): Promise<PrimerState<Pcis>>
474
485
  }
475
486
 
476
- interface ExtendedTextMultipleState<Pcis extends PciId = PciId> extends NonSerializable {
487
+ interface ExtendedTextMultipleState<Pcis extends PciId = PciId> {
477
488
  readonly phase: "interaction"
478
489
  readonly kind: "extended-text"
479
490
  readonly cardinality: "multiple"
491
+ readonly body: ContentBlock[]
492
+ readonly stimulus: RendererStimulus | null
493
+ readonly interaction: Extract<
494
+ StandardRendererInteraction,
495
+ { type: "extended-text"; cardinality: "multiple" }
496
+ >
480
497
  readonly minStrings: number
481
498
  readonly maxStrings: number
482
499
  submitTexts(values: string[]): Promise<PrimerState<Pcis>>
@@ -484,14 +501,27 @@ interface ExtendedTextMultipleState<Pcis extends PciId = PciId> extends NonSeria
484
501
  }
485
502
  ```
486
503
 
487
- Both include `body`, `stimulus`, and `interaction` fields like every other interaction state.
504
+ For single-cardinality extended text, call `submitText(value)`. For multiple-cardinality extended text, call `submitTexts(values)`.
505
+
506
+ Valid multiple-cardinality payloads:
507
+
508
+ | Requirement | Error if violated |
509
+ | --- | --- |
510
+ | At least `minStrings` values | `ErrInvalidSubmission` |
511
+ | At most `maxStrings` values | `ErrInvalidSubmission` |
512
+ | No duplicate values | `ErrInvalidSubmission` |
488
513
 
489
- ### Order
514
+ `expectedLength`, `expectedLines`, `patternMask`, and `placeholderText` are renderer hints.
515
+
516
+ ## `OrderState`
490
517
 
491
518
  ```ts
492
- interface OrderState<Pcis extends PciId = PciId> extends NonSerializable {
519
+ interface OrderState<Pcis extends PciId = PciId> {
493
520
  readonly phase: "interaction"
494
521
  readonly kind: "order"
522
+ readonly body: ContentBlock[]
523
+ readonly stimulus: RendererStimulus | null
524
+ readonly interaction: Extract<StandardRendererInteraction, { type: "order" }>
495
525
  readonly choices: RendererChoice[]
496
526
  readonly minChoices: number
497
527
  readonly maxChoices: number
@@ -502,7 +532,16 @@ interface OrderState<Pcis extends PciId = PciId> extends NonSerializable {
502
532
 
503
533
  Submit identifiers in learner-selected order.
504
534
 
505
- ### Match
535
+ Valid `submitOrder` payloads:
536
+
537
+ | Requirement | Error if violated |
538
+ | --- | --- |
539
+ | At least `minChoices` identifiers | `ErrInvalidSubmission` |
540
+ | At most `maxChoices` identifiers | `ErrInvalidSubmission` |
541
+ | Every identifier exists in `choices` | `ErrInvalidSubmission` |
542
+ | No duplicate identifiers | `ErrInvalidSubmission` |
543
+
544
+ ## `MatchState`
506
545
 
507
546
  ```ts
508
547
  interface MatchPair {
@@ -510,9 +549,12 @@ interface MatchPair {
510
549
  target: string
511
550
  }
512
551
 
513
- interface MatchState<Pcis extends PciId = PciId> extends NonSerializable {
552
+ interface MatchState<Pcis extends PciId = PciId> {
514
553
  readonly phase: "interaction"
515
554
  readonly kind: "match"
555
+ readonly body: ContentBlock[]
556
+ readonly stimulus: RendererStimulus | null
557
+ readonly interaction: Extract<StandardRendererInteraction, { type: "match" }>
516
558
  readonly sourceChoices: RendererMatchChoice[]
517
559
  readonly targetChoices: RendererMatchChoice[]
518
560
  readonly minAssociations: number
@@ -522,13 +564,29 @@ interface MatchState<Pcis extends PciId = PciId> extends NonSerializable {
522
564
  }
523
565
  ```
524
566
 
525
- Each `RendererMatchChoice` has `matchMin` and `matchMax`. A `matchMax` of `0` means unbounded.
567
+ Each pair connects one source identifier to one target identifier.
568
+
569
+ Valid `submitMatch` payloads:
570
+
571
+ | Requirement | Error if violated |
572
+ | --- | --- |
573
+ | At least `minAssociations` pairs | `ErrInvalidSubmission` |
574
+ | At most `maxAssociations` pairs | `ErrInvalidSubmission` |
575
+ | Every source identifier exists in `sourceChoices` | `ErrInvalidSubmission` |
576
+ | Every target identifier exists in `targetChoices` | `ErrInvalidSubmission` |
577
+ | No duplicate source-target pairs | `ErrInvalidSubmission` |
578
+ | Every choice respects its `matchMin` | `ErrInvalidSubmission` |
579
+ | Every choice respects its `matchMax` when `matchMax` is nonzero | `ErrInvalidSubmission` |
526
580
 
527
- ### Portable Custom Interaction
581
+ `matchMax: 0` means unbounded.
582
+
583
+ ## `PciInteractionState`
584
+
585
+ Portable Custom Interaction state is typed by PCI id.
528
586
 
529
587
  ```ts
530
588
  type PciInteractionState<Pcis extends PciId = PciId> = {
531
- [K in Pcis]: NonSerializable & {
589
+ [K in Pcis]: {
532
590
  readonly phase: "interaction"
533
591
  readonly kind: "portable-custom"
534
592
  readonly body: ContentBlock[]
@@ -542,29 +600,25 @@ type PciInteractionState<Pcis extends PciId = PciId> = {
542
600
  }[Pcis]
543
601
  ```
544
602
 
545
- For `urn:primer:pci:fraction-input`, `properties` has:
603
+ When the state is narrowed to a PCI id, `submit` accepts only that PCI's value type.
546
604
 
547
605
  ```ts
548
- interface FractionInputProps {
549
- form: "whole" | "proper" | "improper" | "mixed"
550
- requireSimplified: boolean
606
+ import type { PciValue } from "@superbuilders/primer-tives/contracts"
607
+
608
+ if (state.phase === "interaction" && state.kind === "portable-custom") {
609
+ if (state.pciId === "urn:primer:pci:fraction-input") {
610
+ const value: PciValue<"urn:primer:pci:fraction-input"> = readFractionInput(
611
+ state.properties
612
+ )
613
+ state = await state.submit(value)
614
+ }
551
615
  }
552
616
  ```
553
617
 
554
- The submitted value is one of:
555
-
556
- ```ts
557
- type FractionInputSubmission =
558
- | { form: "whole"; whole: string }
559
- | { form: "proper"; numerator: string; denominator: string }
560
- | { form: "improper"; numerator: string; denominator: string }
561
- | { form: "mixed"; whole: string; numerator: string; denominator: string }
562
- ```
563
-
564
618
  ## `FeedbackState`
565
619
 
566
620
  ```ts
567
- interface FeedbackState<Pcis extends PciId = PciId> extends NonSerializable {
621
+ interface FeedbackState<Pcis extends PciId = PciId> {
568
622
  readonly phase: "feedback"
569
623
  readonly body: ContentBlock[]
570
624
  readonly stimulus: RendererStimulus | null
@@ -577,22 +631,24 @@ interface FeedbackState<Pcis extends PciId = PciId> extends NonSerializable {
577
631
  }
578
632
  ```
579
633
 
580
- Feedback state is returned after a successful interaction submission. Render the submitted frame, the submitted value, correctness, server feedback, and optional review data. Call `advance()` when the learner is ready to continue.
634
+ Feedback state is returned after a successful learner submission. Render the submitted frame, submitted value, correctness, feedback content, and optional review data. Call `advance()` when the learner is ready to continue.
635
+
636
+ Repeated `advance()` calls while the first one is pending return the same pending result.
581
637
 
582
638
  ## `CompletedState`
583
639
 
584
640
  ```ts
585
- interface CompletedState extends NonSerializable {
641
+ interface CompletedState {
586
642
  readonly phase: "completed"
587
643
  }
588
644
  ```
589
645
 
590
- Terminal state. There is no action method.
646
+ Terminal state. There is no transition method.
591
647
 
592
648
  ## `ErroredState`
593
649
 
594
650
  ```ts
595
- interface ErroredState<Pcis extends PciId = PciId> extends NonSerializable {
651
+ interface ErroredState<Pcis extends PciId = PciId> {
596
652
  readonly phase: "errored"
597
653
  readonly error: Error
598
654
  readonly retriable: boolean
@@ -600,43 +656,74 @@ interface ErroredState<Pcis extends PciId = PciId> extends NonSerializable {
600
656
  }
601
657
  ```
602
658
 
603
- `retry()` re-runs the exact same intent that failed. If `retriable` is false, `retry()` resolves to the same errored state.
659
+ `ErroredState` means the current learner intent could not complete, but the learning session itself is not necessarily terminal.
660
+
661
+ If `retriable` is `true`, `retry()` repeats the exact failed intent. If `retriable` is `false`, `retry()` resolves to the same errored state.
662
+
663
+ `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.
604
664
 
605
665
  ## `FatalState`
606
666
 
607
667
  ```ts
608
- interface FatalState extends NonSerializable {
668
+ interface FatalState {
609
669
  readonly phase: "fatal"
610
670
  readonly error: Error
611
671
  readonly retriable: false
612
672
  }
613
673
  ```
614
674
 
615
- Fatal means the browser cannot recover by retrying the current intent. Examples include invalid token, expired token, forbidden request, unsupported PCI, or SDK upgrade required.
675
+ Fatal state means the SDK cannot recover by retrying the current learner intent. Render a terminal error UI, require a new `create` call, or send the learner through auth again depending on the sentinel.
676
+
677
+ Fatal sentinels:
678
+
679
+ | Sentinel | Meaning |
680
+ | --- | --- |
681
+ | `ErrBadRequest` | Primer rejected the runtime request as invalid for the SDK contract. |
682
+ | `ErrInvalidAccessToken` | The learner token was rejected. |
683
+ | `ErrTokenExpired` | The learner token expired. |
684
+ | `ErrForbidden` | The learner is not allowed to continue in this runtime scope. |
685
+ | `ErrNotFound` | The requested runtime scope or state could not be found. |
686
+ | `ErrSdkUpgradeRequired` | The installed SDK is too old for the current Primer runtime. |
687
+ | `ErrUnsupportedPci` | Primer presented a PCI that the renderer did not declare in `supportedPcis`. |
616
688
 
617
- # `/contracts`
689
+ ## `/contracts`
618
690
 
619
- The contracts subpath contains renderer wire shapes and helpers shared between custom renderers and server-adjacent tooling.
691
+ The contracts subpath contains renderer-facing data types and validation helpers.
620
692
 
621
693
  ```ts
622
694
  import {
695
+ ChoiceSubmissionSchema,
696
+ ExtendedTextSubmissionSchema,
697
+ FractionInputPciSubmissionSchema,
698
+ MatchPairSchema,
699
+ MatchSubmissionSchema,
700
+ OrderSubmissionSchema,
701
+ PCI_IDS,
623
702
  RendererSubmissionSchema,
703
+ TextEntrySubmissionSchema,
624
704
  blocksToPlainText,
625
705
  inlinesToPlainText,
706
+ isPciId,
626
707
  submissionValidationMessage,
627
708
  validateSubmissionForInteraction
628
709
  } from "@superbuilders/primer-tives/contracts"
710
+
629
711
  import type {
630
712
  ContentBlock,
631
713
  ContentInline,
632
714
  ContentSpan,
715
+ FractionInputForm,
716
+ FractionInputProps,
717
+ FractionInputSubmission,
633
718
  ImageStimulus,
634
719
  InteractionReview,
635
720
  MatchPair,
636
721
  PciId,
637
722
  PciInteraction,
638
723
  PciProps,
724
+ PciRegistry,
639
725
  PciSubmission,
726
+ PciUrn,
640
727
  PciValue,
641
728
  RendererChoice,
642
729
  RendererInteraction,
@@ -658,11 +745,11 @@ type ContentBlock = { type: "paragraph"; children: ContentInline[] }
658
745
  Helpers:
659
746
 
660
747
  ```ts
661
- inlinesToPlainText(nodes: ContentInline[]): string
662
- blocksToPlainText(blocks: ContentBlock[]): string
748
+ function inlinesToPlainText(nodes: ContentInline[]): string
749
+ function blocksToPlainText(blocks: ContentBlock[]): string
663
750
  ```
664
751
 
665
- Use these helpers for accessibility labels, alt fallbacks, logging summaries, and non-rich previews.
752
+ 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.
666
753
 
667
754
  ## Stimulus
668
755
 
@@ -676,7 +763,7 @@ interface ImageStimulus {
676
763
  type RendererStimulus = ImageStimulus
677
764
  ```
678
765
 
679
- `RendererStimulus` is currently image-only, but it is still a discriminated union so renderers stay future-safe.
766
+ `RendererStimulus` is currently image-only. Always switch on `stimulus.kind` anyway.
680
767
 
681
768
  ## Interactions
682
769
 
@@ -730,50 +817,86 @@ type RendererSubmission<Pcis extends PciId = PciId> =
730
817
  | PciSubmission<Pcis>
731
818
  ```
732
819
 
733
- Schemas:
820
+ Public schemas:
734
821
 
735
- ```ts
736
- ChoiceSubmissionSchema
737
- TextEntrySubmissionSchema
738
- ExtendedTextSubmissionSchema
739
- OrderSubmissionSchema
740
- MatchSubmissionSchema
741
- FractionInputPciSubmissionSchema
742
- RendererSubmissionSchema
743
- ```
822
+ | Schema | Validates |
823
+ | --- | --- |
824
+ | `MatchPairSchema` | `{ source, target }` match pair shape |
825
+ | `ChoiceSubmissionSchema` | choice submission shape |
826
+ | `TextEntrySubmissionSchema` | text-entry submission shape |
827
+ | `ExtendedTextSubmissionSchema` | extended-text submission shape |
828
+ | `OrderSubmissionSchema` | order submission shape |
829
+ | `MatchSubmissionSchema` | match submission shape |
830
+ | `FractionInputPciSubmissionSchema` | fraction-input PCI submission shape |
831
+ | `RendererSubmissionSchema` | union of all supported submission shapes |
744
832
 
745
- Always use `safeParse` when parsing arbitrary input:
833
+ Always use `safeParse` when parsing arbitrary input.
746
834
 
747
835
  ```ts
836
+ import * as errors from "@superbuilders/errors"
837
+ import * as logger from "@superbuilders/slog"
838
+ import { RendererSubmissionSchema } from "@superbuilders/primer-tives/contracts"
839
+
748
840
  const parsed = RendererSubmissionSchema.safeParse(payload)
749
841
  if (!parsed.success) {
750
- throw parsed.error
842
+ logger.error("submission payload invalid", { error: parsed.error })
843
+ throw errors.wrap(parsed.error, "submission payload")
751
844
  }
845
+
846
+ const submission = parsed.data
752
847
  ```
753
848
 
754
- ## Semantic Validation
849
+ ## Semantic Submission Validation
755
850
 
756
- `RendererSubmissionSchema` validates shape. `validateSubmissionForInteraction` validates a submission against a specific interaction.
851
+ Shape validation answers “does this look like a submission?” Semantic validation answers “is this submission valid for this exact interaction?”
757
852
 
758
853
  ```ts
759
- const validation = validateSubmissionForInteraction(interaction, submission)
760
- if (!validation.ok) {
761
- throw errors.wrap(ErrInvalidSubmission, submissionValidationMessage(validation))
762
- }
854
+ function validateSubmissionForInteraction(
855
+ interaction: RendererInteraction,
856
+ submission: RendererSubmission
857
+ ): SubmissionValidationResult
858
+
859
+ function submissionValidationMessage(result: SubmissionValidationFailure): string
763
860
  ```
764
861
 
765
- Checks include:
862
+ Result shape:
863
+
864
+ ```ts
865
+ type SubmissionValidationResult =
866
+ | { ok: true; value: RendererSubmission }
867
+ | { ok: false; issues: readonly string[] }
868
+ ```
869
+
870
+ Validation checks:
871
+
872
+ | Interaction | Checks |
873
+ | --- | --- |
874
+ | `choice` | type match, min/max selection count, duplicate identifiers, unknown identifiers |
875
+ | `text-entry` | type match |
876
+ | `extended-text` single | type match, exactly one value |
877
+ | `extended-text` multiple | type match, min/max value count, duplicate values |
878
+ | `order` | type match, min/max selection count, duplicate identifiers, unknown identifiers |
879
+ | `match` | type match, min/max association count, duplicate pairs, unknown sources, unknown targets, `matchMin`, `matchMax` |
880
+ | `portable-custom` | type match, PCI id match, PCI value schema |
766
881
 
767
- - submission type matches interaction type
768
- - choice identifiers exist
769
- - choice cardinality is within `minChoices` and `maxChoices`
770
- - duplicate choice/order identifiers are rejected
771
- - extended-text value count matches cardinality bounds
772
- - match source/target identifiers exist
773
- - match min/max usage bounds hold
774
- - portable-custom `pciId` matches and payload parses against the PCI schema
882
+ The built-in standard interaction state methods call this before submitting. Custom renderer utilities that build `RendererSubmission` values directly should call it too.
775
883
 
776
- The built-in browser state objects call this before submitting. Custom renderers that bypass state methods should call it themselves.
884
+ ```ts
885
+ import * as errors from "@superbuilders/errors"
886
+ import * as logger from "@superbuilders/slog"
887
+ import {
888
+ submissionValidationMessage,
889
+ validateSubmissionForInteraction
890
+ } from "@superbuilders/primer-tives/contracts"
891
+ import { ErrInvalidSubmission } from "@superbuilders/primer-tives/errors"
892
+
893
+ const validation = validateSubmissionForInteraction(interaction, submission)
894
+ if (!validation.ok) {
895
+ const message = submissionValidationMessage(validation)
896
+ logger.error("submission invalid", { issues: validation.issues })
897
+ throw errors.wrap(ErrInvalidSubmission, message)
898
+ }
899
+ ```
777
900
 
778
901
  ## Review Types
779
902
 
@@ -800,23 +923,143 @@ Review variants:
800
923
  | `match` | `correctPairs: MatchPair[]` |
801
924
  | `portable-custom` | `pciId`, `fields: ReviewRecordField[]` |
802
925
 
803
- `review` is for display and inspection. Correctness already lives on `FeedbackState.isCorrect`.
926
+ Review scalar values:
927
+
928
+ ```ts
929
+ type ReviewScalarValue =
930
+ | { kind: "identifier"; value: string }
931
+ | { kind: "string"; value: string }
932
+ | { kind: "integer"; value: number }
933
+ | { kind: "float"; value: number }
934
+ | { kind: "pair"; source: string; target: string }
935
+ ```
936
+
937
+ `review` is for renderer display and inspection. Correctness already lives on `FeedbackState.isCorrect`.
938
+
939
+ ## PCI Registry
940
+
941
+ The current PCI registry contains one PCI.
942
+
943
+ ```ts
944
+ const PCI_IDS = ["urn:primer:pci:fraction-input"] as const
945
+ ```
946
+
947
+ ```ts
948
+ type PciId = "urn:primer:pci:fraction-input"
949
+ type PciProps<K extends PciId> = PciRegistry[K]["props"]
950
+ type PciValue<K extends PciId> = PciRegistry[K]["value"]
951
+ ```
952
+
953
+ Use `isPciId(value)` to narrow an arbitrary string to the current `PciId` union.
954
+
955
+ ## Fraction Input PCI
956
+
957
+ ```ts
958
+ type FractionInputForm = "whole" | "proper" | "improper" | "mixed"
959
+
960
+ interface FractionInputProps {
961
+ form: FractionInputForm
962
+ requireSimplified: boolean
963
+ }
964
+ ```
965
+
966
+ Submitted value:
967
+
968
+ ```ts
969
+ type FractionInputSubmission =
970
+ | { form: "whole"; whole: string }
971
+ | { form: "proper"; numerator: string; denominator: string }
972
+ | { form: "improper"; numerator: string; denominator: string }
973
+ | { form: "mixed"; whole: string; numerator: string; denominator: string }
974
+ ```
975
+
976
+ Example renderer branch:
804
977
 
805
- # `/errors`
978
+ ```ts
979
+ if (state.phase === "interaction" && state.kind === "portable-custom") {
980
+ if (state.pciId === "urn:primer:pci:fraction-input") {
981
+ renderFractionInput({
982
+ mode: "pending",
983
+ properties: state.properties,
984
+ onValueChange: handleFractionValueChange
985
+ })
986
+ }
987
+ }
988
+ ```
989
+
990
+ ## PCI Render Props
991
+
992
+ PCI render props are convenience types for renderer components.
993
+
994
+ ```ts
995
+ type PciPendingRenderProps<K extends PciId> = {
996
+ mode: "pending"
997
+ properties: PciProps<K>
998
+ onValueChange: (value: PciValue<K> | null) => void
999
+ }
1000
+
1001
+ type PciSubmittedRenderProps<K extends PciId> = {
1002
+ mode: "submitted"
1003
+ properties: PciProps<K>
1004
+ submission: PciValue<K>
1005
+ review: Extract<InteractionReview<K>, { type: "portable-custom"; pciId: K }> | null
1006
+ }
1007
+
1008
+ type PciRenderProps<K extends PciId> = PciPendingRenderProps<K> | PciSubmittedRenderProps<K>
1009
+ ```
1010
+
1011
+ ## Subject PCI Helpers
1012
+
1013
+ Use `@superbuilders/primer-tives/subject-pcis` when renderer tooling needs the same subject-to-PCI contract as `create`.
1014
+
1015
+ ```ts
1016
+ import {
1017
+ REQUIRED_PCIS_BY_SUBJECT,
1018
+ missingPcisForSubject,
1019
+ requiredPcisForSubject
1020
+ } from "@superbuilders/primer-tives/subject-pcis"
1021
+ import type {
1022
+ HasRequiredPcis,
1023
+ MissingRequiredPcis,
1024
+ RequiredPciForSubject
1025
+ } from "@superbuilders/primer-tives/subject-pcis"
1026
+
1027
+ const requiredForMath = requiredPcisForSubject("math")
1028
+ const missingForMath = missingPcisForSubject("math", [])
1029
+ ```
1030
+
1031
+ `requiredPcisForSubject(undefined)` returns the all-subject required PCI union.
806
1032
 
807
- All sentinels are exported from `/errors`.
1033
+ ## Errors
1034
+
1035
+ All SDK sentinels are exported from `@superbuilders/primer-tives/errors` and are compatible with `errors.is()` from `@superbuilders/errors`.
1036
+
1037
+ ```ts
1038
+ import * as errors from "@superbuilders/errors"
1039
+ import { ErrTokenExpired } from "@superbuilders/primer-tives/errors"
1040
+
1041
+ if (errors.is(err, ErrTokenExpired)) {
1042
+ renderSignInAgain()
1043
+ }
1044
+ ```
1045
+
1046
+ Complete export set:
808
1047
 
809
1048
  ```ts
810
1049
  import {
1050
+ ErrAuthCallbackInvalid,
1051
+ ErrAuthCancelled,
1052
+ ErrAuthConfigInvalid,
1053
+ ErrAuthPopupBlocked,
1054
+ ErrAuthStateMismatch,
1055
+ ErrAuthUnavailable,
811
1056
  ErrBadRequest,
812
1057
  ErrConflict,
813
1058
  ErrForbidden,
814
1059
  ErrInvalidAccessToken,
815
- ErrInvalidSecretKey,
816
1060
  ErrInvalidSubmission,
817
1061
  ErrJsonParse,
818
1062
  ErrMalformedAccessToken,
819
- ErrMissingRequiredPci,
820
1063
  ErrNetwork,
821
1064
  ErrNotFound,
822
1065
  ErrNotSerializable,
@@ -830,55 +1073,101 @@ import {
830
1073
  } from "@superbuilders/primer-tives/errors"
831
1074
  ```
832
1075
 
833
- ## Server SDK Errors
1076
+ ## Create-Time Errors
834
1077
 
835
- | Sentinel | Meaning |
836
- | --- | --- |
837
- | `ErrInvalidSecretKey` | Secret key rejected by Primer. |
838
- | `ErrBadRequest` | Token request was malformed. |
839
- | `ErrServerError` | Primer returned an unexpected non-2xx status. |
840
- | `ErrJsonParse` | Successful response did not parse or lacked token shape. |
841
- | `ErrNetwork` | Fetch rejected before response. |
842
- | `ErrTimeout` | Fetch aborted. |
1078
+ These are most often thrown by `create` before a `PrimerState` exists.
843
1079
 
844
- ## Browser Transport Errors
1080
+ | Sentinel | Meaning | Typical handling |
1081
+ | --- | --- | --- |
1082
+ | `ErrAuthUnavailable` | SDK-managed auth cannot run in the current host environment. | Show unsupported-runtime or fallback sign-in UI. |
1083
+ | `ErrAuthConfigInvalid` | Public auth configuration is invalid. | Log and treat as integration error. |
1084
+ | `ErrAuthCallbackInvalid` | Learner auth did not complete with an acceptable result. | Offer sign-in retry; log if unexpected. |
1085
+ | `ErrAuthStateMismatch` | Auth result does not match the initiated auth attempt. | Restart auth. |
1086
+ | `ErrAuthPopupBlocked` | Browser blocked learner auth UI. | Ask learner to allow popups or retry from a user gesture. |
1087
+ | `ErrAuthCancelled` | Learner auth was closed or timed out. | Offer retry. |
1088
+ | `ErrMalformedAccessToken` | Provided or resolved token is not shaped like a learner access token. | Re-authenticate learner or fix token source. |
1089
+
1090
+ ## Runtime Error States
1091
+
1092
+ Runtime errors are represented as `ErroredState` or `FatalState`.
1093
+
1094
+ | Sentinel | State | Retriable | Meaning |
1095
+ | --- | --- | --- | --- |
1096
+ | `ErrNetwork` | `ErroredState` | yes | Runtime communication failed before a usable Primer result existed. |
1097
+ | `ErrTimeout` | `ErroredState` | yes | Runtime work was aborted or exceeded the host's allowed time. |
1098
+ | `ErrServerError` | `ErroredState` | yes | Primer could not produce a normal runtime result. |
1099
+ | `ErrServiceUnavailable` | `ErroredState` | yes | Primer is temporarily unavailable. |
1100
+ | `ErrRateLimited` | `ErroredState` | yes | Runtime work is temporarily rate limited. |
1101
+ | `ErrConflict` | `ErroredState` | yes | The learner intent conflicts with another in-flight or current runtime action. |
1102
+ | `ErrJsonParse` | `ErroredState` | yes | Runtime data could not be interpreted as the SDK contract. |
1103
+ | `ErrInvalidSubmission` | `ErroredState` | no | Renderer submitted a value that is invalid for the active interaction. |
1104
+ | `ErrBadRequest` | `FatalState` | no | Runtime request violates the SDK contract. |
1105
+ | `ErrInvalidAccessToken` | `FatalState` | no | Learner token is invalid. |
1106
+ | `ErrTokenExpired` | `FatalState` | no | Learner token expired. |
1107
+ | `ErrForbidden` | `FatalState` | no | Learner cannot continue in this runtime scope. |
1108
+ | `ErrNotFound` | `FatalState` | no | Runtime scope or state is unavailable. |
1109
+ | `ErrSdkUpgradeRequired` | `FatalState` | no | Installed SDK version is too old for Primer. |
1110
+ | `ErrUnsupportedPci` | `FatalState` | no | Renderer did not declare support for the presented PCI. |
1111
+ | `ErrNotSerializable` | thrown by `toJSON` | no | A live `PrimerState` was serialized. |
1112
+
1113
+ ## Error-Handling Recipes
1114
+
1115
+ Handle create-time errors before using state:
845
1116
 
846
- These become `ErroredState` unless classified as fatal:
1117
+ ```ts
1118
+ const result = await errors.try(create(options))
1119
+ if (result.error) {
1120
+ if (errors.is(result.error, ErrAuthCancelled)) {
1121
+ renderTryAgain()
1122
+ return
1123
+ }
1124
+ logger.error("primer create failed", { error: result.error })
1125
+ throw result.error
1126
+ }
847
1127
 
848
- | Sentinel | Meaning |
849
- | --- | --- |
850
- | `ErrNetwork` | Fetch rejected. |
851
- | `ErrTimeout` | Fetch aborted. |
852
- | `ErrServerError` | HTTP 5xx not otherwise mapped. |
853
- | `ErrServiceUnavailable` | HTTP 502, 503, or 504. |
854
- | `ErrRateLimited` | HTTP 429. |
855
- | `ErrConflict` | HTTP 409. |
856
- | `ErrJsonParse` | Successful response body failed to parse. |
857
- | `ErrInvalidSubmission` | Local semantic validation rejected the submission. |
1128
+ let state = result.data
1129
+ ```
858
1130
 
859
- ## Browser Fatal Errors
1131
+ Handle runtime errors through the state machine:
860
1132
 
861
- These become `FatalState`:
1133
+ ```ts
1134
+ if (state.phase === "errored") {
1135
+ if (state.retriable) {
1136
+ state = await state.retry()
1137
+ return
1138
+ }
1139
+ logger.error("primer non-retriable state", { error: state.error })
1140
+ throw state.error
1141
+ }
862
1142
 
863
- | Sentinel | Meaning |
864
- | --- | --- |
865
- | `ErrBadRequest` | HTTP 400. |
866
- | `ErrInvalidAccessToken` | HTTP 401. |
867
- | `ErrTokenExpired` | HTTP 401 with token-expired detail. |
868
- | `ErrForbidden` | HTTP 403. |
869
- | `ErrNotFound` | HTTP 404. |
870
- | `ErrSdkUpgradeRequired` | Server requires a newer SDK. |
871
- | `ErrUnsupportedPci` | Server returned a portable-custom interaction whose `pciId` is not declared in `supportedPcis`. |
1143
+ if (state.phase === "fatal") {
1144
+ if (errors.is(state.error, ErrTokenExpired)) {
1145
+ renderSignInAgain()
1146
+ return
1147
+ }
1148
+ if (errors.is(state.error, ErrSdkUpgradeRequired)) {
1149
+ renderSdkUpgradeMessage()
1150
+ return
1151
+ }
1152
+ logger.error("primer fatal state", { error: state.error })
1153
+ throw state.error
1154
+ }
1155
+ ```
872
1156
 
873
- ## Direct Client Construction Errors
1157
+ Handle invalid submissions by fixing renderer state, not by retrying the same invalid intent:
874
1158
 
875
- | Sentinel | Meaning |
876
- | --- | --- |
877
- | `ErrMalformedAccessToken` | Token string does not look like a JWS. |
878
- | `ErrMissingRequiredPci` | `create` was called for a subject whose required PCI URNs are not present in `supportedPcis`. |
879
- | `ErrNotSerializable` | `JSON.stringify` was called on live `PrimerState`. |
1159
+ ```ts
1160
+ const next = await state.submitChoice(selectedKeys)
1161
+ if (next.phase === "errored") {
1162
+ if (errors.is(next.error, ErrInvalidSubmission)) {
1163
+ renderSelectionError(next.error.message)
1164
+ return
1165
+ }
1166
+ }
1167
+ state = next
1168
+ ```
880
1169
 
881
- # `/logger`
1170
+ ## Logger
882
1171
 
883
1172
  ```ts
884
1173
  import type { PrimerLogger } from "@superbuilders/primer-tives/logger"
@@ -891,74 +1180,152 @@ interface PrimerLogger {
891
1180
  }
892
1181
  ```
893
1182
 
894
- The same logger shape is used by server and browser SDKs. `@superbuilders/slog` matches it directly:
1183
+ `@superbuilders/slog` matches this interface directly.
895
1184
 
896
1185
  ```ts
897
1186
  import * as logger from "@superbuilders/slog"
898
1187
 
899
- const primer = createPrimerServer({ origin, secretKey, logger })
900
- const client = create({ origin, accessToken, subject: "vocabulary", logger })
1188
+ const state = await create({
1189
+ origin,
1190
+ publishableKey,
1191
+ subject: "vocabulary",
1192
+ logger
1193
+ })
901
1194
  ```
902
1195
 
903
- # `/grade-level`
1196
+ ## Grade Levels
904
1197
 
905
1198
  ```ts
906
1199
  import { GRADE_LEVELS } from "@superbuilders/primer-tives/grade-level"
907
1200
  import type { GradeLevel } from "@superbuilders/primer-tives/grade-level"
908
1201
 
1202
+ const gradeLevels = GRADE_LEVELS
1203
+ type RuntimeGradeLevel = GradeLevel
1204
+ ```
1205
+
1206
+ ```ts
909
1207
  const GRADE_LEVELS = ["K", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"] as const
910
1208
  type GradeLevel = (typeof GRADE_LEVELS)[number]
911
1209
  ```
912
1210
 
913
- The server SDK no longer accepts a grade argument. Primer asks the learner for selected grade inside the browser runtime if needed.
1211
+ Grade level is not a `create` option. Treat grade-level identifiers as content/runtime data when Primer presents them, not as an SDK lifecycle input.
1212
+
1213
+ ## Testing
1214
+
1215
+ `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 `create`, transitions, submissions, retries, and timeouts.
914
1216
 
915
- # Onboarding Grade Selection
1217
+ The runtime exchange shape is not public SDK surface. Tests should assert SDK semantics after `create` resolves or rejects:
916
1218
 
917
- Grade selection is not a separate SDK API.
1219
+ | Scenario | Assert |
1220
+ | --- | --- |
1221
+ | auth fails | `create` rejects with the expected auth sentinel |
1222
+ | first runtime work fails recoverably | `create` resolves to `ErroredState` with `retriable: true` |
1223
+ | first runtime work fails terminally | `create` resolves to `FatalState` |
1224
+ | unsupported PCI is presented | `create` resolves to `FatalState` with `ErrUnsupportedPci` |
1225
+ | standard submission is invalid | submit method resolves to `ErroredState` with `ErrInvalidSubmission` |
1226
+ | concurrent submit/timeout conflict occurs | transition resolves to `ErroredState` with `ErrConflict` |
1227
+ | state is serialized | serialization throws `ErrNotSerializable` |
1228
+
1229
+ Example test shape:
1230
+
1231
+ ```ts
1232
+ import * as errors from "@superbuilders/errors"
1233
+ import { create } from "@superbuilders/primer-tives/client"
1234
+ import { ErrUnsupportedPci } from "@superbuilders/primer-tives/errors"
1235
+
1236
+ declare const fetchMock: typeof globalThis.fetch
1237
+
1238
+ const state = await create({
1239
+ origin: "https://primer.test",
1240
+ publishableKey: "pk_test",
1241
+ accessToken: "eyJ.test.token",
1242
+ subject: "math",
1243
+ fetch: fetchMock,
1244
+ logger
1245
+ })
918
1246
 
919
- If the learner's email profile has no selected grade yet, the first `/advance` response is a normal `choice` interaction:
1247
+ if (state.phase === "fatal") {
1248
+ if (errors.is(state.error, ErrUnsupportedPci)) {
1249
+ renderRendererCapabilityError()
1250
+ }
1251
+ }
1252
+ ```
920
1253
 
921
- - `phase: "interaction"`
922
- - `kind: "choice"`
923
- - `minChoices: 1`
924
- - `maxChoices: 1`
925
- - options with identifiers from `GRADE_LEVELS`
1254
+ ## Security Model
926
1255
 
927
- Your renderer should treat it like any other single-choice interaction. Submit the selected option through `submitChoice([grade])`. The server captures that answer, stores the selected grade on the hashed email profile, bootstraps placement, and returns real content.
1256
+ The browser may hold:
928
1257
 
929
- This design keeps the SDK small:
1258
+ ```txt
1259
+ publishable key
1260
+ learner access token
1261
+ ```
930
1262
 
931
- - no grade argument to `getToken`
932
- - no server-side student creation method
933
- - no separate onboarding endpoint
934
- - no browser special case beyond rendering a normal choice interaction
1263
+ The publishable key identifies the Primer frontend. It is not learner auth.
935
1264
 
936
- # Integration Checklist
1265
+ The access token authenticates the learner. Primer verifies it before producing learning state.
937
1266
 
938
- Use this as the minimum correct integration:
1267
+ 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`.
939
1268
 
940
- 1. Backend imports `createPrimerServer` from `/server`.
941
- 2. Backend keeps `secretKey` server-only.
942
- 3. Backend calls `getToken` only for an authenticated user with a verified email.
943
- 4. Backend returns the token string to the browser.
944
- 5. Browser imports `create` from `/client`.
945
- 6. Browser passes `accessToken`, `origin`, `subject`, `logger`, and required `supportedPcis`.
946
- 7. Browser calls `start()` once per runtime mount.
947
- 8. Browser renders by switching on `state.phase`.
948
- 9. Browser never serializes `PrimerState`.
949
- 10. Browser recreates the client after reload or subject switch.
1269
+ Primer does not need these as public SDK inputs:
950
1270
 
951
- # What This SDK Does Not Do
1271
+ ```txt
1272
+ learner email
1273
+ verified email
1274
+ frontend secret key
1275
+ student id
1276
+ grade level
1277
+ ```
952
1278
 
953
- The current SDK does not expose:
1279
+ ## Integration Checklist
954
1280
 
955
- - raw student creation
956
- - student ids as public integration inputs
957
- - SIS-specific identity-provider flows
958
- - host-app email verification
959
- - Primer-sent email verification links
960
- - a grade-level argument on token creation
961
- - persistent browser sessions
962
- - root package exports
1281
+ 1. Import `create` from `@superbuilders/primer-tives/client`.
1282
+ 2. Import shared renderer contracts from `@superbuilders/primer-tives/contracts`.
1283
+ 3. Pass `origin`, `publishableKey`, and `logger` to `create`.
1284
+ 4. Either pass `accessToken` or let `create` perform SDK-managed auth.
1285
+ 5. Choose a public `subject`, or omit `subject` for all-subject runtime scope.
1286
+ 6. Declare every required renderer PCI in `supportedPcis`.
1287
+ 7. Await `create` and handle create-time errors with `errors.try`.
1288
+ 8. Render by switching on `state.phase`.
1289
+ 9. For interaction states, render by switching on `state.kind`.
1290
+ 10. Use only the transition methods exposed by the current state.
1291
+ 11. Handle `ErroredState` through `retriable` and `retry()`.
1292
+ 12. Handle `FatalState` as terminal for the current state object.
1293
+ 13. Never serialize `PrimerState`.
1294
+ 14. Recreate state with `create` after reload, remount, account switch, or subject switch.
1295
+
1296
+ ## What This SDK Does Not Expose
1297
+
1298
+ The current SDK intentionally does not expose:
1299
+
1300
+ | Not exposed | Use instead |
1301
+ | --- | --- |
1302
+ | package-root exports | explicit public subpaths |
1303
+ | backend-only SDK surface | browser/client semantic SDK only |
1304
+ | public auth result union | `create` |
1305
+ | separate auth API | `create` |
1306
+ | client wrapper object | `create` returning `Promise<PrimerState>` |
1307
+ | `start()` | `create` already starts the first state |
1308
+ | `snapshot()` | live `PrimerState` only |
1309
+ | serializable state | recreate with `create` |
1310
+ | public `"all"` subject value | omit `subject` |
1311
+ | implicit PCI negotiation | explicit `supportedPcis` |
1312
+ | grade-level lifecycle option | runtime content/state handling |
1313
+
1314
+ ## Final Invariants
1315
+
1316
+ ```txt
1317
+ create is the only public lifecycle operation
1318
+ create returns Promise<PrimerState>
1319
+ accessToken present skips SDK-managed auth
1320
+ accessToken absent uses SDK-managed auth
1321
+ subject is optional; omitted means all-subject runtime scope
1322
+ subject determines required renderer PCI capabilities
1323
+ supportedPcis declares renderer PCI capabilities
1324
+ PrimerState is the live learning state machine
1325
+ only valid state variants expose learning transitions
1326
+ standard interaction submissions are validated before runtime submission
1327
+ fatal state is terminal for the current state object
1328
+ live state is not serializable
1329
+ ```
963
1330
 
964
- Those omissions are intentional. The public integration point is one backend method, `getToken`, and one browser state machine, `create(...).start()`.
1331
+ 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.