Scaffolding
LoomWeaver ships its own generators. They exist because the platform is easy to use and fiddly to wire up. Before a weaver does anything at all, it needs a manifest, a surface, an i18n bundle and the right capability declaration. Getting one of those wrong fails quietly. The generators emit all of it consistently. They also derive the capabilities from the features you ask for. A scaffolded plugin is therefore correct by construction rather than by review.
First: the tool is not a dependency
Two different things carry a @loom name, and only one of them belongs in your application:
| Package | Installed where | Purpose | |
|---|---|---|---|
| Runtime | @loom/shell, @loom/plugin-sdk, @loom/sandbox-kit |
your app’s dependencies |
your app boots the shell with them |
| Tool | @loom/cli, @loom/mcp |
nowhere in your app — run on demand, or registered with your assistant | generate source files |
| Tool (Nx) | @loom/devkit |
your workspace’s devDependencies |
adds nx g generators |
Only the Nx collection is installed, and only as a dev dependency, because Nx loads generators from
node_modules. The other two run as separate processes and know nothing about your codebase.
Which tool depends on how you want to drive it:
| You want… | Use | |
|---|---|---|
| an Nx workspace to generate the way it generates everything else | @loom/devkit |
writes into the workspace and registers the project |
| a command you can run, script and put in CI | @loom/cli |
deterministic, no workspace of any kind |
| to ask in prose and let an assistant fill in the options | @loom/mcp |
your assistant writes the files |
All three read the same scaffold descriptors and call the same generator core, so a weaver scaffolded any of the three ways has byte-identical source. What differs is what each one is allowed to do with the result — see who writes the files.
The CLI — @loom/cli
Availability: the
@loom/*packages are not on the public npm registry yet; the first public release publishes them. Until then they come from LoomWeaver’s own package feed, andnpxneeds that registry configured for the@loomscope.
It needs nothing installed and no workspace of any kind — the generators are bundled in:
npx @loom/cli weaver --id notes --command --shortcut 'mod+shift+n' --out src/lib/notesWrote 8 file(s) into /home/you/acme-studio/src/lib/notes: README.md src/index.ts src/lib/i18n/de.json …loom list prints every scaffold with its options. loom --help prints everything else. The
version matches the platform packages, so the output always fits the @loom/shell you build
against.
Three scaffolds carry options worth knowing before you read that list. The weaver composes its features from flags. The other two decide how your product is styled, and they work together:
| Flag | What it changes |
|---|---|
distribution --styles precompiled |
emits a one-line src/styles.css that imports the stylesheet we compiled, so the application needs no Tailwind — no packages, no .postcssrc.json, no @source hops. The default, tailwind, compiles the shell’s source theme and is what lets you write Tailwind utilities of your own |
theme --preset bootstrap |
maps all 29 --lw-* tokens onto Bootstrap 5.3’s --bs-* variables instead of emitting literal colours, so the shell follows your Bootstrap theme live |
Together they are the whole Bootstrap path. Scaffold the distribution with --styles precompiled.
Scaffold a theme with --preset bootstrap. Import Bootstrap into a cascade layer, and import
the theme after ours. That last part is not housekeeping. See
bringing your own CSS framework for what
unlayered CSS does to the chrome.
| Option | Effect |
|---|---|
--out <dir> |
where to write; defaults to the current directory |
--dry-run |
list the files it would write — naming any that already exist — and write nothing |
--force |
overwrite files that already exist — without it, an existing file stops the run and is named |
--strict |
make validation warnings fail the exit code, for CI |
The three validators work the same way, which is what makes them useful in a pipeline:
npx @loom/cli validate-manifest --id notes --capabilities ui,contributionsnpx @loom/cli validate-i18n --dir src/lib/notes/src/lib/i18n --strictnpx @loom/cli validate-catalog --file public/plugins/catalog.json --strictA missing translation key is a warning: it reports and exits 0, so it will not break an unrelated
build. --strict turns warnings into a non-zero exit when you do want to gate on parity.
validate-catalog earns its place for one reason: the shell parses a
plugin store catalog defensively —
it tolerates bad input instead of failing on it. A field it does not recognise is skipped. A
malformed field is dropped. An entry missing id or entryUrl disappears entirely. All of this
happens without a word, because a store that throws on one bad entry serves nobody. That is the
right runtime behaviour, and a terrible authoring experience. So every finding names the
consequence rather than the rule:
error: catalog[0].capabilities contains "uii", which the host filters out silently — the plugin then throws CapabilityError at runtime. Known: contributions, ui, host, navigation, session, theme.warning: catalog[0].discription is not one of the fields the host reads (…), so it is ignored without a word — which is exactly what a misspelled field looks like.warning: catalog[1] carries no version. Update detection compares catalog versions, so the store can never offer an update and republishing the plugin will not respawn it for anyone who already installed it.The one thing it cannot judge from outside a browser is whether an absolute URL is same-origin. It does not know the origin you will serve from. So it reports absolute URLs as warnings. It leaves root-relative paths alone, because those are same-origin by construction.
The MCP server — @loom/mcp
Same generators, driven by conversation rather than by flags: you describe what you want and your assistant picks the options. Where the CLI writes the files itself, here your assistant does — see how a file actually gets created below.
@loom/mcp is a self-contained Model Context Protocol server —
the generators and validators are bundled in, so there is no transitive install and no LoomWeaver
checkout. Register it in your repository’s .mcp.json and your AI assistant gains the tools:
{ "mcpServers": { "loomweaver": { "command": "npx", "args": ["-y", "@loom/mcp"] } }}The server speaks stdio and reports its version on connect, so the tools always match the platform version you are building against.
How a file actually gets created
scaffold_* tools do not write files. They return a file map — relative path to content — and
your assistant writes it:
{ "files": { "src/index.ts": "…", "src/lib/plugin/notes.plugin.ts": "…" } }End to end, asking for a plugin looks like this:
- You ask your assistant for a weaver — say a
notesplugin with a command onmod+shift+n. - It calls
scaffold_weaver { "id": "notes", "command": true, "shortcut": "mod+shift+n" }. - The server generates in memory and answers with the file map. Nothing has touched disk.
- Your assistant picks the target directory and writes each file with its ordinary file-writing tool — so this is where your usual permission prompt or diff review appears.
- You do the wiring the generated README lists: grant the declared capabilities, compose the
translations, translate
de.json.
Three consequences worth knowing:
- You choose where the files land. The paths in the map are relative. The server has no idea whether you run a monorepo, where your library root is, or what your projects are called — so it states structure, not location, and your assistant resolves it against your layout.
- Nothing reaches disk except through your client. A server started via
npxis code you did not audit. Because it returns data instead of writing files, it stays inside the review path you already have. It never gets a write path of its own. - The same generator core serves every path. The core is a pure function
(recipe, input) → FileMap: it takes input and returns file contents, and it has no filesystem access at all. The CLI, the MCP server and the Nx generator are thin adapters over it. That is why they cannot drift apart. It is also why the core itself never writes files.
Which leaves each adapter doing exactly what its position allows:
| Adapter | Writes files? | Edits existing files? | Because |
|---|---|---|---|
@loom/devkit (Nx) |
yes | yes — registers the project, adds the tsconfig alias | Nx hands it a virtual tree of your workspace |
@loom/cli |
yes | no | it knows a target directory, not a workspace |
@loom/mcp |
no | no | it returns relative paths so your client stays in the review path |
validate_* tools return findings, not prose, so an assistant can act on them:
{ "findings": [ { "level": "error", "code": "manifest.id", "message": "Plugin id must be a kebab-case string; got \"Notes\".", "path": "manifest.id" } ]}The tools
| Tool | Arguments | Gives you |
|---|---|---|
list_generators |
— | the available generators and what they emit |
scaffold_weaver |
see below | a complete plugin: manifest, surface, rail item, i18n, test |
scaffold_sandbox_plugin |
id, name |
a framework-agnostic iframe plugin (Penpal + the sandbox UI kit) |
scaffold_distribution |
name, title, styles |
a runnable composition root that boots the shell |
scaffold_auth_source |
name |
an AuthSource implementation to feed the session |
scaffold_settings_store |
name |
a settings-store implementation backed by your API |
scaffold_theme |
name, preset |
a token-override stylesheet in @layer lw-tenant-theme |
scaffold_layout |
name |
a ShellLayout with the regions a weaver expects |
validate_manifest |
id, name, capabilities |
findings on a plugin manifest |
validate_catalog |
catalog — the parsed catalog JSON array |
findings on a plugin store catalog, including fields the host never reads |
validate_i18n |
bundles — the parsed language files keyed by language, e.g. { "en": { "notes.list": "Notes" }, "de": { "notes.list": "Notizen" } } |
findings on translation-bundle parity (keys missing in one language) |
The weaver generator
This is the one you will use most, and the only one with real options. Each feature you switch on
pulls in what it needs — ask for a command and the ui capability is declared for you; ask for an
About dialog and host comes with it.
The CLI spells these as flags (--bar-item) and MCP as arguments (barItem); they are the same
option, so the table gives both.
| CLI flag | MCP argument | Effect |
|---|---|---|
--id <id> required |
id |
plugin id in kebab-case, e.g. notes |
--name <name> |
name |
display name; defaults to a title-cased id |
--command |
command |
also register a command; its run raises a toast — a placeholder action on a real shortcut |
--shortcut <chord> |
shortcut |
keyboard chord for it, e.g. mod+shift+n (implies command) |
--menu <slot> |
menu |
hook a menu item into a slot, e.g. content/tab/context (implies command) |
--bar-item |
barItem |
a status-bar button that triggers the command (implies command) |
--settings |
settings |
a settings section with a toggle and a text field |
--about |
about |
an About dialog that reads ctx.host, plus its command |
--instanceable |
instanceable |
named saved instances with a switcher — this docks the surface instead of routing it (see below) |
--container |
container |
make the surface a container: a routable tab holding a nested pane tree |
--access <req> |
access |
auth-gate the surface and rail item: authenticated, anonymous, or a role requirement |
--no-spec |
spec: false |
skip the starter unit test, which is generated by default |
--instanceable and --container shape the surface itself and are therefore mutually exclusive; the
generator says so rather than emitting something that quietly does nothing.
Write shortcuts with the mod token rather than cmd or ctrl: the host binds and displays it
per platform (⌘ on macOS, Ctrl elsewhere).
Either way — loom weaver --id notes --command --shortcut 'mod+shift+n', or the MCP argument
{ "id": "notes", "command": true, "shortcut": "mod+shift+n" } — you get the same eight files:
src/index.tssrc/lib/plugin/notes.plugin.tssrc/lib/plugin/notes.plugin.spec.tssrc/lib/views/notes-view.tssrc/lib/views/notes-view.htmlsrc/lib/i18n/en.jsonsrc/lib/i18n/de.jsonREADME.mdNo test setup file: the recipes emit no project infrastructure, because a runner is the workspace’s
choice, not a plugin’s. The Nx generator wires @nx/angular:unit-test (Vitest) for you; elsewhere the
spec runs under whatever your application already uses.
The generated README lists the three steps that are easy to miss. First, grant the plugin’s
declared capabilities via provideCapabilityGrants. The broker is default-deny, so an ungranted
plugin activates into a CapabilityError rather than silently doing nothing. Second, compose its
translations with provideTranslationNamespaces. Third, translate de.json, which starts as a
copy of the English strings.
Three shapes of surface
The default surface is routable: it lives at /<id>, holds the URL pane, and is what a deep link
and the browser’s back button address. Two flags trade that for something else.
--instanceable docks the surface into the primary region and drops routable. That is not
a detail. Named instances exist only for a docked surface; a routable surface holds the URL pane
instead. The rail item then reveals the surface (ctx.revealSurface) rather than navigating to it.
Revealing also means the rail item finds the surface wherever the user has since dragged it.
--container goes the other way and makes the surface more routable: a container tab lives at
/<id>/:id and holds a nested pane tree of child surfaces.
The host draws the inner tabs, splits and drop targets; the weaver only declares which children it
offers:
ctx.registerSurface({ id: 'notes', title: 'notes.title', icon: 'notes', routable: { path: 'notes/:id' }, container: { children: ['notes.canvas', 'notes.details'], initial: ['notes.canvas', 'notes.details'], },});
ctx.registerSurface({ id: 'notes.canvas', title: 'notes.canvas', docks: [], component: NotesCanvasView,});children is what the inner “new tab” picker lists; the host access-gates that list. initial is
what a freshly opened container tab starts with. The children declare docks: []. That is the
container-only convention: it keeps them out of every sidebar and picker except this container’s.
A child reads the container’s :id from an injected ActivatedRoute. The host supplies a synthetic
one, so the child needs no knowledge of where it is mounted, and two open container tabs are two
independent trees:
private readonly route = inject(ActivatedRoute, { optional: true });protected readonly instanceId = this.route?.snapshot.paramMap.get('id') ?? '—';The inner tree is sealed: nothing can be dragged out of it and nothing into it. It travels with the tab — including into a sidebar or a pop-out window.
The Nx generators — @loom/devkit
If your workspace is an Nx workspace, this is the fullest of the three. It is the only adapter that can change files as well as write them, because Nx hands it a virtual tree of your workspace. It registers the project and adds the tsconfig path alias. The other two adapters can only describe those steps.
npm i -D @loom/devkit# what the generated source imports — see step 2 of the quickstart for the version pinnpm i @loom/shell @loom/plugin-sdk @angular/cdk @jsverse/transloco @ng-icons/heroicons# only for a distribution on the default --styles tailwind; `precompiled` needs none of thesenpm i -D tailwindcss @tailwindcss/postcss @tailwindcss/typography
nx g @loom/devkit:weaver --id notes --command --shortcut 'mod+shift+n'nx g @loom/devkit:distribution --name acme-studio --title 'Acme Studio' --styles precompilednx g @loom/devkit:sandbox-plugin --id chartsnx g @loom/devkit:auth-source --name acmenx g @loom/devkit:settings-store --name backendnx g @loom/devkit:theme --name midnight --preset bootstrapnx g @loom/devkit:layout --name baseAn Nx application usually exists before LoomWeaver does, since nx g @nx/angular:application is how
one comes into being. To compose into it, name it and pass --force:
nx g @loom/devkit:distribution --name acme-studio --directory apps/acme-studio --forceThat replaces the bootstrap files this scaffold owns, and it merges the scaffold’s build targets
into the project. The wiring the shell needs lands this way: the i18n and sandbox-kit assets, the
stylesheet, the service worker, and inlineCritical: false. What the application already declares
is not discarded — its own targets, its implicitDependencies and its tags survive. The generator
refuses to rename a project. If the occupant is called something else, pass that name instead,
because renaming would break every reference to it.
It reads your workspace rather than assuming ours:
| It needs to know | How it decides |
|---|---|
| where the project goes | --directory, defaulting to libs/<project name> |
| what to call the import alias | --import-path, defaulting to your root manifest’s npm scope |
| which application to drop into | --app; with one buildable application it is inferred, and with several it fails naming them rather than guessing. E2E projects are applications to Nx but build nothing, so they are never candidates — otherwise the usual <app> + <app>-e2e pair would defeat inference |
| Nx tags and the selector prefix | --tags and --prefix — the prefix is carried into the generated component selectors. Without --tags a project is generated untagged, because tag names only mean something inside your own depConstraints; see LoomWeaver and Nx |
| how deep the project sits | derived from the directory, not hard-coded |
| serving the weaver’s translations | an assets glob for /i18n/<id>/ is added to the composing application’s build |
| styling the weaver’s templates | a @source for the new library is appended to the composing application’s entry stylesheet, so its utilities are emitted. Tailwind 4 also detects sources by itself, and in a plain workspace that already reaches a sibling library — but that detection depends on where it resolves the project root and on .gitignore, and the scaffolded @source './' names the application alone. Left untouched when the application runs no Tailwind, as with --styles precompiled. The other two adapters write no foreign files, so there it is a step in the generated README.md |
The generated test target is @nx/angular:unit-test (Vitest), which is what Angular 21+ and Nx both
default to. A weaver library has no build of its own, so its specs compile with the build options of
the application that composes it — that is what --app resolves. If your workspace runs a different
runner, --unit-test-runner none emits no test wiring at all and leaves it to you.
Inside this repository the same collection is used through the workspace path alias, with the placement passed explicitly:
cd platformnx g @loom/devkit:weaver --id notes --directory libs/weavers/notes-weaver \ --import-path @loom/notes-weaver --app loom-testbedTo point an MCP client at a local build rather than the published package:
cd platform && nx bundle mcp{ "mcpServers": { "loomweaver": { "command": "node", "args": ["platform/libs/tooling/mcp/dist/main.mjs"] } }}Next: Authoring a weaver — what to do with the plugin once it exists.