smrt-planner
@happyvertical/smrt-planner lives in the
smrt monorepo at packages/planner
and releases in lockstep with every other @happyvertical/smrt-* package.
(It used to live in happyvertical/smrt-planner, on its own 0.1.x line; that
repository is archived.)
A static demo of s-m-r-t. Add recipes (Customers, Sales, Purchases, ...) or whole packages and an app mock-up assembles around them: a shell with navigation, a list view and create/edit form per model with sample data, and a plain-language Help page per recipe that follows the app's options, with a collapsed "Connect other tools" section listing the REST routes, MCP tools and CLI commands s-m-r-t generates from each model's decorator.
It is a purely static site (SvelteKit + adapter-static). Nothing is
installed or stored on a server; sample data lives in memory and resets on
reload. The layout comes from the canonical starter,
smrt-start, minus everything
server-side.
Run it
From the root of an smrt checkout (Node.js 26+, pnpm from the root
packageManager). The planner's catalog is generated from the workspace's
built manifests, so build the workspace once first:
pnpm install
pnpm turbo run build --filter=@happyvertical/smrt-planner...
pnpm --filter @happyvertical/smrt-planner dev # http://localhost:5173
The build writes the static site to ./build (servable from any static host,
or pnpm --filter @happyvertical/smrt-planner preview) and copies it into
./app for the package.
Hosting under a sub-path (for example GitHub Pages)? Build with
BASE_PATH=/smrt-planner pnpm build.
Use it
- On the Planner page, add recipes: small units like Customers, Vendors, Sales (needs Customers) and Purchases (needs Vendors). Adding one also adds what it requires. The whole package catalog is still there under "All packages".
- A recipe opens an options form built from its models' own parameters:
switch fields on or off (required ones stay on), set label, help text,
default value and order, and narrow REST, MCP and CLI exposure. Saving writes
field policies in the
@happyvertical/smrt-fieldsshape, held in memory for now. - The left navigation grows a group per recipe with its entries (Customers, Sales Orders, ...). Open one to browse sample rows and create, edit or delete them. The generated views and each recipe's Help page (overview, tasks and a field glossary) follow the options: hide a field and its glossary entry and any task step about it disappear. Money fields are stored as integer minor units (cents).
- Recipes and options live in the URL (
?r=commerce.sales,...&o=...); copy it to share the mock-up.
Short on time? The Cookbooks tab (where a first visit lands) offers ready-made starting points for a bakery, a mechanic, a welder and a yoga studio: pick one, read what it adds (recipes, menu, default payment terms, currency and tax), and use it to replace your recipes and menu. Your records stay.
The Assistant runs a small model on your device. The chat icon at the end of
the header opens it once a model is ready; before that it opens the AI models
page (/ai/), so a first click sets inference up instead of showing an empty
chat. Three icons in the left sidebar footer show Think (the language
model), Hear (voice typing) and Speak (read replies aloud); each opens
the same page, where you choose and download them. A first visit with nothing built starts there; "I don't
need AI, let's just build" skips it for good. Live s-m-r-t objects are tracked in #4.
Generated record forms use the Planner's own responsive, open form layout. Generated record lists let you sort any data column by selecting its heading; select it again to reverse the order. Sorting changes only the current view. The left sidebar starts open, with inference controls above the user menu in its footer. The Theme tab offers palette previews, large appearance choices, a brand colour wheel and font selection. Advanced Styles opens a dedicated CSS editor with syntax highlighting. CSS applies to the Planner as it changes and travels with the cookbook when saved, exported or imported.
Install the package
The primary package registry is npm.happyvertical.com. Configure the scope
once, then install the planner:
pnpm config set @happyvertical:registry https://npm.happyvertical.com/
pnpm add @happyvertical/smrt-planner
The same version is mirrored to npmjs on a best-effort basis. Install the
planner at the same version as the rest of your smrt packages: they release
together as one line. Planner mounts
the cookbook-driven app shell and its customization controls. A host can drive
it with the typed command set:
<script lang="ts">
import { Planner, createPlannerController } from '@happyvertical/smrt-planner';
const controller = createPlannerController();
</script>
<Planner {controller} basePath="/planner" />
Planner props, all optional: controller, basePath (where its pages are
served from), persistence ('self' reads and saves the cookbook in this
browser, 'host' leaves both to you), themeControls, defaultTitle (the shell title when no cookbook name or
library cookbook applies; defaults to 'Planner'), tabs, onready, and:
The mounted planner is the framework's cookbook-driven app shell with its
customizable overviews switched on: the pencil at the start of the header
edits the menu and every section page in place (add, move, resize, configure
and remove widgets), and each edit lands in the cookbook's layout and
overviews, so it is saved, exported and imported with the rest of the plan.
The metric, records and chart widgets read the in-browser sample data;
the chart groups the seeded rows itself (by a field, or a date field bucketed
over the widget's period) as the stand-in for the runtime report a generated
app's chart loader would compile. Its pages are fragment routes (#/s/...,
#/m/..., #/packages/...), so the mount needs nothing from the host's
router and every in-planner link stays inside it.
backLink: { href, label }renders a link out of the planner at the start of its header, beside the brand (a docs site's "Back to documentation").inferencegives the planner's assistant a model and switches the assistant on: the chat icon at the end of the header opens the chat dock once a model is ready, and the AI models page (#/ai/) before, where a visitor downloads a browser model or connects their own endpoint. Pass the same object asplanner.config.json'sinference({ mode, alternatives?, credentialPersistence?, host?, byo? }, seedocs/inference-host.md) or{ chat }with a model of your own. For a public site where visitors bring their own endpoint:inference={{ mode: 'byo', alternatives: ['browser'], credentialPersistence: 'memory' }}. Withoutinferencethe planner has no assistant and no chat icon.
<Planner
basePath="/planner"
backLink={{ href: '/', label: 'Back to documentation' }}
inference={{ mode: 'byo', credentialPersistence: 'memory' }}
/>
Hosts that already own an AppShell can mount PlannerControls inside it.
The controls are the reusable part; Planner remains the complete shell.
Use the exported shell defaults to start navigation open, with inference
controls above the user menu in its footer. The bottom panel starts hidden.
<script lang="ts">
import { AppShell } from '@happyvertical/smrt-svelte/app';
import {
PLANNER_SHELL_EDGE_TOGGLES,
PLANNER_SHELL_PANEL_DEFAULTS,
PlannerControls,
} from '@happyvertical/smrt-planner';
</script>
<AppShell
edgeToggles={PLANNER_SHELL_EDGE_TOGGLES}
config={PLANNER_SHELL_PANEL_DEFAULTS}
layoutEditing
>
<PlannerControls layout="shell" persistence="host" />
</AppShell>
import { commandTools, createPlannerController } from '@happyvertical/smrt-planner/commands';
const controller = createPlannerController();
const result = controller.run({
name: 'add_recipes',
input: { ids: ['commerce.sales'] },
});
// { ok: true, snapshot, receipt } | { ok: false, error: { code, message } }
controller.subscribe((snapshot) => render(snapshot));
commandTools is every command as { name, description, inputSchema }: pass it
straight to an LLM or an MCP server as tool definitions. controller.snapshot()
is a compact read-only plan (a few KB) fit for a model prompt. Commands:
add_recipes, remove_recipes, add_features, remove_features,
add_cookbook, remove_cookbook, apply_cookbook, import_cookbook,
set_settings, set_policy, set_theme, reset_theme, rename_section,
rename_item, hide, show, focus, export_cookbook, undo.
@happyvertical/smrt-planner and @happyvertical/smrt-planner/commands are Svelte source (runes) and JSON,
so consume them through Vite or another Svelte-compiling bundler. A plain Node
server uses @happyvertical/smrt-planner/core instead (command schemas, the assistant prompt,
reply validation, the library catalog) and can serve the prebuilt app from
@happyvertical/smrt-planner/app; see docs/inference-host.md.
How the catalog is generated
src/lib/catalog/catalog.json is the only thing the app reads. It is
generated by pnpm build and never committed, from the same workspace the
release publishes, so a released planner always describes exactly the packages
released beside it:
pnpm --filter @happyvertical/smrt-planner catalog:generate
The script (scripts/generate-catalog.ts):
- Reads every public
@happyvertical/smrt-*package in the workspace that exports./manifest.json, from its built manifest and thesmrt-knowledge.jsonbeside it. Each of those packages is a devDependency of the planner, so Turbo builds them first; a declared but unbuilt manifest fails the build rather than silently shrinking the catalog. Packages without a manifest export (cli, svelte, ui, ...) or without models are skipped. - Drops the infrastructure packages in
src/lib/catalog/generate/exclusions.ts(core, tenancy, jobs, prompts, ...); that list is the only hand-maintained part, and each entry says why. A listed package that declares recipes stays (smrt-chatbrings the assistant,smrt-fieldsform customization): a recipe is a feature. - Writes, per package: description, models, each model's fields and types, the
generated REST routes, MCP tools and CLI commands (from the knowledge
artifact's
surfaces), public methods and which are AI-callable (have an MCP tool), the recipes it declares with their surfaces, providers (secret names only), runtime, demo seed and browser-demo mode (demo, plus the package'sbrowsercapability; smrt#3708/#3709), and dependencies (the manifest'ssmrtDependenciesplus models' foreign keys and cross-package references, limited to catalog packages).
Output is sorted and has no timestamps or checkout paths, so a build of the
same tree is byte-identical anywhere. CATALOG_SOURCE=registry reads the
published packages from the registry instead (SMRT_PLANNER_REGISTRY
overrides it), and CATALOG_SOURCE=<path> another smrt checkout, to compare.
Because the library cookbooks are validated against this catalog in the planner's tests, a framework change that breaks a cookbook (a field moved out of a model, a recipe renamed) fails the pull request that makes it.
Scripts
Run with pnpm --filter @happyvertical/smrt-planner <script>.
| Script | Does |
|---|---|
dev | Vite dev server |
build | Catalog and CLI version from the workspace, the static app into build/ and app/, the MCP widget app/mcp.html, then svelte-package into dist/ |
verify:pack | publint and the monorepo's packed-exports check |
catalog:generate | Regenerate the catalog alone |
typecheck | svelte-kit sync, tsc, svelte-check |
test | Vitest, including every library cookbook validated against the workspace catalog |
test:mcp-browser | Rebuild app/mcp.html and drive it in Chromium |
test:live-preview-browser | Drive the live preview against the dev server in Chromium |
test:package -- <tarball> | Install and exercise an already packed tarball as an isolated consumer |
The Node/DOM tests use patched Happy DOM 21. The security regression exercises valid modules and rejects an invalid import binding that previously executed a Node-host initializer (GHSA-834v-8h66-3h79).
Lint and formatting are the monorepo's root pnpm lint. The package is
released by the monorepo's batched release with every other smrt package (see
the root CHANGESETS.md); it is never published on its own.
License
MIT © Happy Vertical Corporation
Native host context and Export actions
A mounted complete Planner can retain an earlier conversation and add its
host's first Export action while keeping the native cookbook download:
<Planner
inference={{ mode: 'host', host: { endpoint: '/api/planner/chat' } }}
initialConversation={{
id: 'stable-handoff-id',
messages: [{ role: 'user', content: 'I run a repair workshop.' }],
}}
cookbookOffers={false}
exportAction={{
label: 'Continue with this app',
run: async ({ export: file, revision, conversation }) => {
// file is the existing ExportData: fileName, exact text, full cookbook.
// Store/submit the detached context according to your host's policy.
await savePlan({ file, revision, conversation });
},
}}
onready={({ nativeAssistant }) => {
// The same transport the visible native dock uses, not `assistant`.
nativeAssistant?.subscribeConversation((messages) => remember(messages));
}}
/>
onready.openAssistant() opens and focuses the ready native dock using the
shell API and returns false if the dock/model is unavailable. It waits a Svelte
tick so shell registration can settle; seeding alone never opens the dock.
onready.nativeAssistant exposes conversation() (a detached full current
thread), subscribeConversation(listener) (immediate snapshot; returns an
unsubscribe), busy, seed({ id, messages }), and the existing chat transport
methods. The legacy onready.assistant remains a separate host-driven assistant;
it is not a way to read the visible native dock's messages.
Seed accepts only plain user/assistant text. It appends the complete input in
one notification, never executes it, and returns false for a repeated id,
malformed input, an in-flight turn or a thread that has already received native
sends. Supply initialConversation before opening the native dock; seeding is
not a way to replace an active conversation. The host retains the original
transcript separately when original message identities/provenance matter.
exportLabel="Build" labels the existing native Export shortcut and shows that
text beside its icon. Its accessible name, tooltip and shell slot/menu title use
the same label. Omitted or blank values retain the icon-only "Export the app"
default. The action still opens the native Export page; it does not create a
second action or change downloads.
headerNav="labelled" replaces the header's chef's-hat and export icons with four
bordered, coloured buttons with visible text: Specs (#/configure/specs/),
Features (#/configure/recipes/, target planner-customize), Styles
(#/configure/theme/) and Build (#/configure/export/, target planner-export,
labelled by exportLabel when set). The open button is aria-current, and the
configure tab strip shows only its group (Specs; Cookbooks, Recipes, Features;
Theme, Settings; Export). Their accents are the theme's info (Specs), success (Features), tertiary (Styles)
and warning (Build) colours; a host recolours one by setting
--smrt-header-icon-link-accent on [data-tone='specs'] and so on. The Specs tab is
a free-text box saved with the cookbook (specs). The default, "icons", is
unchanged.
buildAgentPrompt(cookbook, options?) is available from the package root and
@happyvertical/smrt-planner/core. It is the same pure builder used by the native
Export page and returns AgentPrompt (text, UTF-8 bytes, appName, version,
delimiter). AgentPromptOptions accepts fallbackName and version. A host
callback returning prompt text should use buildAgentPrompt(data.cookbook).text.
The core entry works in plain Node without a Svelte compiler.
headerAction={{ label, run }} adds a primary button to the complete Planner header.
It uses the same capture and busy/error safeguards as exportAction; hosts may
pass the same callback to both entry points. The Export shortcut and native
download remain available. PlannerControls does not render a header action.
PlannerExportContext is { export: ExportData, revision, conversation }.
The Export action captures document and thread synchronously and rejects an
in-flight conversation rather than submitting a partial turn. Host callback
failures are displayed inline; download remains available. Conversation export
has no inference history truncation, including native tool data and error
messages. The inference wire still sends only bounded recent turns. No server
persistence, iframe bridge, automatic app generation, or custom model/recipe
creation is implied. Sample records remain outside the cookbook.
cookbookOffers={false} removes library cookbook proposals from the native
assistant's vocabulary/schema; existing recipe/feature composition remains
available. This defaults to true and does not remove the Cookbooks tab.
PlannerControls also accepts exportAction; without a complete Planner its
export conversation is empty because it owns no native dock.
External assistant on the same live app
An external host assistant can drive onready.controller through a dedicated
MessagePort without using, seeding, or writing the native assistant thread.
The host must first authenticate its window handshake by exact origin and
expected event.source, then transfer a fresh MessageChannel port. The
library intentionally does not install a broad window message listener.
// Inside the mounted Planner host, after a trusted port transfer:
const binding = bindPlannerCommandPort({
port,
controller: handle.controller,
allowedCommands: ['add_cookbook', 'add_recipes', 'add_features'],
source: 'external-assistant',
});
// Call binding.invalidate() in the workspace's synchronous onreset callback.
// Call binding.dispose() before replacing/unmounting the mounted Planner.
// On the external assistant side, using the other dedicated port:
const client = connectPlannerCommandPort(otherPort);
const before = await client.snapshot();
// Prepare a hosted model reply from before.snapshot and this assistant's own
// conversation. context() additionally exposes the canonical catalog/tools.
const result = await client.applyReply({
id: 'stable-request-id',
generation: before.generation,
expectedRevision: before.snapshot.revision,
reply: hostedReply,
});
// Speak/display result.content: it comes from actual command receipts/coaching.
snapshot() and subscribe() expose { generation, snapshot } from the actual
mounted controller, including manual edits. context() adds the package's
published catalog and tools. execute() accepts a typed command array;
applyReply() accepts only { reply, commands?, sampleContext? }. The latter
validates fictional sample context before executing an atomic canonical batch,
then installs it only after successful changed commands and before the next
render. It reuses native command receipts and coaching, with no assistant thread.
Every mutation requires the observed generation and revision. The binding assigns source attribution, namespaces command ids, deduplicates the whole request, rejects id reuse with changed input, and refuses stale/reset requests. Allowed commands are explicit (an empty list denies all mutations). Envelopes are limited to 32 KiB, 16 commands and 128-character request ids. Each generation retains at most 256 receipts; at capacity new requests are rejected, never silently evicted/reexecuted. Reconnect/invalidate deliberately to renew the capability. The client bounds pending calls to 32 and times out unanswered calls after ten seconds; disposal rejects pending calls and closes the port.
invalidate() changes only the connection generation and receipt cache; it
never resets the app itself. The canonical workspace reset increments the
controller revision and invokes host onreset synchronously, so a host binding
invalidated there cannot accept a delayed pre-reset reply. Do not automatically
retry a conflict with a newer revision: inspect the current plan first. A host
using an older release must feature-detect these exports and report unavailable
Planner control honestly while its external conversation continues.
When persistence="host", Planner controls do not offer browser Remember or
legacy recovery by default: the host owns saving the current cookbook. An owner
that actually persists that cookbook may supply projectPersistence, the typed
canonical facade capability (remembering, recoveryAvailable, setRemember,
recover, discardRecovery). Self persistence uses the existing facade by
default. Reset remains available independently. The static app's layout supplies
its actual canonical storage capability; a live Kitchen-hosted plan supplies none.
Without inference, native assistant handles and action captures omit native
conversation data. Confirmed Reset still retires any private previous native
session so enabling inference for the next project cannot revive its thread.