Chapter 40: Building the Shell
An application needs a home.
The runtime built in Chapter 39 is the architecture’s coordination kernel, but it doesn’t have a place to live in the DOM. It doesn’t know when the application starts or stops. It doesn’t have a root boundary to anchor the application’s context. It doesn’t have a way to install a set of modules at startup. It needs a shell.
The shell is the application’s entry point. It owns the runtime, installs the application’s modules, establishes the root boundary, mounts to a DOM element, manages startup and shutdown, and exposes the runtime to the rest of the application code that needs to talk to it. The shell is small — under two hundred lines of TypeScript when implemented — and its job is to be the seam between the runtime as a library and the application as a thing that runs.
This chapter builds the shell. The implementation is two parallel pieces: a createShell() factory function for programmatic use, and a <kit-shell> custom element for declarative use. Most applications use one or the other; some use both. The contract is the same.
What the Shell Owns
Section titled “What the Shell Owns”The shell owns the application’s lifecycle and the architectural pieces that come with it:
The runtime. Each shell creates its own runtime instance. The runtime isn’t a global; it’s scoped to the shell that created it. This matters for testing (each test creates its own shell, runtime, and clean state) and for multi-shell scenarios (an embedded application in a page might have its own shell with its own runtime).
Module installation. The shell installs the application’s modules at startup, in the order they were specified. Installation happens once, during the shell’s start() call. Modules can be added or removed dynamically afterward, but the default installation is declarative.
The root boundary. The shell establishes a <kit-boundary> at the application’s root, supplying the outermost context — typically the application name, the current route (when a router adapter is also installed), and the user’s identity. Every event fired anywhere inside the application inherits this boundary’s context unless an inner boundary overrides.
Lifecycle hooks. The shell provides onStart, onStop, and onError hooks for application-level setup and teardown. Modules have their own lifecycle hooks; the shell’s hooks are for the application code that owns the shell.
Diagnostic exposure. The shell offers a default diagnostic subscriber that logs to the console during development and stays quiet in production. Applications can replace or extend it.
The shell doesn’t own rendering. It doesn’t decide which framework draws the DOM. It doesn’t replace the application’s content. It doesn’t dictate the visual structure. The renderer (Lit, React, Vue, server-rendered HTML, or any combination) operates inside or alongside the shell; the shell just provides the architectural scaffolding.
The Public Shape
Section titled “The Public Shape”The factory function’s API:
interface ShellOptions { name: string modules?: KitModule[] rootContext?: Record<string, unknown> onStart?: (shell: Shell) => Promise<void> | void onStop?: (shell: Shell) => Promise<void> | void onError?: (error: Error, context: { phase: string }) => void}
interface Shell { runtime: Runtime mount(root: Element): void start(): Promise<void> stop(): Promise<void> install(module: KitModule): Promise<void> uninstall(name: string): Promise<void>}
function createShell(options: ShellOptions): ShellThe factory takes a name, an optional list of modules to install at startup, an optional root-context object (which becomes the root boundary’s context), and lifecycle hooks. It returns a Shell object with methods for mounting, starting, stopping, and adding modules at runtime.
A typical use:
const shell = createShell({ name: 'settings-app', modules: [ debugModule(), notificationModule(), analyticsModule({ providerKey: 'abc123' }), auditModule(), storageModule(), userModule() ], rootContext: { appName: 'settings', appVersion: '1.0.0' }, onStart: async (shell) => { console.log('Application started') }, onError: (error, ctx) => { console.error(`Application error during ${ctx.phase}:`, error) }})
shell.mount(document.getElementById('app')!)await shell.start()After start() resolves, the application is running. Modules are installed. The root boundary is attached. The runtime is routing events and commands. The DOM under the mount point is participating in the architecture.
Implementing createShell()
Section titled “Implementing createShell()”The implementation, piece by piece.
function createShell(options: ShellOptions): Shell { const runtime = createRuntime() // from Chapter 39
let mountedRoot: Element | null = null let started = false let stopped = false
function onError(error: Error, phase: string) { options.onError?.(error, { phase }) runtime.emit({ type: 'shell.error', payload: { phase, error: serializeError(error) } }) }
function mount(root: Element) { if (mountedRoot) { throw new Error('Shell is already mounted') } mountedRoot = root
// Attach the root boundary to the mount point runtime.emit({ type: 'shell.mounted', context: { rootContext: options.rootContext }, payload: { mountPoint: root.tagName } })
// Tag the root element so boundary collection knows where to stop root.setAttribute('data-kit-shell', options.name) if (options.rootContext) { for (const [key, value] of Object.entries(options.rootContext)) { root.setAttribute(`data-meta-${key}`, String(value)) } } }
async function start() { if (started) { throw new Error('Shell is already started') } if (stopped) { throw new Error('Shell has been stopped and cannot be restarted') } started = true
runtime.emit({ type: 'shell.starting' })
// Install modules in order for (const module of options.modules ?? []) { try { await runtime.install(module) } catch (error) { onError(error as Error, `module-install:${module.name}`) // Continue installing remaining modules } }
// Run the application's onStart hook if (options.onStart) { try { await options.onStart(shell) } catch (error) { onError(error as Error, 'on-start') } }
runtime.emit({ type: 'shell.started' }) }
async function stop() { if (!started || stopped) return stopped = true
runtime.emit({ type: 'shell.stopping' })
// Run the application's onStop hook if (options.onStop) { try { await options.onStop(shell) } catch (error) { onError(error as Error, 'on-stop') } }
// Uninstall modules in reverse order const installedModules = [...(options.modules ?? [])].reverse() for (const module of installedModules) { try { await runtime.uninstall(module.name) } catch (error) { onError(error as Error, `module-uninstall:${module.name}`) } }
// Remove root attributes if (mountedRoot) { mountedRoot.removeAttribute('data-kit-shell') if (options.rootContext) { for (const key of Object.keys(options.rootContext)) { mountedRoot.removeAttribute(`data-meta-${key}`) } } }
runtime.emit({ type: 'shell.stopped' }) }
const shell: Shell = { runtime, mount, start, stop, install: (module) => runtime.install(module), uninstall: (name) => runtime.uninstall(name) }
return shell}The implementation is straightforward. The shell threads a runtime through the application’s lifecycle. Errors at any phase are reported via the onError callback and re-emitted as shell.error events on the runtime, so module observers can also react.
The <kit-shell> Custom Element
Section titled “The <kit-shell> Custom Element”For declarative use, the shell can also be authored as a custom element:
class KitShell extends HTMLElement { static observedAttributes = ['name']
shell?: Shell
connectedCallback() { const name = this.getAttribute('name') ?? 'app'
this.shell = createShell({ name, modules: this.discoverModules(), rootContext: this.discoverRootContext() })
this.shell.mount(this) this.shell.start().catch((error) => { console.error('Shell failed to start:', error) }) }
disconnectedCallback() { this.shell?.stop() }
private discoverModules(): KitModule[] { // Modules can be registered via global registration or // via attributes that name installed modules. Implementation // details depend on how the application wants to wire things up. return [] }
private discoverRootContext(): Record<string, unknown> { const context: Record<string, unknown> = {} for (const attr of this.attributes) { if (attr.name.startsWith('context-')) { context[attr.name.slice('context-'.length)] = attr.value } } return context }}
customElements.define('kit-shell', KitShell)A page using the custom-element shell:
<kit-shell name="settings-app" context-app-name="settings" context-app-version="1.0.0"> <kit-boundary surface="settings-page" feature="preferences"> <kit-button meta-event="profile.save_requested">Save</kit-button> </kit-boundary></kit-shell>The shell’s connectedCallback constructs the runtime, installs modules, and starts the application. The <kit-shell> element is the visible attachment point. Inside it, the application’s markup is regular HTML with the metadata protocol applied.
The two APIs (programmatic and declarative) coexist. A programmatic shell is right when the application owns its bootstrap process (the typical case for an SPA-style application). A declarative <kit-shell> is right when the shell should appear in server-rendered HTML or AI-generated markup, where the page itself declares its own architecture.
Mounting Is Attachment, Not Rendering
Section titled “Mounting Is Attachment, Not Rendering”A key architectural distinction worth landing carefully.
When the shell mounts to a DOM element, it doesn’t render new content. The element’s existing content stays exactly as it was. The shell adds attributes to the root, installs a delegated listener for the metadata boundary (Chapter 42), and announces its presence through the runtime. That’s all.
This matters for progressive enhancement. The page might already contain valid HTML — server-rendered, statically generated, or AI-produced — with the metadata protocol applied. Mounting a shell enhances that page with the architectural coordination layer. Without the shell, the page still works as plain HTML (forms submit, links navigate, native controls function). With the shell, the metadata protocol becomes active and the application’s capability modules start observing events.
A concrete example. Suppose the page is server-rendered:
<body> <kit-shell name="settings"> <main data-surface="settings-page" data-feature="preferences"> <form action="/profile" method="post" data-meta-event="profile.save_requested"> <label>Display name <input name="displayName" required></label> <button type="submit">Save</button> </form> </main> </kit-shell></body>Without the shell mounting (or before it does), the page is fully functional. The form submits normally to /profile. The server handles the request and returns a response.
After the shell mounts, the shell’s metadata-boundary listener observes the form’s submit event. The listener walks up the DOM to collect context. It enriches the event with surface: 'settings-page', feature: 'preferences'. It dispatches the event into the runtime. Modules subscribed to profile.save_requested respond — analytics tracks it, audit records it, observability adds a breadcrumb. Then the form’s default submit still happens (because the metadata listener doesn’t call preventDefault), and the server still handles the request.
The shell didn’t replace anything. It added an observation layer. The page works without the shell; it works better with the shell. This is what enhancement means in practice.
Module Lifecycle
Section titled “Module Lifecycle”The runtime’s module installation (Chapter 39) supports onInstall and onUninstall lifecycle hooks. The shell threads these through its own lifecycle.
A module that needs to do work at startup uses onInstall:
const userModule = defineKitModule({ name: 'user', providers: [ { token: USER, value: createUserProvider() } ], onInstall: async ({ runtime, inject }) => { // Hydrate the user from storage or the network const storage = inject(STORAGE) const cached = await storage?.get('user') if (cached) { userState.set(cached) } else { const fresh = await fetch('/api/me').then(r => r.json()) userState.set(fresh) } runtime.emit({ type: 'user.hydrated', payload: userState.get() }) }})The hook runs as part of runtime.install(module). The shell’s start() calls runtime.install for each module in order, awaiting each one. The application’s onStart hook runs after all modules are installed and ready.
A module that needs to clean up resources uses onUninstall:
const websocketModule = defineKitModule({ name: 'websocket', providers: [/* ... */], onInstall: async () => { socket = new WebSocket('wss://example.com') // ... }, onUninstall: async () => { socket?.close() }})When the shell stops, modules are uninstalled in reverse order of installation. Each module’s onUninstall runs. Connections close, timers cancel, subscriptions clean up. The shell’s onStop hook runs after all modules are uninstalled.
The lifecycle discipline is what keeps the application clean. A shell that’s been started, used, and stopped should leave no listeners attached, no timers running, no WebSocket connections open. Modules declare what they need to clean up; the shell coordinates the cleanup; the application doesn’t accumulate invisible global state.
Multiple Shells in One Document
Section titled “Multiple Shells in One Document”A specific architectural property worth surfacing.
The shell isn’t a singleton. A single page can host multiple shells, each with its own runtime, its own module set, its own diagnostic stream. The shells are independent — events fired in one shell don’t reach the other; modules in one shell can’t talk directly to modules in the other.
This is useful in several scenarios. A page might host an embedded application alongside the main one — a comments widget, a chat panel, an analytics dashboard — each as its own shell. A development environment might host a runtime under test alongside a test harness runtime, with the harness observing the test runtime’s diagnostic stream. A micro-frontend architecture might compose several independent applications, each as its own shell, on a single page.
The shells communicate, when needed, through the platform — BroadcastChannel for in-page messaging, localStorage events for cross-shell state synchronization, custom DOM events for direct DOM-level communication. The architecture doesn’t try to be a global bus; it stays scoped, and applications cross shell boundaries through platform mechanisms when they need to.
Bridge to Boundaries
Section titled “Bridge to Boundaries”The shell establishes the root boundary. The next chapter (Chapter 41) builds the full boundary system — declarative <kit-boundary> elements, programmatic runtime.attachBoundary calls, the context inheritance walk, nested boundaries, boundary handles for dynamic context updates.
The metadata boundary (Chapter 42) sits on top of the boundary system. It’s the piece that observes DOM events inside the shell’s root and dispatches them into the runtime with the boundary context attached. After Chapter 42, the closed loop from Chapter 38 is complete in code.
Exercise: Build createShell() and <kit-shell>
Section titled “Exercise: Build createShell() and <kit-shell>”Implement createShell() and the <kit-shell> custom element from the patterns in this chapter, building on the runtime from Chapter 39.
Then build a small application:
- A shell named
settings-appthat installs three modules: debug (logs all events), notifications (handlesnotification.show), and awelcomemodule that emits awelcomeevent on install. - A static HTML page with a button that dispatches
notification.showand a button that emits a custom event. - The shell mounted on a
<main>element in the page.
Wire up:
- The shell starts on page load.
- The welcome event fires automatically when the shell starts.
- Clicking the buttons produces console output via the debug module and visible toasts via the notification module.
- A second instance of the shell (with a different name) mounted on a separate element in the same page. Verify that events in one don’t leak to the other.
Then experiment:
- What happens if
start()is called twice? (Should error.) - What happens if you call
stop()and then click a button? (Buttons should still be in the DOM but the metadata listener — when we add it in Chapter 42 — would no longer be active, so events wouldn’t reach modules.) - What happens if a module’s
onInstallthrows? (The shell should report the error viaonErrorbut continue installing the remaining modules.) - Add an
onStophook that logs the diagnostic surface’s total entry count. Afterstop(), how many events and commands did the shell process? - Try the
<kit-shell>element withcontext-*attributes and inspect the root boundary’s context.
Reflect on:
- What does the shell own?
- What does the shell not own?
- How could the same shell host a Lit application, a React application, or server-rendered HTML?
- Where does the application’s renderer fit?
The exercise is the second piece of the Part IV implementation. The runtime from Chapter 39 plus the shell from this chapter are enough to install modules, emit events, dispatch commands, and run the application’s lifecycle. The next chapter adds boundaries; the chapter after that adds the metadata observation that turns DOM events into runtime events. After Chapter 42, the application is wired end-to-end.