Skip to content

Scaffolding

This is a guide, not the contract. What the platform guarantees is specified under openspec/specs/. For this page: scaffolding. Where this page and a specification disagree, the specification is right, and that is a defect in this page: change the behaviour there, then explain it here.

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 @loomweaver name, and only one of them belongs in your application:

PackageInstalled wherePurpose
Runtime@loomweaver/shell, @loomweaver/plugin-sdk, @loomweaver/frame-kityour app’s dependenciesyour app boots the shell with them
Tool@loomweaver/cli, @loomweaver/mcpnowhere in your app — run on demand, or registered with your assistantgenerate source files
Tool (Nx)@loomweaver/devkityour workspace’s devDependenciesadds 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. The CLI reads the workspace above the directory it writes into and wires what it finds there; the MCP server knows nothing about your codebase and names each step instead.

Which tool depends on how you want to drive it:

You want…Use
an Nx workspace to generate the way it generates everything else@loomweaver/devkitwrites into the workspace and registers the project
a command you can run, script and put in CI@loomweaver/clideterministic; wires the workspace it finds, needs none
to ask in prose and let an assistant fill in the options@loomweaver/mcpyour 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.

One command: init

Run in an Angular CLI application or in an Nx workspace, init takes it to a running product in one go. It detects which kind of workspace it is in, reads the package manager off the lockfile, installs the platform’s packages, scaffolds the distribution and a first weaver, and ends by naming the command that serves the result. It creates no application; one line of ng new before it is the prerequisite, and Getting started begins with exactly that.

Terminal window
npx @loomweaver/cli init
Terminal window
pnpm dlx @loomweaver/cli init
Terminal window
yarn dlx @loomweaver/cli init
Terminal window
bunx @loomweaver/cli init

It asks no questions. Every choice is an option with a default:

OptionDefaultEffect
--title <text>the package name in title casethe product name the top bar shows
--styles tailwind|precompiledtailwindthe style pipeline; precompiled installs no Tailwind and imports the stylesheet the shell ships
--weaver <id>notesthe first plugin, with a command on mod+shift+ and the id’s first letter, so the rail shows something on the first serve
--no-weaverno first plugin; the output says the rail stays empty until one is composed in
--app <name>inferred when the Nx workspace has one applicationwhich Nx application to take to a product; with several and none named, the candidates are listed and nothing is written
--package-manager npm|pnpm|yarn|bunfrom the lockfile, npm when there is nonehow packages are installed and how the serve command is spelled
--dry-runname every package, file and amendment, install and write nothing

In an Angular CLI application it runs distribution over the application with --force, because the files it replaces are the bootstrap wiring ng new produced, then weaver into src/<id>. In an Nx workspace it installs @loomweaver/devkit as a dev dependency and runs the Nx generators, because only they register the project and the import alias; the result is what the Nx generators produce. The serve command is npm start in the first case and npx nx serve <app> in the second, each spelled for the package manager found.

Run twice, it changes nothing and says so: a package the manifest already carries is not installed again, a composition root that already boots the shell is not rewritten, a weaver whose entry point exists is not generated again. A trial run walks the same steps and names what each would do. Its weaver step is planned against the composition root as it is at that moment, so before the distribution step has run it says the plugin would not yet compose. That is true until that step has happened.

Outside an Angular application or an Nx workspace it refuses, names what it expected to find, and says how to create one.

The CLI — @loomweaver/cli

It needs nothing installed: the generators are bundled in. It runs without a workspace too; where it finds one it also wires the build, and where it does not it names what is left:

Terminal window
npx @loomweaver/cli weaver --id notes --command --shortcut 'mod+shift+n' --out src/lib/notes
Wrote 8 file(s) into /home/you/acme-studio/src/lib/notes:
README.md
src/index.ts
src/lib/i18n/de.json
…
Wired 3 workspace file(s):
src/styles.css
+ @source 'lib/notes/src'
src/app/app.config.ts
+ notesPlugin, its translations and its capability grants
angular.json
+ assets: src/lib/notes/src/lib/i18n

It finds the workspace by walking up from --out to the nearest angular.json or nx.json, past a library’s own package.json on the way, so it wires the build as well as writing the files. --dry-run previews both and writes nothing.

loomweaver list prints every scaffold with its options. loomweaver --help prints everything else, init included. The CLI is published on the platform’s version line, and its output fits the @loomweaver/shell of the same version. Running npx @loomweaver/cli unpinned takes the latest one, which is right on the day you install the platform. A project on an older shell pins the tool to it, npx @loomweaver/cli@0.9.0, because a generator from a newer line emits code for that line.

Four scaffolds carry options worth knowing before you read that list. The weaver composes its features from flags. Two decide how your product is styled, and they work together; the fourth decides how much of a stand-in session you want:

FlagWhat it changes
distribution --styles precompiledemits 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 bootstrapmaps all 29 --lw-* tokens onto Bootstrap 5.3’s --bs-* variables instead of emitting literal colours, so the shell follows your Bootstrap theme live
auth-source --barewrites the AuthSource alone. Without it the scaffold also writes the plugin with the sign-in, switch and sign-out verbs, the bundles, and composes them into app.config.ts, so a product without an identity provider can operate its session from the rail on the first serve

Together they are the whole Bootstrap path, and the framework itself has to go into a cascade layer: Bringing your own CSS framework has the import order and the mapping.

OptionEffect
--out <dir>where to write; defaults to the current directory
--dry-runlist the files it would write — naming any that already exist — and write nothing
--forceoverwrite files that already exist — without it, an existing file stops the run and is named
--strictmake validation warnings fail the exit code, for CI

The four validators work the same way, which is what makes them useful in a pipeline:

Terminal window
npx @loomweaver/cli validate-manifest --id notes --capabilities ui,contributions
npx @loomweaver/cli validate-i18n --dir src/lib/notes/src/lib/i18n --strict
npx @loomweaver/cli validate-catalog --file public/plugins/catalog.json --strict
npx @loomweaver/cli validate-commands --dir src/lib/notes --strict

A 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.

The command check, validate-commands, reads every registration in a directory. For each command it says whether an agent is offered it, what would leave the agent guessing, or that the registration could not be read. Guessing means an argument without a description or a returned value without answers. Only a callable command without a description is a warning, so a plugin with private commands passes --strict. Every report ends by saying that grants, access and the window decide the rest at runtime; the check judges the registrations alone.

validate-catalog earns its place for one reason: the shell parses a plugin store catalogue 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, automation.
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 — @loomweaver/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. This section is what the server does; the path from a registered server to a running product, with a run recorded as it happened, is Building with an AI assistant.

@loomweaver/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", "@loomweaver/mcp"] }
}
}

The server speaks stdio and reports its version on connect. It is published on the platform’s version line like the CLI, and its generators emit code for that line; npx -y @loomweaver/mcp unpinned takes the latest one. A project on an older shell pins the server to it, @loomweaver/mcp@0.9.0 in the args above, and the version it reports on connect is how you check that the pin took.

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:

  1. You ask your assistant for a weaver, say a notes plugin with a command on mod+shift+n.
  2. It calls scaffold_weaver { "id": "notes", "command": true, "shortcut": "mod+shift+n" }.
  3. The server generates in memory and answers with the file map. Nothing has touched disk.
  4. 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.
  5. 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. It therefore states structure, not location, and your assistant resolves it against your layout. A remaining step that needs the location carries a placeholder for it, and your assistant fills in the directory it chose.
  • Nothing reaches disk except through your client. A server started via npx is 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: it takes input and returns two things, the file contents to write and a statement of what the workspace around them must carry for them to work. It has no filesystem access at all. The CLI, the MCP server and the Nx generator are thin adapters over it, which is why they cannot drift apart, and why the core itself never writes anything.

That second product is what keeps a scaffold honest. Sources alone do not run: a stylesheet needs a style pipeline, the chrome needs its strings served, a release build needs a setting its own content-security policy demands. Stating those as data means each adapter applies as much of it as its position allows, and says what it could not do rather than leaving a reader to discover it in the browser.

Every amendment is an ensure this is present, never a set this to. Running a scaffold twice changes nothing, and a value you chose yourself is never overruled.

AdapterWrites files?Amends the workspace?Because
@loomweaver/devkit (Nx)yesyes — the project registration, the tsconfig alias, the build target, the style pipeline, the composition root, a package the output needsNx hands it a virtual tree of your workspace
@loomweaver/cliyesyes — the style pipeline, the build target, the entry stylesheet, the composition root, a package the output needsit finds the workspace above the target directory it was given
@loomweaver/mcpnono — it names each step instead, with what it costs to skipit returns relative paths so your client stays in the review path

The files a route amends are ones it names for itself, never ones derived from what you passed on the command line: the refusal to write outside the target directory governs supplied targets and is unchanged.

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

ToolArgumentsGives you
list_generators—the available generators and what they emit
scaffold_weaversee belowa complete plugin: manifest, surface, rail item, i18n, test
scaffold_frame_pluginid, namea framework-agnostic iframe plugin (Penpal + the frame UI kit)
scaffold_distributionname, title, stylesa runnable composition root that boots the shell
scaffold_auth_sourcename, barea stand-in session a user can operate: the AuthSource, the verbs in the rail, composed in; bare for the source alone
scaffold_settings_storenamea settings-store implementation backed by your API
scaffold_themename, preseta token-override stylesheet in @layer lw-tenant-theme
scaffold_layoutnamea ShellLayout with the regions a weaver expects
validate_manifestid, name, capabilitiesfindings on a plugin manifest
validate_catalogcatalog — the parsed catalogue JSON arrayfindings on a plugin store catalogue, including fields the host never reads
validate_i18nbundles — 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)
validate_commandsfiles — TypeScript sources keyed by pathper command, whether an agent is offered it and what it would have to guess at

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 flagMCP argumentEffect
--id <id> requiredidplugin id in kebab-case, e.g. notes
--name <name>namedisplay name; defaults to a title-cased id
--commandcommandalso register a command, the complete pattern: a described choice argument, a declared answer, callable, and a run that raises a toast in the tone the caller chose and answers with it; its shortcut defaults to mod+shift+ and the first letter of the id
--shortcut <chord>shortcutkeyboard chord for it, e.g. mod+shift+n (implies command)
--menu <slot>menuhook a menu item into a slot, e.g. content/tab/context (implies command)
--bar-itembarItema status-bar button that triggers the command (implies command)
--settingssettingsa settings section with a toggle and a text field
--aboutaboutan About dialog that reads ctx.host, plus its command
--instanceableinstanceablenamed saved instances with a switcher — this docks the surface instead of routing it (see below)
--containercontainermake the surface a container: a routable tab holding a nested pane tree
--agentagentwire the weaver up for an AG-UI agent to drive: a docked panel, the seam that decides about a call before it runs, and a stand-in that works on the first serve (implies command)
--access <req>accessauth-gate the surface and rail item: authenticated, anonymous, or a role requirement
--prefix <prefix>prefixselector prefix of the generated components. Without it the CLI takes the one your application declares in angular.json, and app where it declares none; MCP cannot read your workspace, so it takes app unless you pass your application’s own
--no-specspec: falseskip 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, whether loomweaver 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.ts
src/lib/plugin/notes.plugin.ts
src/lib/plugin/notes.plugin.spec.ts
src/lib/views/notes-view.ts
src/lib/views/notes-view.html
src/lib/i18n/en.json
src/lib/i18n/de.json
README.md

No 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.

The agent connection

--agent generates what it takes for an assistant speaking the AG-UI protocol to run the commands your workbench offers, and it generates it working: serve the product and the whole path runs, from the offered list through a streamed call to its outcome, with no backend, no key and no network.

Three files land under src/lib/agent/:

  • <id>-connection.ts: the connection, which is the workbench’s half. The workbench’s own commands become the tools, and a call comes back through the same seam every other trigger runs through. It also carries the place where your product says no before a call runs. The generated command declares agentConsent: 'ask' on itself and the connection reads that off the call, so what an agent’s word is enough for stays with the command rather than in a list beside it.
  • <id>-agent-panel.ts: a docked panel showing what is offered, the call as its arguments stream in, and what came back.
  • <id>-agent.ts: a stand-in for the agent, which is your half, and it says so where you cannot miss it. It produces the protocol’s own events and nothing else. Replace that one file with your transport; the panel and the connection stay as they are. Nothing is generated for the transport, the credentials or the model, because none of those can be guessed.

What the connection guarantees, which calls to ask about and how to replace the stand-in is Driving your product with an AG-UI agent; that page also names the two packages --agent needs and the automation capability it derives.

Three shapes of surface

The default surface is routable: it lives at /<id>, holds the address 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 left panel (left-panel in the scaffolded layout) and drops routable. That is not a detail. Named instances exist only for a docked surface; a routable surface holds the address 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:

src/lib/plugin/notes.plugin.ts
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:

src/lib/views/notes-canvas-view.ts
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 — @loomweaver/devkit

If your workspace is an Nx workspace, this is the fullest of the three, because Nx hands it a virtual tree of your workspace. Beyond what the CLI wires too, it registers the project and adds the tsconfig path alias; the CLI cannot, and the MCP server describes those steps instead.

Terminal window
npm i -D @loomweaver/devkit
# what the generated source imports; the service worker pinned to the Angular version already installed
npm i @loomweaver/shell @loomweaver/plugin-sdk @loomweaver/frame-kit @angular/cdk @jsverse/transloco @ng-icons/heroicons \
@angular/service-worker@$(node -p "require('@angular/core/package.json').version")
# only for a distribution on the default --styles tailwind; `precompiled` needs none of these
npm i -D tailwindcss @tailwindcss/postcss @tailwindcss/typography
nx g @loomweaver/devkit:weaver --id notes --command --shortcut 'mod+shift+n'
nx g @loomweaver/devkit:distribution --name acme-studio --title 'Acme Studio' --styles precompiled
nx g @loomweaver/devkit:frame-plugin --id charts
nx g @loomweaver/devkit:auth-source --name acme
nx g @loomweaver/devkit:settings-store --name backend
nx g @loomweaver/devkit:theme --name midnight --preset bootstrap
nx g @loomweaver/devkit:layout --name base

An 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:

Terminal window
nx g @loomweaver/devkit:distribution --name acme-studio --directory apps/acme-studio --force

That replaces the bootstrap files this scaffold owns, and it merges the scaffold’s build targets into the project value by value. The wiring the shell needs lands this way: the i18n and frame-kit assets, the stylesheet, the service worker, inlineCritical: false and an initial budget the workbench fits. Every value the application already set stays as it set it: its build options and configurations, its own targets, its implicitDependencies and its tags. An initial budget below what the workbench needs is raised. Where one of its values leaves no room for a setting the shell needs, such as optimization: true beside inlineCritical, the generator says so and leaves it. 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 its shape:

It needs to knowHow 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 --prefix a weaver takes the prefix its composing application declares, and app where it declares none; a distribution declares app. 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 sitsderived from the directory, not hard-coded
serving the weaver’s translationsan assets glob for /i18n/<id>/ is added to the composing application’s build
styling the weaver’s templatesa @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 CLI appends the same line when it finds the workspace; over the MCP server your assistant writes it, as a named step

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.


Next: Authoring a weaver says what to do with the plugin once it exists.