Chapter 63: Application 5: Mini Prompt Workbench
The fifth application is the AI-flavored one — a mini prompt workbench that exercises streaming generation, AI-generated UI from Chapter 73, and the validation patterns from Chapter 74.
The workbench is the bridge between Part VI’s architectural walkthroughs and Part VIII’s AI-future arguments. The application is realistic enough to show the architecture handling stochastic content; small enough that the patterns are visible.
The Product
Section titled “The Product”A Prompt Workbench that:
- Lets the user write a prompt.
- Sends the prompt to a language model and streams the response.
- Renders the streaming response as it arrives.
- Lets the model optionally return a generated UI — a small piece of markup the workbench validates and renders inline.
- Records each prompt and response to local history.
- Lets the user fork, retry, or share previous prompts.
The application is a smaller, single-user version of what production AI products (Claude.ai, ChatGPT, the various Code-style products) look like. The architecture handles the streaming, the validation, the history management, and the generated-UI rendering through the same patterns the previous applications used.
The Markup
Section titled “The Markup”<kit-shell name="prompt-workbench"> <kit-boundary surface="workbench" feature="ai"> <main> <h1>Prompt Workbench</h1>
<kit-boundary surface="composer"> <form data-meta-event="prompt.submit_requested" data-meta-prop-prevent-default="true" > <kit-field label="Prompt"> <kit-text-field name="prompt" multiline required></kit-text-field> </kit-field>
<kit-field> <kit-checkbox name="allowGeneratedUI" value="yes"> Allow generated UI responses </kit-checkbox> </kit-field>
<kit-button type="submit" variant="primary">Send</kit-button> </form> </kit-boundary>
<kit-boundary surface="response"> <h2>Response</h2> <div id="response-stream"></div> </kit-boundary>
<kit-boundary surface="history"> <h2>History</h2> <ul id="history"></ul> </kit-boundary> </main> </kit-boundary></kit-shell>The structure is small. The composer surface is for the user’s input. The response surface is where the streaming response renders. The history surface lists previous prompts.
The Streaming Module
Section titled “The Streaming Module”The streaming is the application’s most novel piece. A module owns the AI API connection and exposes the streaming through the runtime’s event bus:
const aiStreamModule = defineKitModule({ name: 'ai-stream', events: { 'prompt.submit_requested': async (event) => { const prompt = event.payload.data.prompt const allowUI = event.payload.data.allowGeneratedUI === 'yes'
runtime.emit({ type: 'ai.stream_started', context: event.context, payload: { prompt } })
try { const response = await fetch('/api/ai/stream', { method: 'POST', body: JSON.stringify({ prompt, allowUI }) })
const reader = response.body!.getReader() const decoder = new TextDecoder()
while (true) { const { value, done } = await reader.read() if (done) break const chunk = decoder.decode(value) runtime.emit({ type: 'ai.chunk_received', context: event.context, payload: { chunk } }) }
runtime.emit({ type: 'ai.stream_completed', context: event.context, payload: { prompt } }) } catch (error) { runtime.emit({ type: 'ai.stream_failed', context: event.context, payload: { error: String(error) } }) } } }})The module receives the user’s submit event. It opens a streaming response from the AI API. As chunks arrive, the module emits ai.chunk_received events. Other modules observe the chunks and update the UI.
The Renderer Module
Section titled “The Renderer Module”A separate module owns the streaming response’s UI:
const responseRendererModule = defineKitModule({ name: 'response-renderer', onStart: ({ runtime }) => { let accumulated = '' let target: HTMLElement | null = null
runtime.on('ai.stream_started', () => { target = document.getElementById('response-stream') if (target) target.textContent = '' accumulated = '' })
runtime.on('ai.chunk_received', (event) => { const chunk = event.payload.chunk as string accumulated += chunk
// Check for generated-UI markers if (accumulated.includes('<<<UI>>>')) { renderGeneratedUI(accumulated) } else if (target) { target.textContent = accumulated } })
runtime.on('ai.stream_completed', () => { // Finalize: render any partial UI, record to history runtime.command({ type: 'history.add', payload: { prompt: '...', response: accumulated } }) }) }})The module accumulates the chunks. If the accumulated response includes a generated-UI marker (the model’s structured-output convention from Chapter 73), the renderer handles it through the validation pipeline (next section). Otherwise it’s plain text.
The Generated-UI Validator
Section titled “The Generated-UI Validator”When the model returns generated UI, the application validates it before rendering:
function renderGeneratedUI(rawResponse: string) { const markup = extractUIMarkup(rawResponse) // strip the markers const parsed = new DOMParser().parseFromString(markup, 'text/html')
// Validate against the workbench's allowed schema const validation = validateGeneratedUI(parsed.body, ALLOWED_SCHEMA) if (!validation.ok) { runtime.emit({ type: 'ai.generated_ui_rejected', payload: { error: validation.error } }) return }
// Insert into the shadow DOM of a sandboxed container const container = document.getElementById('response-stream')! const host = container.shadowRoot ?? container.attachShadow({ mode: 'open' }) host.replaceChildren(...parsed.body.children)}
const ALLOWED_SCHEMA = { elements: new Set(['div', 'p', 'h1', 'h2', 'h3', 'ul', 'ol', 'li', 'code', 'pre', 'kit-button', 'kit-field', 'kit-text-field', 'kit-card']), events: new Set([/* the workbench's allowed event surface */]), attributes: new Map([ ['kit-button', new Set(['variant', 'meta-event', 'meta-command', 'meta-prop-*'])], // ... per-component allowed attributes ])}The validator from Chapter 74 catches malformed or malicious output. The validated UI renders into a shadow root for additional isolation. The metadata boundary still observes events from inside the shadow root (composed events bubble), so any actions the model declared work through the runtime.
The History Module
Section titled “The History Module”A history module persists prompts and responses to localStorage and exposes them through a provider:
const historyModule = defineKitModule({ name: 'history', commands: { 'history.add': (command) => { const entry = { id: crypto.randomUUID(), timestamp: Date.now(), ...command.payload } const all = JSON.parse(localStorage.getItem('workbench.history') ?? '[]') all.unshift(entry) const trimmed = all.slice(0, 100) // keep last 100 localStorage.setItem('workbench.history', JSON.stringify(trimmed)) runtime.emit({ type: 'history.updated', payload: { entries: trimmed } }) return { id: entry.id } }, 'history.clear': () => { localStorage.removeItem('workbench.history') runtime.emit({ type: 'history.updated', payload: { entries: [] } }) return {} } }})The module handles both history.add (after each prompt completes) and history.clear (the user’s clear history button). Each operation emits history.updated so UI components can re-render the history list.
Patterns the Application Exercises
Section titled “Patterns the Application Exercises”Streaming as runtime events. The architecture handles streaming naturally — each chunk is an event the modules observe. The pattern composes with the runtime’s diagnostic stream (the trace shows the streaming progress) and with any other observers the application needs.
Generated-UI validation. The model’s output goes through the architecture’s validation boundary before rendering. The schema is small and explicit. Unsafe output is rejected before it reaches the DOM.
Shadow-DOM sandboxing for generated UI. The validated UI renders into a shadow root. The encapsulation is the second defense layer beyond the validation.
Persistent history through storage. The history lives in localStorage. The history module is the interface; the storage is the substrate.
Composed events from generated UI. The model can include meta-event attributes in its output. The metadata boundary observes them. The model’s output participates in the runtime through the same protocol as everything else.
What Production Would Add
Section titled “What Production Would Add”A production version of this workbench would extend in specific ways:
Server-side rate limiting and cost control — the AI API calls have to be metered.
Better validation — the schema would be larger; the validator would catch more sophisticated injection patterns.
Sandboxed iframe rendering — for higher-stakes outputs, the generated UI would render in a sandboxed iframe with explicit postMessage for any actions.
Multi-modal support — images, audio, file uploads as part of the prompt.
Conversation history — the prompts would be threaded into conversations rather than one-shot.
Model selection — the user would pick which model to use.
Each extension is a new module or repository in the architecture’s vocabulary. The shape stays the same.
Bridge to React Adapter
Section titled “Bridge to React Adapter”Chapter 64 takes the React adapter — how teams running React applications can adopt Kitsune incrementally, with both architectures coexisting on the same page.
After Chapter 64, Chapter 65 closes Part VI with What Kitsune Is Not — the honest accounting of where the architecture doesn’t fit.
Exercise: Build the Prompt Workbench
Section titled “Exercise: Build the Prompt Workbench”Implement the prompt workbench from this chapter. The application’s full set:
- The composer markup with the prompt form.
- The streaming module that owns the AI API connection.
- The renderer module that displays the streaming response and handles generated-UI markup.
- The history module that persists prompts to local storage.
- The validation logic that gates generated UI.
Test it against a real model (or a fake one that returns predetermined responses):
- Submit a prompt with
allowGeneratedUIunchecked. Verify the text streams in. - Submit a prompt with
allowGeneratedUIchecked. Verify generated UI renders if the model returns it. - Make the model return invalid UI (e.g., a
<script>tag). Verify the validation rejects it. - Verify the history accumulates and persists across page reloads.
Reflect on:
- How does the streaming pattern compare to other approaches you’ve seen (e.g., the Vercel AI SDK)?
- How does the validation surface limit what the model can do?
- If you wanted to expand the workbench into a full Claude.ai-style product, what would change in the architecture? (More modules, larger schema, more sandboxing — but the architectural shape stays the same.)
The workbench is the architecture meeting the AI generation. The patterns from Chapters 72–74 are concrete here. The next chapters of Part VI cover integration and what the architecture doesn’t try to be.