@popcomputer/structured-chat 0.1.0

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 (52) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +780 -0
  3. package/dist/adapters/openai-compatible-model.d.ts +89 -0
  4. package/dist/adapters/openai-compatible-model.js +231 -0
  5. package/dist/core/answer.d.ts +73 -0
  6. package/dist/core/answer.js +60 -0
  7. package/dist/core/chat.d.ts +124 -0
  8. package/dist/core/chat.js +490 -0
  9. package/dist/core/collect-stage.d.ts +203 -0
  10. package/dist/core/collect-stage.js +645 -0
  11. package/dist/core/command.d.ts +16 -0
  12. package/dist/core/command.js +17 -0
  13. package/dist/core/definition.d.ts +10 -0
  14. package/dist/core/definition.js +14 -0
  15. package/dist/core/json-value.d.ts +11 -0
  16. package/dist/core/json-value.js +3 -0
  17. package/dist/core/model-guard.d.ts +52 -0
  18. package/dist/core/model-guard.js +37 -0
  19. package/dist/core/model.d.ts +99 -0
  20. package/dist/core/model.js +109 -0
  21. package/dist/core/protocol.d.ts +257 -0
  22. package/dist/core/protocol.js +153 -0
  23. package/dist/core/question.d.ts +52 -0
  24. package/dist/core/question.js +62 -0
  25. package/dist/core/repair.d.ts +14 -0
  26. package/dist/core/repair.js +9 -0
  27. package/dist/core/session.d.ts +78 -0
  28. package/dist/core/session.js +34 -0
  29. package/dist/core/stage-name.d.ts +3 -0
  30. package/dist/core/stage-name.js +3 -0
  31. package/dist/core/stage.d.ts +88 -0
  32. package/dist/core/stage.js +104 -0
  33. package/dist/core/tool-set.d.ts +49 -0
  34. package/dist/core/tool-set.js +66 -0
  35. package/dist/core/tool.d.ts +149 -0
  36. package/dist/core/tool.js +215 -0
  37. package/dist/core/view.d.ts +95 -0
  38. package/dist/core/view.js +69 -0
  39. package/dist/index.d.ts +16 -0
  40. package/dist/index.js +16 -0
  41. package/dist/integrations/assistant-ui.d.ts +95 -0
  42. package/dist/integrations/assistant-ui.js +114 -0
  43. package/dist/testing/in-memory-session-store.d.ts +4 -0
  44. package/dist/testing/in-memory-session-store.js +38 -0
  45. package/dist/testing/scenario.d.ts +59 -0
  46. package/dist/testing/scenario.js +147 -0
  47. package/dist/testing.d.ts +4 -0
  48. package/dist/testing.js +2 -0
  49. package/examples/agency-search.ts +101 -0
  50. package/examples/answer-modes.ts +92 -0
  51. package/examples/prompt-injection-policy.ts +66 -0
  52. package/package.json +89 -0
package/README.md ADDED
@@ -0,0 +1,780 @@
1
+ # @popcomputer/structured-chat
2
+
3
+ Schema-defined, server-owned chat workflows for Effect applications.
4
+
5
+ `@popcomputer/structured-chat` lets an application describe:
6
+
7
+ - the facts a conversation must collect;
8
+ - the tools available at each stage;
9
+ - the data returned to the model;
10
+ - the data rendered by the browser; and
11
+ - the order in which those capabilities become available.
12
+
13
+ The framework derives the model tool schemas, runtime validation, workflow
14
+ state, browser protocol, and Effect requirements from those definitions.
15
+
16
+ ```sh
17
+ bun add @popcomputer/structured-chat effect
18
+ ```
19
+
20
+ The published entry points are ESM-only and support Node.js 22 or newer.
21
+
22
+ ## Start with one tool
23
+
24
+ ```ts
25
+ import {
26
+ defineTool,
27
+ defineView,
28
+ Stage,
29
+ Tool,
30
+ } from "@popcomputer/structured-chat"
31
+ import { Effect, Schema } from "effect"
32
+
33
+ const AgencyCards = defineView({
34
+ name: "agency_cards",
35
+ version: 1,
36
+ schema: Schema.Struct({
37
+ agencies: Schema.Array(
38
+ Schema.Struct({
39
+ id: Schema.String,
40
+ name: Schema.String,
41
+ reason: Schema.String,
42
+ }),
43
+ ),
44
+ }),
45
+ })
46
+
47
+ const SearchAgencies = defineTool({
48
+ name: "search_agencies",
49
+ description: "Find agencies relevant to the completed project brief.",
50
+ input: Schema.Struct({ query: Schema.NonEmptyTrimmedString }),
51
+ execute: ({ query }) => AgencySearch.find(query),
52
+ }).pipe(
53
+ Tool.modelResult(AgencyEvidenceSchema, ({ evidence }) => evidence),
54
+ Tool.present(AgencyCards, ({ agencies }) => ({ agencies })),
55
+ )
56
+
57
+ const Matching = Stage.tools({
58
+ name: "matching",
59
+ instructions: ["Route the completed brief to one agency search."],
60
+ tools: [SearchAgencies],
61
+ })
62
+ ```
63
+
64
+ `SearchAgencies` is the single source of truth for one capability. Its input
65
+ schema becomes both the model-facing JSON Schema and the authoritative runtime
66
+ parser. Its Effect error and service requirements remain typed.
67
+
68
+ The three result surfaces are deliberately separate:
69
+
70
+ | Surface | Purpose | Typical contents |
71
+ | --- | --- | --- |
72
+ | Server result | Trusted application work | rows, graph nodes, internal scores |
73
+ | Model result | Bounded reasoning evidence | opaque refs, summaries, citations |
74
+ | Views | Browser display | cards, links, public images |
75
+
76
+ Internal data does not reach the model or browser merely because a tool loaded
77
+ it.
78
+
79
+ ## Compose directly with document-graph
80
+
81
+ Structured chat does not wrap or replace retrieval. An application-defined
82
+ `@popcomputer/document-graph` handle is already an Effect and can be the tool
83
+ execution directly:
84
+
85
+ ```ts
86
+ import { FindAgencies } from "./agency-graph.js"
87
+
88
+ const SearchAgencies = defineTool({
89
+ name: "search_agencies",
90
+ description: "Find agencies supported by relevant work evidence.",
91
+ input: Schema.Struct({ query: Schema.NonEmptyTrimmedString }),
92
+ execute: ({ query }) => FindAgencies.search(query, { limit: 6 }),
93
+ }).pipe(
94
+ Tool.modelResult(AgencyEvidenceSchema, toModelEvidence),
95
+ Tool.present(AgencyCards, toAgencyCards),
96
+ )
97
+ ```
98
+
99
+ `FindAgencies` remains the application-owned graph retrieval policy: it can
100
+ combine direct Agency profile matches with Work evidence reached through a
101
+ typed relationship. Structured chat contributes the closed tool schema,
102
+ stage policy, result projections, and UI protocol. The graph's typed failures
103
+ and Effect requirements flow through the tool without another adapter layer.
104
+
105
+ ## Put a free-form conversation on rails
106
+
107
+ Collection stages describe meaning, not a fixed form wizard:
108
+
109
+ ```ts
110
+ import {
111
+ Answer,
112
+ defineChat,
113
+ Question,
114
+ Stage,
115
+ } from "@popcomputer/structured-chat"
116
+ import { Schema } from "effect"
117
+
118
+ const ProjectBrief = Stage.collect({
119
+ name: "project_brief",
120
+ questions: {
121
+ guidance:
122
+ "Ask one conversational question at a time and briefly explain why the answer improves the match.",
123
+ escape: "Not sure yet",
124
+ },
125
+ fields: {
126
+ priority: Answer.semantic(Schema.NonEmptyTrimmedString, {
127
+ description:
128
+ "The unmet need the client most wants an agency to solve.",
129
+ ask: Question.adaptiveChoice(
130
+ "Where would outside agency expertise help most?",
131
+ {
132
+ minimumOptions: 3,
133
+ maximumOptions: 5,
134
+ fallbackOptions: [
135
+ "Launch or grow a product",
136
+ "Build the brand long term",
137
+ "Fix a performance problem",
138
+ ],
139
+ },
140
+ ),
141
+ }),
142
+ location: Answer.explicit(Schema.NonEmptyTrimmedString, {
143
+ description: "Where the client is based.",
144
+ ask: Question.fixed("Where are you based?"),
145
+ }),
146
+ },
147
+ })
148
+
149
+ const Matchmaker = defineChat({
150
+ name: "agency_matchmaker",
151
+ version: 1,
152
+ stages: [ProjectBrief, Matching],
153
+ })
154
+ ```
155
+
156
+ The model can understand an answer supplied naturally earlier in the
157
+ conversation, but the runtime decides whether each field is complete and which
158
+ stage is active.
159
+
160
+ `questions.guidance` is trusted application policy for model-authored wording.
161
+ `questions.escape` adds the same uncertainty option to every browser question.
162
+ When the user chooses it, the server keeps the current field unresolved even if
163
+ the model incorrectly proposes the label as an answer; an adaptive question can
164
+ then explore the same need from another angle.
165
+
166
+ A field can resolve the escape instead of looping. Declare
167
+ `escape: { value }` on the answer and the stage accepts that
168
+ application-authored value when the user chooses the uncertainty option
169
+ while the field is pending. The value is validated against the answer schema
170
+ at definition time and requires `questions.escape` to be configured; a
171
+ confirmed field still requires its question to have been issued first.
172
+ Fields without an escape value stay unresolved and are re-asked from another
173
+ angle, so a client who genuinely cannot answer never blocks the workflow
174
+ unless the application wants it to.
175
+
176
+ For an adaptive choice, valid contextual model suggestions take precedence.
177
+ `fallbackOptions` guarantees useful buttons when a model or provider omits them;
178
+ the runtime validates both sources against the same option bounds and never
179
+ extracts choices from conversational prose.
180
+
181
+ - `Answer.semantic` instructs the model that it may infer a typed fact from
182
+ quoted user evidence.
183
+ - `Answer.explicit` instructs the model to require a direct user statement.
184
+ - `Answer.confirmed` is ignored until the server has actually issued that
185
+ question and the user then confirms it.
186
+
187
+ Only `confirmed` carries a server-enforced ordering guarantee: acceptance
188
+ requires an issued assistant question grounded at its exact transcript location
189
+ plus evidence from a later user message. Loaded sessions that violate that
190
+ ordering are rejected, and repair requires reconfirmation. The
191
+ `semantic`/`explicit` distinction is model instruction only — the server
192
+ verifies both the same way, as one exact quote from any user message. Choose
193
+ `confirmed` when the ordering guarantee must hold against a misbehaving model,
194
+ not just a well-prompted one.
195
+
196
+ | Answer mode | Server enforcement | Model interpretation |
197
+ | --- | --- | --- |
198
+ | `Answer.confirmed` | The exact assistant question must exist before the supporting user message | Treat the later statement as an explicit answer |
199
+ | `Answer.explicit` | Requires an exact quote from a user message | Accept only a directly stated value |
200
+ | `Answer.semantic` | Requires an exact quote from a user message | May infer the typed value from that evidence |
201
+ | No collect stage | The final tool stage is active immediately | No preliminary fact extraction |
202
+
203
+ See the compile-checked
204
+ [`answer-modes.ts`](./examples/answer-modes.ts) example for required Q&A,
205
+ free-form understanding, and completely open tool chat definitions.
206
+
207
+ Every proposed answer requires an exact quote from a user message. The model
208
+ does not calculate transcript positions: the runtime resolves the most recent
209
+ eligible user message, then persists its index with the typed value and quote
210
+ as one unit. Loaded sessions are rejected if that quote no longer points to the
211
+ same user message. Assistant text cannot become evidence.
212
+
213
+ Applications can read the typed value and its provenance without reaching into
214
+ the persisted state shape:
215
+
216
+ ```ts
217
+ const priority = Matchmaker.getAcceptedAnswer(
218
+ reply.turn.state,
219
+ ProjectBrief,
220
+ "priority",
221
+ )
222
+
223
+ priority?.value
224
+ priority?.evidence.messageIndex
225
+ priority?.evidence.quote
226
+ ```
227
+
228
+ For semantic answers, the quote supports the model's inference; it does not
229
+ need to equal the typed value. Treat the quote as untrusted user content.
230
+
231
+ ### Validate domain acceptance conversationally
232
+
233
+ Keep parsing and business acceptance separate when a structurally valid value
234
+ may still be unsuitable for the workflow:
235
+
236
+ ```ts
237
+ class BudgetTooLow extends Schema.TaggedError<BudgetTooLow>()(
238
+ "BudgetTooLow",
239
+ { minimum: Schema.Number },
240
+ ) {}
241
+
242
+ const Budget = Answer.explicit(Schema.Number, {
243
+ // The description remains model-visible, so repeat acceptance constraints.
244
+ description: "Project budget in GBP; must be at least 5,000",
245
+ ask: Question.fixed("What budget have you set aside?"),
246
+ validate: (budget) =>
247
+ budget >= 5_000
248
+ ? Effect.void
249
+ : Effect.fail(new BudgetTooLow({ minimum: 5_000 })),
250
+ reject: {
251
+ ask: Question.fixed(
252
+ "Our minimum engagement is £5,000. Could you revise the budget?",
253
+ ),
254
+ },
255
+ })
256
+ ```
257
+
258
+ `schema` is the structural, model-visible contract. `validate` runs on its
259
+ decoded Type-side value and may use Effect services. Its exact errors and
260
+ requirements flow through the stage and chat types. If it fails, the package
261
+ returns `AnswerValidationRejected` with the original typed error and the
262
+ trusted retry prompt; no answer, message, revision, or partial field set is
263
+ persisted.
264
+
265
+ When one proposal contains several answers, validators run sequentially in
266
+ field-definition order and stop at the first rejection. A later validator is
267
+ not started after an earlier field fails. This ordering is deliberate because
268
+ validators may use Effect services; combine independent checks inside a single
269
+ field validator if the application wants its own concurrency and error policy.
270
+
271
+ Present that non-progressing retry with the browser's previous session
272
+ reference, if one exists:
273
+
274
+ ```ts
275
+ const response = yield* presentAnswerValidationRejection({
276
+ rejection,
277
+ session: request.session,
278
+ })
279
+ ```
280
+
281
+ Retry prompts are fixed or choice questions in v1. They never require another
282
+ model call, and choice values remain server-only.
283
+
284
+ For adaptive questions, the model proposes a field together with its wording
285
+ and options. The runtime accepts that presentation only when the field matches
286
+ the server-selected first missing field. The model can phrase a question; it
287
+ cannot choose which workflow requirement comes next. Missing, invalid, or
288
+ wrong-field adaptive presentation falls back to the application-authored
289
+ question without suggested choices, so optional model wording cannot make the
290
+ workflow unavailable.
291
+
292
+ `Question.adaptive(goal, { fallback })` keeps those two voices separate: the
293
+ goal is model-facing phrasing guidance and is never shown to the user, while
294
+ the fallback is the user-facing question presented whenever no valid model
295
+ wording is available. Adaptive-choice options — model-authored and
296
+ `fallbackOptions` alike — are validated against the answer schema, because a
297
+ selected label is later submitted as that answer's value.
298
+
299
+ ## Run one server action
300
+
301
+ The browser submits only its latest text and, after the first turn, an opaque
302
+ session reference. State, history, tools, and answers stay on the server.
303
+
304
+ ```ts
305
+ import {
306
+ presentChatReply,
307
+ StructuredChatTurnRequestSchema,
308
+ Text,
309
+ } from "@popcomputer/structured-chat"
310
+ import { Effect } from "effect"
311
+
312
+ const program = Effect.gen(function* () {
313
+ const request = yield* parseRequest(StructuredChatTurnRequestSchema)
314
+ const publicSessionId = request.session?.id ?? crypto.randomUUID()
315
+
316
+ const reply = yield* Matchmaker.reply({
317
+ namespace: authenticatedUser.id,
318
+ sessionId: publicSessionId,
319
+ expectedRevision: request.session?.revision,
320
+ message: request.message,
321
+ })
322
+
323
+ return yield* presentChatReply(
324
+ { ...reply, sessionId: publicSessionId },
325
+ {
326
+ result: ({ result }) => [
327
+ Text.make("These are the strongest evidence-backed matches."),
328
+ ...result.views,
329
+ ],
330
+ },
331
+ )
332
+ })
333
+ ```
334
+
335
+ `ChatSessionStore` is a two-operation adapter: load a complete snapshot, then
336
+ atomically replace it at an expected revision. A stale or concurrent turn
337
+ fails with `ChatSessionConflict`. The package includes an in-memory adapter at
338
+ `@popcomputer/structured-chat/testing`; production applications should provide
339
+ a durable database adapter.
340
+
341
+ The application should generate initial public session IDs on the server and
342
+ pass the authenticated actor as a separate `namespace`. The persistence
343
+ identity is the tuple `(namespace, sessionId, chat, version)`: components are
344
+ never concatenated. Different component pairs cannot collide at delimiter
345
+ boundaries, and two individually valid IDs cannot become invalid merely
346
+ because they were joined. Browser-held IDs are references, not authorization.
347
+
348
+ A session stores at most 200 conversation messages. Each reply reserves room
349
+ for the user message and the largest possible assistant result before calling
350
+ the model or an application tool. A session with 198 messages may advance; a
351
+ session with 199 or 200 messages fails with `InvalidChatSession` and reason
352
+ `history_limit` without performing model, tool, or persistence work. Start a
353
+ new session at that boundary. History summarisation and compaction remain an
354
+ explicit application policy rather than silently changing conversation
355
+ meaning.
356
+
357
+ ## Continue naturally after the first tool result
358
+
359
+ `Stage.tools(...)` stays active by default. A user can refine the previous
360
+ search without restarting the collection stage:
361
+
362
+ ```txt
363
+ User: We need a public-sector service redesign.
364
+ Assistant: [search results]
365
+ User: Favour agencies with accessibility experience.
366
+ Assistant: [refined search results]
367
+ ```
368
+
369
+ When a tool defines `Tool.modelResult(...)`, each bounded result is retained in
370
+ server-owned history as untrusted assistant context. That lets the next model
371
+ step resolve references to earlier results while keeping server-only data and
372
+ browser views out of the prompt. Retrieved result text never becomes trusted
373
+ instructions.
374
+
375
+ For a deliberately one-shot read, opt into completion explicitly:
376
+
377
+ ```ts
378
+ const FetchReportOnce = Stage.tools({
379
+ name: "fetch_report_once",
380
+ instructions: ["Fetch the completed report once."],
381
+ tools: [FetchReport],
382
+ afterExecution: "complete",
383
+ })
384
+ ```
385
+
386
+ ## Run writes as terminal commands
387
+
388
+ Declare side effects honestly. A command receives the package-derived stable
389
+ ID that its application endpoint must use as an idempotency key:
390
+
391
+ ```ts
392
+ const Submit = defineCommand({
393
+ name: "submit_application",
394
+ description: "Submit the confirmed application once.",
395
+ input: SubmitApplicationInput,
396
+ execute: (input, { commandId }) =>
397
+ Applications.submit(input, { idempotencyKey: commandId }),
398
+ }).pipe(
399
+ Tool.present(SubmissionReceipt, toSubmissionReceipt),
400
+ )
401
+
402
+ const SubmitApplication = Stage.command({
403
+ name: "submit_application",
404
+ instructions: ["Submit the confirmed application."],
405
+ command: Submit,
406
+ })
407
+ ```
408
+
409
+ `Stage.command` accepts exactly one command, must be the final chat stage, and
410
+ always completes the chat. Commands cannot be added to repeatable
411
+ `Stage.tools` sets. Persisted command chats execute through `Chat.reply`; the
412
+ unscoped `Chat.run` seam refuses to run them because it has no session/revision
413
+ identity.
414
+
415
+ The command ID is an opaque SHA-256 identity derived from `(namespace, chat,
416
+ version, sessionId, expectedRevision, commandName)`. A retry after a failed
417
+ session-store write therefore reaches the application with the same ID. The
418
+ application's durable idempotent endpoint must:
419
+
420
+ - return the original outcome when the same ID and input are retried;
421
+ - reject reuse of one ID with different input; and
422
+ - commit its idempotency record atomically with the side effect.
423
+
424
+ This v1 design preserves the session store's single optimistic replacement per
425
+ turn. It does not pretend that one chat-state write can journal a command before
426
+ and after execution.
427
+
428
+ ## Opt into bounded conversation repair
429
+
430
+ Repair is disabled by default. Enable it only on chats whose final stage is a
431
+ repeatable query:
432
+
433
+ ```ts
434
+ const Matchmaker = defineChat({
435
+ name: "agency_matchmaker",
436
+ version: 2,
437
+ stages: [ProjectBrief, Matching],
438
+ repair: Repair.standard({ maximumCorrections: 5 }),
439
+ })
440
+ ```
441
+
442
+ On a persisted follow-up, the model sees a closed choice between the stage's
443
+ queries and `apply_conversation_repairs`. An ordinary follow-up chooses a query
444
+ and still uses one model request. A correction uses at most one bounded second
445
+ request after the package applies the typed transition:
446
+
447
+ - semantic and explicit answers are replaced in place only with fresh evidence
448
+ from the current user message, then the query is rerun;
449
+ - confirmed answers are cleared together with their issuance cursor, the chat
450
+ rewinds to the earliest affected collect stage, and the question is reissued;
451
+ - multiple confirmed corrections are retained as an ordered persisted
452
+ reconfirmation queue; and
453
+ - field validators run again before a replacement is accepted.
454
+
455
+ Repair cannot be enabled for a terminal query or command chat. Commands are
456
+ never offered to repair planning and never rerun. Enabling repair adds repair
457
+ state to persisted sessions, so bump the chat version when adding it to an
458
+ existing definition.
459
+
460
+ ## Connect a model provider
461
+
462
+ ```ts
463
+ import {
464
+ ModelProvider,
465
+ structuredChatModelLayer,
466
+ } from "@popcomputer/structured-chat"
467
+
468
+ const ModelLive = structuredChatModelLayer({
469
+ provider: ModelProvider.cloudflareWorkersAI({
470
+ model: "@cf/google/gemma-4-26b-a4b-it",
471
+ complete: ({ model, input }, signal) =>
472
+ env.AI.run(model, { ...input }, { signal }),
473
+ requestOptions: {
474
+ temperature: 0,
475
+ max_completion_tokens: 256,
476
+ },
477
+ }),
478
+ timeoutMilliseconds: 15_000,
479
+ classifyError: (cause) =>
480
+ workersAIBlocked(cause)
481
+ ? "response_blocked"
482
+ : "request_failed",
483
+ })
484
+ ```
485
+
486
+ The application chooses a provider and model; the package owns the provider's
487
+ tool-call dialect and strongest safe schema guarantee. There is no
488
+ `toolArguments: "guided" | "strict"` switch for application code to get wrong.
489
+
490
+ For OpenAI, only the provider definition changes. Recognised Structured Outputs
491
+ model families use constrained function arguments automatically; unknown or
492
+ older model identifiers conservatively use schema guidance:
493
+
494
+ ```ts
495
+ const ModelLive = structuredChatModelLayer({
496
+ provider: ModelProvider.openAI({
497
+ model: "gpt-5.6-luna",
498
+ complete: ({ model, input }, signal) =>
499
+ openAI.chat.completions.create(
500
+ { ...input, model },
501
+ { signal },
502
+ ),
503
+ }),
504
+ timeoutMilliseconds: 15_000,
505
+ })
506
+ ```
507
+
508
+ The adapter always requires exactly one tool call, disables parallel tool
509
+ calls and streaming, separates trusted instructions from untrusted
510
+ conversation text, parses the provider envelope and JSON arguments, then lets
511
+ the selected Effect Schema validate them. Provider options cannot override
512
+ those invariants.
513
+
514
+ Cloudflare Workers AI uses schemas as generation guidance because its API does
515
+ not guarantee schema-constrained output. Invalid output is rejected at the
516
+ Effect Schema boundary and may receive the chat's one bounded repair attempt.
517
+ Recognised OpenAI models add `strict: true`. Before contacting OpenAI, the
518
+ adapter rejects schemas whose root is not an object, whose objects allow
519
+ additional properties, or whose object properties are optional.
520
+
521
+ Model optional values explicitly with `Schema.NullOr(...)` so the field remains
522
+ required while its value may be absent. If a constrained provider receives an
523
+ incompatible tool, the request fails before transport with
524
+ `UnsupportedModelToolSchema`, including the safe tool name, schema path, and
525
+ incompatibility reason. Every provider response is still parsed with the
526
+ original Effect Schema: constrained generation strengthens the trust boundary;
527
+ it never replaces it.
528
+
529
+ Provider credentials, gateway routing, retries, and moderation remain at the
530
+ application composition edge. If a provider is not built in, implement the
531
+ public `StructuredChatModelService` Effect seam; provider-specific SDK types do
532
+ not need to leak into chat, stage, or tool definitions.
533
+
534
+ Built-in provider policy is deliberately conservative:
535
+
536
+ | Provider definition | Tool argument policy |
537
+ | --- | --- |
538
+ | `ModelProvider.cloudflareWorkersAI(...)` | Schema-guided, then parsed and validated |
539
+ | `ModelProvider.openAI(...)` with a recognised Structured Outputs model | Schema-constrained, then parsed and validated |
540
+ | `ModelProvider.openAI(...)` with an unknown model ID | Schema-guided, then parsed and validated |
541
+
542
+ ## Connect assistant-ui
543
+
544
+ ```tsx
545
+ import { AssistantRuntimeProvider, useLocalRuntime } from "@assistant-ui/react"
546
+ import { CollectQuestionView } from "@popcomputer/structured-chat"
547
+ import {
548
+ makeAssistantChatModelAdapter,
549
+ makeAssistantView,
550
+ } from "@popcomputer/structured-chat/assistant-ui"
551
+ import type { ReactNode } from "react"
552
+
553
+ const AgencyCardsUI = makeAssistantView(AgencyCards, {
554
+ render: AgencyCardsComponent,
555
+ fallback: InvalidCardFallback,
556
+ })
557
+
558
+ const QuestionUI = makeAssistantView(CollectQuestionView, {
559
+ render: ProjectQuestion,
560
+ fallback: InvalidQuestionFallback,
561
+ })
562
+
563
+ const model = makeAssistantChatModelAdapter({
564
+ endpoint: "/api/matchmaker/turn",
565
+ })
566
+
567
+ export function MatchmakerRuntime({
568
+ children,
569
+ }: Readonly<{ children: ReactNode }>) {
570
+ const runtime = useLocalRuntime(model)
571
+
572
+ return (
573
+ <AssistantRuntimeProvider runtime={runtime}>
574
+ <QuestionUI />
575
+ <AgencyCardsUI />
576
+ {children}
577
+ </AssistantRuntimeProvider>
578
+ )
579
+ }
580
+ ```
581
+
582
+ The adapter sends only the current user text and latest opaque session
583
+ reference. It rejects attachments, strictly parses responses, and stores the
584
+ new reference in assistant message metadata. A selected option is simply its
585
+ displayed text; the server-owned stage determines what that text may answer.
586
+ Pass `fetch` only when the application needs a custom transport or test seam.
587
+
588
+ This is the lowest-friction assistant-ui `LocalRuntime` path. Its attachment,
589
+ speech, feedback, suggestion, history, and thread-list adapters remain ordinary
590
+ assistant-ui composition; structured-chat does not replace them. The default
591
+ structured-chat session is linear and optimistically revisioned. Applications
592
+ that expose message editing, regeneration, or persistent branching should pair
593
+ it with an application-owned branch/session policy rather than treating browser
594
+ history as authoritative.
595
+
596
+ ## Plan without executing
597
+
598
+ The default remains one call:
599
+
600
+ ```ts
601
+ const result = yield* Matching.run(messages)
602
+ ```
603
+
604
+ Approval flows, dry runs, and evals can stop after a strictly parsed proposal:
605
+
606
+ ```ts
607
+ const call = yield* Matching.plan(messages)
608
+
609
+ // Application-owned review or approval.
610
+ const result = yield* Matching.toolSet.execute(call)
611
+ ```
612
+
613
+ `plan` uses the same trusted instructions, closed tool set, guards, provider
614
+ adapter, and schemas as `run`; it simply omits application execution.
615
+ `execute` accepts that already parsed call. Use `executeCall(unknownInput)`
616
+ only at a boundary where the call has not already been parsed.
617
+
618
+ ## Test transcript scenarios
619
+
620
+ The optional testing entry point scripts valid model calls while exercising the
621
+ real public runtime, schemas, Effects, and session store:
622
+
623
+ ```ts
624
+ import {
625
+ inMemoryChatSessionStore,
626
+ Scenario,
627
+ } from "@popcomputer/structured-chat/testing"
628
+
629
+ const ModelTest = Scenario.model(
630
+ Scenario.answers(ProjectBrief, {
631
+ location: Scenario.quoted("Leeds", { quote: "based in Leeds" }),
632
+ }),
633
+ Scenario.call(SearchAgencies, { query: "Leeds public services" }),
634
+ )
635
+
636
+ const reply = yield* Matchmaker.reply({
637
+ sessionId: "scenario-1",
638
+ message: "We are based in Leeds.",
639
+ }).pipe(
640
+ Effect.provide(Layer.merge(ModelTest, inMemoryChatSessionStore)),
641
+ )
642
+ ```
643
+
644
+ Scenario values use each schema's Type side and are encoded into provider
645
+ calls, so transformed schemas remain covered. A quote without an index must
646
+ occur in exactly one preceding user message; assistant matches are ignored. If
647
+ the same quote occurs in several user messages, specify
648
+ `{ quote, messageIndex }` and the helper verifies that exact message.
649
+
650
+ Use the DSL for valid conversational flows. Keep malformed envelopes, invalid
651
+ evidence, stale revisions, and history/size limits on raw model and store
652
+ layers—their purpose is to exercise states the helper intentionally refuses to
653
+ construct.
654
+
655
+ After enabling repair, valid correction scripts can use the same evidence
656
+ rules:
657
+
658
+ ```ts
659
+ Scenario.repairs(
660
+ Scenario.replace(ProjectBrief, "location", "Manchester", {
661
+ quote: "Actually, Manchester",
662
+ }),
663
+ )
664
+ ```
665
+
666
+ For a field declared with `Answer.confirmed`, use `Scenario.reconfirm(...)` in
667
+ the same corrections list. The helper's type contract prevents replacement of
668
+ confirmed fields and reconfirmation of semantic or explicit fields.
669
+
670
+ ## Prompt injection and trust boundaries
671
+
672
+ The framework provides structural containment, not a claim that a prompt can
673
+ make an LLM intrinsically safe.
674
+
675
+ Built-in boundaries include:
676
+
677
+ - trusted stage instructions and untrusted conversation are separate fields;
678
+ - provider adapters serialize conversation under `untrustedConversation` in a
679
+ user message rather than interpolating it into system instructions;
680
+ - each stage exposes a closed tool set, so later capabilities cannot be called
681
+ early;
682
+ - exactly one call is accepted and unknown calls are parsed before execution;
683
+ - excess capability fields are rejected;
684
+ - collection requires exact user-message evidence;
685
+ - confirmed fields cannot be populated before being issued;
686
+ - session state and history are server-owned and revisioned;
687
+ - bounded tool model results used for follow-ups remain untrusted conversation
688
+ context;
689
+ - model, server, and browser result projections are distinct; and
690
+ - optional guards can inspect both the untrusted conversation before the
691
+ model and the strictly parsed proposal before application code executes.
692
+
693
+ Optional guards run before the provider and, when configured, after strict
694
+ call parsing but before application execution. Both hooks can use ordinary
695
+ Effect services:
696
+
697
+ ```ts
698
+ const InjectionPolicy = defineModelGuard({
699
+ name: "prompt_injection_policy",
700
+ check: ({ messages, toolNames }) =>
701
+ PolicyService.check({ messages, toolNames }),
702
+ checkCall: ({ messages, call }) =>
703
+ PolicyService.checkCall({ messages, call }),
704
+ })
705
+
706
+ const Matching = Stage.tools({
707
+ name: "matching",
708
+ instructions: ["Route the completed brief to one search."],
709
+ tools: [SearchAgencies],
710
+ guards: [InjectionPolicy],
711
+ })
712
+ ```
713
+
714
+ See the complete, strictly type-checked
715
+ [`prompt-injection-policy.ts`](./examples/prompt-injection-policy.ts) example
716
+ for an Effect service whose typed policy rejection flows through the stage.
717
+
718
+ The package intentionally does not ship a universal keyword detector. Prompt
719
+ injection is contextual, and a weak detector can create false confidence. A
720
+ guard may call a specialised classifier, a policy service, or deterministic
721
+ application rules and return the application's typed Effect failure.
722
+
723
+ Applications still own:
724
+
725
+ - authentication, actor-to-session binding, CSRF and origin protection;
726
+ - tool-level authorization and database visibility filters;
727
+ - confirmation for consequential writes;
728
+ - output escaping and safe link/image policies;
729
+ - provider moderation and data-retention choices; and
730
+ - rate limits, spend limits, secrets, network policy, audit retention, and DLP.
731
+
732
+ Those last operational concerns belong in the hosting platform or API gateway,
733
+ not in a chat workflow package.
734
+
735
+ ## Versions
736
+
737
+ | Version | Change it when | Effect of changing it |
738
+ | --- | --- | --- |
739
+ | Chat `version` | Persisted stage, answer, provenance, command, or repair state is no longer compatible | Creates a new persistence scope; old sessions are not silently decoded as the new workflow |
740
+ | View `version` | Browser data shape or meaning changes incompatibly | Changes `schemaVersion`; old payloads fail closed in the renderer |
741
+
742
+ Tool inputs and model-result projections are schema-validated but do not have a
743
+ separate numeric version. Make incompatible tool changes deliberately: rename
744
+ the tool or coordinate provider, application, and eval changes together.
745
+
746
+ ## Design trade-offs
747
+
748
+ - Workflows are sequential collect stages followed by one query-tool or command
749
+ stage. Query stages remain active for follow-ups unless explicitly terminal;
750
+ command stages are always terminal. Arbitrary cyclic agent graphs are
751
+ intentionally out of scope.
752
+ - Every model request produces exactly one closed, schema-parsed call. One
753
+ contract-invalid response may receive a bounded repair request before any
754
+ application tool executes. If collection still receives invalid or
755
+ ungrounded model output — including evidence quotes that do not appear
756
+ verbatim in a user message — it safely presents the application-authored
757
+ pending question without advancing answers. A turn
758
+ may advance from collection into its next sequential stage. Opt-in repair
759
+ adds one specifically bounded planner step on a final-stage correction: one
760
+ repair call may be followed by one collect/query call. There is no unbounded
761
+ model → tool → model loop.
762
+ - Tool and command execution are application-owned. Commands make idempotency
763
+ identity explicit; the framework does not infer authorization or provide the
764
+ application's durable outcome journal.
765
+ - Durable persistence requires an adapter; no database is selected by default.
766
+ - Public errors expose only stable, content-free reasons and identifiers
767
+ relevant to each failure—not raw prompts, provider responses, persisted
768
+ state, or unknown causes. Content-free Effect spans cover model
769
+ requests and session-store loads/replacements, with aggregate message and
770
+ character counts where available. They never include session identifiers,
771
+ namespaces, revisions, message content, or tool arguments/results.
772
+ - assistant-ui and React are optional peer integrations. The core has no React
773
+ runtime dependency.
774
+ - The default browser adapter is non-streaming and uses one linear server
775
+ revision. Streaming and branch-aware thread restoration require explicit
776
+ application protocols rather than hidden framework behaviour.
777
+
778
+ These constraints keep the default path legible while leaving provider,
779
+ persistence, guards, tools, views, and Effect services independently
780
+ composable.