Backend integration
This is a guide, not the contract. What the platform guarantees is specified under
openspec/specs/. For this page:persistence-ports·access-gating·i18n. 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 no server. The platform is the frontend (@loomweaver/shell and
@loomweaver/plugin-sdk, with @loomweaver/frame-kit for frame plugins and @loomweaver/ag-ui for
an agent connection) plus its TypeScript contracts. It never owns settings persistence, authentication, secrets or egress.
Instead it defines ports with local/anonymous defaults, and your product implements them
against its own backend. Any stack you already run works: .NET, Node, Python, Go… A distribution
runs standalone with no backend at all. You wire a backend only for multi-user/tenant persistence
and real auth.
This page is the hand-off: what a product built on the platform provides.
Three ports and a translation loader
The platform has three ports: the settings store, the working-state store and the auth source. Each is a token the shell fills with a local, anonymous default and your product overrides. Translations are the fourth thing a backend usually answers, but the loader is a Transloco provider, not a port; it is listed here because the swap is made in the same place.
| Seam | What you provide | Without it |
|---|---|---|
| Settings persistence (port) | a settings store (KeyValueStore) | localStorage |
| Working state (port, optional) | a working-state store (KeyValueStore) | localStorage |
| Auth / session (port) | an AuthSource signal | everyone is anonymous |
| Translations (provider) | a Transloco loader | static files under /i18n/ |
They are independent: adopt any subset. Each is a provider in the providers array of your
distribution’s app.config.ts, placed after provideShell() so it wins the last-in-wins race
for the token the shell already filled with its default.
1 · Settings persistence — the SETTINGS_STORE port
A string key-value port with the KeyValueStore shape. The shell writes its genuine
settings through this seam, the deliberate choices a user makes. Working state (view state,
layout, usage traces) lives behind the separate WORKING_STATE_STORE port and never reaches this
one. Which key flows through which port is listed in
Persistence stores. Writes here are rare, small and worth roaming across devices.
That is what makes a REST call per write appropriate. Values are opaque: callers serialise their
own payloads, so any backend that can store a string under a string key qualifies. The default is
LocalStorageStore, which is exported: wrap it rather than reimplement it if you only want to
observe or mirror what the shell stores.
import { KeyValueStore, provideSettingsStore } from '@loomweaver/shell';
export class HttpSettingsStore implements KeyValueStore { async get(key: string): Promise<string | undefined> { const res = await fetch(`/api/settings/${encodeURIComponent(key)}`, { credentials: 'include' }); return res.status === 404 ? undefined : res.text(); } async set(key: string, value: string): Promise<void> { await fetch(`/api/settings/${encodeURIComponent(key)}`, { method: 'PUT', body: value, credentials: 'include' }); } async delete(key: string): Promise<void> { await fetch(`/api/settings/${encodeURIComponent(key)}`, { method: 'DELETE', credentials: 'include' }); } // Omit `peek` (network stores can't answer synchronously); the shell falls back to `get` and a default.}
// in your providers, after provideShell():provideSettingsStore(HttpSettingsStore),Local or network — pick one, not half of each
The optional peek is what tells the shell which world it is in. Its mere presence is the
switch. Every consumer reads peek first. It falls back to the asynchronous path only when the
store has no peek at all. A store that answers synchronously is assumed to have answered. So
consider a store that implements peek but returns undefined for keys it happens not to have
cached. It does not quietly fall back to get. Those settings are lost without a trace.
Two shapes are supported, and the choice is per store, not per key:
peekpresent: reads are synchronous, so theme, layout and panel sizes apply before the first paint. Nothing is ever re-read afterwards, so the value yourpeekreturns at construction is the value that session uses.peekabsent: reads are asynchronous. Each consumer starts from its default and reconciles when the promise resolves, which is a brief flash of the default for anything visible.
A store that answers some keys locally and others over the network therefore cannot be expressed
by returning undefined from peek. You have two options. Either keep everything local: mirror
writes to your API in the background and let the next load converge. Or keep everything remote and
accept the reconcile. If you wrap another store, bind peek only when the inner one has it. That is
what the shell’s own identity-scoping and cross-tab wrappers do, so the signal stays truthful.
A running window does not poll your server. If your backend can push (SSE, WebSocket), call
StateSyncService.notifyRemoteChange(key) when it reports a changed key: the window then re-reads
that key through the store and applies it, exactly as it does for a change made in another
browser tab. Without a push transport, a reload is the refresh boundary.
Your backend keys the store per tenant off the authenticated session (never off the wire). It
holds no credentials, so plaintext storage is fine. It is not all trivial preferences, though.
It also carries the user’s installed community plugins, and their persisted capability list is
their grant. It carries their capability revocations too, and plugin settings blobs that may
contain whatever a plugin puts there. Treat it as user data with integrity requirements: authorize every read/write against the
session. The authoritative storage-key inventory is in
Persistence stores. It also lists
which keys are device-level and how provideIdentityScopedStores separates users on a shared
browser. A convenient wire shape is a flat { key → value } map with get-all and set-value,
but the shape is yours to choose. The frontend only needs the three async methods above.
1b · Working state — the WORKING_STATE_STORE port (optional)
The second persistence port carries what accrues from using the app: view state and view
instances, the palette’s recently-used list, and the window-local layout keys. It has the same
KeyValueStore shape but the opposite write profile: frequent, debounced writes. That is why it
defaults to the device (localStorage), and why most distributions never touch it. Three tiers:
- Local (default): do nothing.
- Cross-device at boot: provide a backend-backed store with
provideWorkingStateStore(...); a fresh tab hydrates the last persisted state through the ordinary boot path. Expect the write volume: a key that is being changed is written once it has been quiet for 400 ms, and at least every two seconds while the changes continue. Whatever is still held is sent when the page is left. The workbench cannot wait for that last answer, so completing a write that arrives while the page unloads is your store’s part (fetchwithkeepalive, orsendBeacon). - Cross-device live: additionally pair the store with a push transport and call
StateSyncService.notifyRemoteChange(key)when the backend reports a change from another device. The two rules that keep this convergent, and why it is not collaborative editing, are on Windows and sync.
2 · Auth / session — AuthSource
A provider-neutral session snapshot signal. You reduce your product’s session into an
AuthSnapshot and hand it over with provideAuthSource. The source can be OIDC, your own identity
platform, or something custom. Whenever the signal changes, the shell reacts: it hides or disables
gated chrome, gates routes, and updates ctx.session. A control naming a command the session may
not run is not drawn at all. Roles are opaque strings: the platform matches them but never
interprets them. The claim bag stays in your own composition. No plugin receives it and no gate
evaluates it.
Sign-in and sign-out are yours: there is no platform login, and the shell never opens one on its
own. Mapping your session, the login page or dialog, the redirect for a gated route and the policy
for a user switch on a shared browser are worked through in Auth integration.
Your own code reads the session back through AuthContext, described under
Session. Client-side gating is presentation, not security.
Enforce it for real in your backend, by rejecting unauthorized calls.
3 · Translations — static files or your API
By default the shell fetches its host keys from i18n/{lang}.json under the application’s base
(/i18n/{lang}.json at the root). Each namespace you registered with
provideTranslationNamespaces('notes', 'product') is fetched from i18n/<name>/{lang}.json beside it.
The shell then nests each namespace under its own key, so it can never collide with a host key. That
is a plain Transloco loader. Transloco owns that seam, not
LoomWeaver, so you swap the source with Transloco’s own provider:
import { HttpClient } from '@angular/common/http';import { Injectable, inject } from '@angular/core';import { provideTranslocoLoader, type Translation, type TranslocoLoader } from '@jsverse/transloco';
@Injectable({ providedIn: 'root' })export class ApiTranslationLoader implements TranslocoLoader { private readonly http = inject(HttpClient);
// One endpoint per language, returning the merged bundle (host keys + every namespace). getTranslation(lang: string) { return this.http.get<Translation>(`/api/i18n/${lang}`, { withCredentials: true }); }}
// after provideShell():provideTranslocoLoader(ApiTranslationLoader),Your loader replaces the whole composition. provideTranslationNamespaces is read by the
built-in loader and by nothing else, so once you provide your own, it stops having any effect. The
object you return is the translation table, and it must already contain the host keys plus each
namespace nested under its name. Host keys go flat, namespaces nested:
// GET /api/i18n/en — the merged bundle your endpoint returns{ "dialog": { "close": "Close" }, // …every host group, exactly as in @loomweaver/shell's en.json "notes": { "list": "Notes" }, // your weaver namespace, nested under its name "product": { "tagline": "Weave anything" }}The simplest split is to let your endpoint do the merging, as above. Your build can read the
shell’s own bundles out of node_modules/@loomweaver/shell/i18n/ and seed them into your translation
store, so the host strings stay in step with the version you ship:
// tools/seed-host-i18n.mjs — run at build/deploy timeimport { readFileSync } from 'node:fs';for (const lang of ['en', 'de']) { const host = JSON.parse(readFileSync(`node_modules/@loomweaver/shell/i18n/${lang}.json`, 'utf8')); await seedTranslationStore(lang, host); // your uploader — namespaces merge in on top}Nothing else changes: the language switcher, reRenderOnLangChange, and the plugin-facing rule that
keys are resolved for contribution metadata are untouched by the swap.
The first paint
The active language is stored under lw.shell.lang and therefore travels through your
settings store like every other setting. The initial language, though, is decided before
dependency injection exists, when Transloco’s config is built. That early read goes straight to
localStorage, not through your store. Without a stored value it falls back to the browser’s
languages, then to English if it is served, and otherwise to the first language the distribution
declared.
With a network-backed store the consequence is visible: a fresh browser boots in the browser’s
language and flips to the stored one once the store answers. If you want the very first paint to be
right, write the language into localStorage under lw.shell.lang before bootstrapApplication
runs, for example from a cookie your server already sets:
// src/main.ts, ABOVE bootstrapApplication(…) — the value is the bare code ('de'), not JSON:const lang = document.cookie.match(/(?:^|; )lang=([a-z-]+)/i)?.[1];if (lang) { localStorage.setItem('lw.shell.lang', lang);}It acts purely as a boot cache; your store remains the durable copy.
A language the shell does not ship
provideShell({ languages }) declares the whole set of languages the workbench serves, including
one it does not ship. Which languages are served
says what a distribution then supplies. With your own loader, that language’s bundle comes from your
endpoint like every other.
Putting it together
All three ports and the loader in one composition root. Everything below provideShell() overrides a default the
shell already provided, which is why the order matters:
export const appConfig: ApplicationConfig = { providers: [ provideShellRouter(), provideShell(), provideLayout(layout), provideProductIdentity(MY_IDENTITY),
// 1 — settings live in your backend (which already scopes them to the session) provideSettingsStore(HttpSettingsStore),
// 2 — your product owns the session; a different subject reloads the app provideAuthSource(() => { const session = inject(MySessionService); return computed<AuthSnapshot>(() => session.user() ? { authenticated: true, roles: session.roles(), claims: {}, subject: session.subject() } : ANONYMOUS, ); }, { onIdentityChange: 'reload' }), provideUnauthorizedRedirect((path) => `/login?from=${encodeURIComponent(path)}`),
// 3 — translations come from your API instead of static files provideTranslocoLoader(ApiTranslationLoader),
// your weaver(s) and their capability grants provideCapabilityGrants({ notes: ['contributions', 'ui', 'navigation'] }), ...providePlugins(notesPlugin), ],};A distribution that adopts none of this still runs. That is the point of the defaults. Adopt them when you have a backend to put behind them, one seam at a time.
One port, one provider. provideSettingsStore and provideIdentityScopedStores both fill the
same SETTINGS_STORE token, and the later one silently discards the other. A backend-backed store
already isolates users on the server. Where a shared browser needs identity scoping as well, pass
your store into the scoping provider instead, as
Persistence stores shows.
The security seam lives in your backend
Per-tenant secrets, credential-injecting egress and per-tenant capability grants are your backend’s job. You need them anyway, with or without LoomWeaver’s UI, and they cannot live in the browser. If your backend platform already provides per-tenant secret storage, session context and request dispatch, those are exactly the pieces to build on. LoomWeaver keeps only the frontend default-deny capability broker (which plugin may do what) and the grant map your backend feeds it.
The same boundary holds for a plugin that calls an external API with a tenant secret. The secret lives in your backend, the call is made there, and the browser never sees it. First-party weavers call your domain API directly. The platform defines no egress contract for third-party plugins and ships no server for one; where such a call is needed, it is your backend’s to offer.
Back to architecture · building a distribution.