diff --git a/.agents/skills b/.agents/skills new file mode 120000 index 0000000..454b842 --- /dev/null +++ b/.agents/skills @@ -0,0 +1 @@ +../.claude/skills \ No newline at end of file diff --git a/.claude/skills/duplo-extension-dev/SKILL.md b/.claude/skills/duplo-extension-dev/SKILL.md index d47b556..549533d 100644 --- a/.claude/skills/duplo-extension-dev/SKILL.md +++ b/.claude/skills/duplo-extension-dev/SKILL.md @@ -36,7 +36,8 @@ from a built-in resource (Network, Namespace, …). > [16-ask-ai](reference/16-ask-ai.md) — **opt-in** Ask AI chat sessions on a resource (build ONLY when asked) · > [17-custom-result-views](reference/17-custom-result-views.md) — the hand-written Result content: Overview tiles, tabs, panels · > [18-access-control](reference/18-access-control.md) — the `[AccessControl]` declaration and named actions · -> [19-detail-page](reference/19-detail-page.md) — the detail-page shell, the lifecycle rail, per-resource phases per mode. +> [19-detail-page](reference/19-detail-page.md) — the detail-page shell, the lifecycle rail, per-resource phases per mode · +> [20-ui-library](reference/20-ui-library.md) — every `ng-common-lib` export, a "need X → use Y" picker, verified recipes, and what works in a remote. > **Every dev-kit extension is TYPED — there is no archetype choice.** A typed extension ships a compiled > .NET DLL (`manifest.archetype = "typed"`) with the resource's own controller/route/collection, an Angular diff --git a/.claude/skills/duplo-extension-dev/reference/02-authoring-guide.md b/.claude/skills/duplo-extension-dev/reference/02-authoring-guide.md index 769a024..9f372f4 100644 --- a/.claude/skills/duplo-extension-dev/reference/02-authoring-guide.md +++ b/.claude/skills/duplo-extension-dev/reference/02-authoring-guide.md @@ -163,7 +163,8 @@ In `angular.json`, set `outputPath` to `{ "base": "dist", "browser": "" }` — A > | `federation.config.js` `name` (== manifest `frontend.remote.remoteName`) | unique `duploExtension` | > Update the routes in `extension.routes.ts`, and each component's own `imports`, to the renamed classes. -Use these library pieces (import from `@duplocloud-internal/ng-common-lib`): +Use these library pieces (import from `@duplocloud-internal/ng-common-lib`). The full catalog — every export, +and which ones don't work in a remote — is [20-ui-library](20-ui-library.md): - **List** → `SearchableDatatableModule` (`` with projected `` cells, an actions `ngbDropdown`, and a status badge). Two things the list component **MUST** do — a plain `[rows]` + `(add)` binding that loads once in `ngOnInit` is the #1 pair of bugs: diff --git a/.claude/skills/duplo-extension-dev/reference/15-error-handling.md b/.claude/skills/duplo-extension-dev/reference/15-error-handling.md index 7af170c..df9c71e 100644 --- a/.claude/skills/duplo-extension-dev/reference/15-error-handling.md +++ b/.claude/skills/duplo-extension-dev/reference/15-error-handling.md @@ -55,8 +55,10 @@ See `samples/network-stack`'s add wizard (feeds `` ## Constraints / notes -- **No cross-boundary toast.** The host exposes no `REMOTE_` alerts/toast token, so an extension remote can't raise - the host toast service. Report **inline** (form banner / wizard slot) — that works fully inside the remote. +- **Toasts reach the host, but report form errors inline.** `ngx-toastr` is a shared singleton, so the lib's + `SharedAlertsService` shows the host's toast from a remote — the confirm dialogs and `app-code-block-with-copy` + do this on their own ([20-ui-library](20-ui-library.md#confirm-dialogs)). A failed create/update still goes + **inline** (form banner / wizard slot), beside the form the user has to fix. Never report one failure both ways. - **No field-level error map.** On create, `errors` is a single string, not a per-field dict — surface it as one message; don't expect to map it onto individual fields. - **Where the real message comes from:** e.g. the name-uniqueness check throws diff --git a/.claude/skills/duplo-extension-dev/reference/20-ui-library.md b/.claude/skills/duplo-extension-dev/reference/20-ui-library.md new file mode 100644 index 0000000..04255eb --- /dev/null +++ b/.claude/skills/duplo-extension-dev/reference/20-ui-library.md @@ -0,0 +1,425 @@ +# 20 — The UI library: what `@duplocloud-internal/ng-common-lib` gives you + +Everything the platform UI library exports, what each piece is for, how to import it, and whether it works +inside an extension remote. Check here before hand-rolling a table, form control, viewer or modal — the +platform almost certainly ships it already, and using it is what makes your pages match the suite. + +Written against **0.3.0**. The source of truth is `portal/common-lib/public-api.ts` in duplo-ui; exact +signatures are in the installed package — see [Checking an exact API](#checking-an-exact-api). + +## Rules + +1. **Look here before writing markup.** Tables, form fields, validators, JSON/YAML viewers, copy buttons, + confirm dialogs, spinners and terminals all exist. Hand-rolling one is the most common way an extension + stops looking like the platform. +2. **Import from the package root only** — `@duplocloud-internal/ng-common-lib`. Never a deep path into it. +3. **Standalone piece → add the class to `imports`. Non-standalone → import the module that exports it** + ([Importing](#importing)). `CommonLibComponentsModule` covers most of them. +4. **Host services come through `REMOTE_*` tokens, never the class** ([Host services](#host-services--use-the-remote_-tokens)). +5. **Don't gate UI with `disableForReadonlyUser` / `hideForReadonlyUser`** — they do nothing in a remote. +6. **Input types are not checked.** The frontends build with `strictTemplates: false`: a misspelled input + fails the build (`NG8002`), but a wrong *shape* — say `string[]` where `FilterPill[]` is expected — compiles + and renders wrong. Match the types in the [Recipes](#recipes), or read them from the `.d.ts`. +7. **The detail-page shell and result tiles are not chosen here** — [19-detail-page](19-detail-page.md) and + [17-custom-result-views](17-custom-result-views.md) rule those. + +## Pick a component + +| You need | Use | Recipe | +|---|---|---| +| A list of resources | `searchable-datatable` | [02](02-authoring-guide.md#frontend) + [table slots](#table-slots-filters-actions-alert) | +| A facet next to the table's search box | `app-filter-pills` in `[datatable-filters]` | [table slots](#table-slots-filters-actions-alert) | +| Row actions beyond Add / Edit | `searchable-datatable` `[extraActions]` | [table slots](#table-slots-filters-actions-alert) | +| A short child list inside a detail tab | `app-expandable-list` | [expandable list](#expandable-list) | +| A form field, validator or error summary | `SharedFormsModule` | [14-forms-and-wizards](14-forms-and-wizards.md) | +| The real reason an API call failed | `extractErrorMessage`, `[form-group-errors]` | [15-error-handling](15-error-handling.md) | +| A delete / deprovision confirmation | `DeleteConfirmationModalService.openGeneric` | [confirm dialogs](#confirm-dialogs) | +| A yes/no confirmation | `ConfirmationModalService.openWithMessage` | [confirm dialogs](#confirm-dialogs) | +| Raw spec or result as JSON / YAML | `app-view-json-comp` / `app-view-yaml-comp` | [JSON / YAML viewer](#json--yaml-viewer) | +| A copy button next to an ID, ARN or command | `copy-to-clipboard` / `app-code-block-with-copy` | [copy buttons](#copy-buttons) | +| Log or command output | `app-xterm-block` | [terminal output](#terminal-output) | +| An inline loading indicator | `app-spinner` | [small pieces](#small-pieces) | +| "3 minutes ago", pretty-printed JSON | `timeAgo`, `prettyJson` pipes | [small pieces](#small-pieces) | +| A breadcrumb for an add / edit page | `REMOTE_CoreConfigService` | [breadcrumbs](#breadcrumbs) | + +## Read first — how the lib reaches your remote + +The lib is **bundled into your remote, not shared with the host** +([`federation.config.js`](../templates/helloworld/frontend/federation.config.js): *"deliberately NOT shared"*). +Your remote runs its own copy of every lib class. That splits the exports four ways: + +| Kind | Works in a remote? | Why | +|---|---|---| +| Components, directives, pipes, utils, models | **Yes** | They hold no host state. Their DI peers (`@ng-bootstrap`, `ngx-datatable`, `ngx-translate`, …) *are* shared, so they resolve the host's instances. | +| Host services — session, HTTP client, config | **Only via a `REMOTE_*` token** | Injecting the lib's class gives you your remote's own fresh instance, not the host's. See [Host services](#host-services--use-the-remote_-tokens). | +| Host-app plumbing — guards, interceptors, `forRoot` modules | **No** | They wire the host application. See [Not for remotes](#not-for-remotes). | +| Read-only-user directives | **No effect** | They read `userRoleInfo`, which only the host's session holds. Your copy has none, so the element is never disabled. The server enforces permissions anyway — [18-access-control](18-access-control.md). | + +## Importing + +Two shapes. Import whichever the piece needs into your standalone component's `imports`: + +- **Through an NgModule** — non-standalone pieces only come this way. Importing an NgModule from a standalone + component is fine and is how the samples do it. +- **The class directly** — standalone components. Some are also re-exported by a module; either works. + +| Module | Brings in | +|---|---| +| `CommonLibComponentsModule` | The broad one. `CommonModule`, `NgbModule`, Material icons, the detail-view and list-view families, `scrollable-nav-tab`, `copy-to-clipboard`, JSON/YAML viewers, sidecards, mini-card, filter pills, status filter — **and** it re-exports `SearchableDatatableModule`, `ConfirmationModalModule`, `HelpModule`, `BlockUIModule` and `RestrictElementAccessModule`, so you rarely need those separately | +| `SharedFormsModule` | Every form field, validator and error component (45 exports) | +| `SearchableDatatableModule` | `searchable-datatable` + `NgbModule`, `NgxDatatableModule` — for a list component that needs nothing else | +| `ServersideDatatableModule` / `TokenPaginatedDatatableModule` | The two server-paged tables | +| `ConfirmationModalModule` | The confirm / delete-confirm modal components | +| `CorePipesModule` | 8 of the 11 pipes | +| `HelpModule` | `help-icon`, `[navHelp]` | +| `CoreDirectivesModule` | `[data-feather]` icons, `[rippleEffect]` | + +A component mixing both shapes. No `changeDetection` property, state in signals — [use-ng22](../../use-ng22/SKILL.md): + +```ts +import { Component } from '@angular/core'; +import { + CommonLibComponentsModule, // NgModule: viewers, copy-to-clipboard, scrollable-nav-tab, the table, modals + SpinnerComponent, // standalone: add the class itself + TimeAgoPipe, // standalone pipe +} from '@duplocloud-internal/ng-common-lib'; + +@Component({ + selector: 'wd-result-panel', + imports: [CommonLibComponentsModule, SpinnerComponent, TimeAgoPipe], + template: `…`, +}) +export class ResultPanelComponent {} +``` + +## Lists & tables + +| Selector | Import | Use for | Example | +|---|---|---|---| +| `searchable-datatable` | `SearchableDatatableModule` | Client-side list: search box, paging, Add button, row actions. It emits `(filter)` but **does not filter** — see [02](02-authoring-guide.md#frontend). | every sample | +| `serverside-datatable` | `ServersideDatatableModule` | Server-side search and paging; debounces the search and emits page requests. | — | +| `token-paginated-datatable` | `TokenPaginatedDatatableModule` | APIs that page by continuation token (`(loadPage)`). | — | +| `app-list-header` · `app-list-stats` · `app-list-stat-card` | `CommonLibComponentsModule` | Page title row plus a strip of summary count cards above a table. | — | +| `app-filter-pills` | `CommonLibComponentsModule` | Single-select facet pills (`options`, `value`, `(valueChange)`). | — | +| `app-flat-status-filter` / `app-flat-dropdown-filter` | module / class | Status tabs; a flat dropdown filter. | — | +| `app-card-grid-toolbar` · `app-list-card` · `app-list-card-grid` · `app-list-page-header` | class | A card-grid list instead of a table: toolbar with search and sort, cards, count header. | — | +| `app-expandable-list` | class | A short list whose rows expand in place, with its own search. Row body via an `#elRow` template. | — | + +`FilterTableUtils.searchByFields()` / `.searchInAllFields()` filter rows for any of these. + +## Detail pages + +| Selector | Import | Use for | +|---|---|---| +| `app-detail-layout` · `app-detail-header` · `app-detail-section` | `CommonLibComponentsModule` | Page shell, header (mark, title, status, actions), body sections | +| `app-detail-fact` · `app-stat-grid` · `app-stat-card` | `CommonLibComponentsModule` | Label/value facts and stat tiles | +| `app-detail-kv` · `app-detail-empty` | `CommonLibComponentsModule` | Key/value table; empty state with an action | +| `app-detail-attached` · `app-detail-timeline` · `app-detail-callout` | `CommonLibComponentsModule` | Side-rail panels: attached facts, event timeline, callout | +| `scrollable-nav-tab` | `CommonLibComponentsModule` | Horizontally scrolling `ngbNav` tab strip — every sample | +| `view-with-sidecards` · `view-header-card` · `sidecard` · `mini-card` | `CommonLibComponentsModule` | The older detail layout; the portal is migrating off it | + +> **The detail-page shell is ruled by [19-detail-page](19-detail-page.md), not by this table.** Extension +> detail pages use the Template-G shell so they match the Extension Studio page they sit next to. The +> `app-detail-*` family is listed so you know it exists; don't switch the shell to it. + +## Forms & validation + +Everything below comes from `SharedFormsModule`. Field usage and the wizard pattern: [14-forms-and-wizards](14-forms-and-wizards.md). + +| What | Selectors | +|---|---| +| Field wrapper, label, errors | `form-field`, `form-field-errors`, `form-field-alert`, `[validation-state]`, `[validation-errors]` | +| Whole-form error summary | `[form-group-errors]` — also the `ErrorReporter` for API errors ([15-error-handling](15-error-handling.md)) | +| Special inputs | `password-field`, `generic-form-field`, `app-form-key-val-field`, `app-key-val-config-field`, `app-form-elements-collapsable-group`, `form-field[expandableModalField]` | +| Uniqueness / list checks | `[notExists]`, `[notExistsCase]`, `[notStartsWith]`, `[notStartsWithCase]`, `[asyncNotExists]` | +| Format checks | `[jsonObject]`, `[jsonArray]`, `[jsonKeyStringValue]`, `[yaml-validation]`, `[yaml-map-validation]`, `[xml-validation]`, `[ini-validation]`, `[validCron]`, `[unicode]`, `[portRangeValidator]`, `[notEqualValidator]` | +| Parent-form access | `form` (`FormRefDirective`) makes the form injectable into child components; `[use-parent-form]` joins a child's controls to it | + +> **Two directives attach on their own.** `InputTextTrimDirective` matches every `input[type="text"][name]` and +> trims whitespace from the model value. `EmailValidatorDirective` matches every `[type="email"]`. Both are live +> wherever `SharedFormsModule` is imported — a leading space the user typed will not reach your model. + +Standalone extra: `[yamlValue]` (`YamlValueValidationDirective`). Programmatic validators: `DuploValidators`. + +## Viewers & editors + +Apart from the JSON/YAML viewers, each of these is a standalone class, so it and its dependency are only +bundled into your remote if you import it. Dependencies marked *shared* come from the host at runtime instead. + +| Selector | Import | Use for | Depends on | +|---|---|---|---| +| `app-view-json-comp` · `app-view-yaml-comp` | `CommonLibComponentsModule` | Read-only (or editable) JSON / YAML with download | `file-saver` | +| `app-code-editor` | class | Editable code with syntax highlighting | CodeMirror | +| `markdown-editor` · `app-markdown-edit-modal` | class | Markdown edit + preview | CodeMirror, `ngx-markdown` *(shared)* | +| `app-yaml-snippet-editor` | class | YAML editor with insertable snippets | `ngx-monaco-editor-v2` *(shared)* | +| `app-diff-render` · `app-diff-merge` | class | Side-by-side diff; two-way merge | `diff2html` / `ngx-monaco-editor-v2` *(shared)* | +| `app-render-file` · `app-render-file-preview-modal` | class | Preview a file by type, incl. DOCX and PPTX | `docx-preview`, `pptx-renderer` (lazy) | +| `app-xterm-block` · `app-xterm-socket` | class | Static terminal output; live terminal over a socket | xterm.js (+ `socket.io-client`) | +| `app-file-editor-wrapper` · `app-entity-file-editor-wrapper` | class | File tree + tabs + editor | CodeMirror | +| `app-code-block-with-copy` · `copy-to-clipboard` | `CommonLibComponentsModule` | Code block with a copy button; copy any value | — | + +The file editor's parts also work on their own: `app-file-tree` (+ `app-file-tree-node`), `app-file-tabs`, +`app-file-icon`, `app-file-details-modal`. + +The JSON/YAML viewers' **edit** control uses the read-only-user check, so in a remote it never locks for +read-only users. Viewing is unaffected. + +## Modals & feedback + +| Symbol | Use for | +|---|---| +| `DeleteConfirmationModalService` | Delete confirmation — `samples/network-stack` | +| `ConfirmationModalService` | Any yes/no confirmation | +| `ViewDataModalService` · `ViewJsonModalService` · `MarkdownEditModalService` | Open a data / JSON / markdown viewer in a modal | + +Open modals through these services — the matching `*ModalComponent` classes are what they render. + +| Symbol | Use for | +|---|---| +| `app-spinner` (class) · `ng-block-ui` exports (`BlockUIModule`, `@BlockUI`) | Inline spinner; loading overlay | +| `extractErrorMessage()` · `ErrorReporter` / `ERROR_REPORTER` | Surface the real API error — [15-error-handling](15-error-handling.md) | +| `status-with-style` (class) | A status label with its colour | + +> **Report form and action errors inline** — the form banner, per [15-error-handling](15-error-handling.md). +> Toasts do reach the host: `ngx-toastr` is a shared singleton, so `SharedAlertsService` shows the host's +> toast, and the two confirm dialogs raise one **on their own** ([confirm dialogs](#confirm-dialogs)). Don't +> report the same failure a second time. + +## Help & small UI + +`help-icon`, `[navHelp]` (`HelpModule`) · `[data-feather]`, `[rippleEffect]` (`CoreDirectivesModule`) · +`IconModule` (Material icons). + +## Pipes + +| Pipe | Standalone | Does | +|---|---|---| +| `prettyJson` · `prettyYaml` | yes | Pretty-print an object as JSON / YAML | +| `timeAgo` | yes | "3 minutes ago" | +| `ansiToHtml` | yes | Render ANSI-coloured log output | +| `filter` · `orderby` · `initials` · `striphtml` · `safe` | via `CorePipesModule` | Filter / sort an array, initials, strip HTML, bypass sanitizing | +| `uiConfig` | via `CorePipesModule` | Read a host UI setting (`window.DUPLO_UI_CONFIG[key]`) | + +`safe` bypasses Angular's sanitizer — never feed it user-controlled HTML. + +## Utilities + +| Export | Use for | +|---|---| +| `FilterTableUtils` | Row search for tables — every sample | +| `extractErrorMessage` | Pull the human reason out of an API error | +| `EnumUtils` (+ `EnumSelectOption`, …) | Enum → dropdown options, enum value maps | +| `NameUtils` | Platform name prefixes and length limits (`withAndWithout`, `fieldAllowedLength`) | +| `StringUtils` | `isEmpty`, `safetrim`, URL / base64 encoding, casing | +| `DateUtils` · `JSONUtils` · `YamlUtils` · `CommonUtils` | Date, JSON, YAML and general helpers | +| `misc` — `generateUniqueId`, `downloadJsonAsFile`, `formatNumberWithK`, `openLinkInNewTab`, `scrollTo`, … | One-off helpers | +| `resolveAvatarColor` · `closeModalsOnNavigation` · `redirectToParentOnError` | Avatar colour; close modals on route change; resolver fallback | +| `SubDestroyService` | `takeUntil` teardown for subscribe-heavy code — prefer `takeUntilDestroyed` in new code ([use-ng22](../../use-ng22/SKILL.md)) | +| `DUPLOCONST`, `TENANT_CONSTS`, `DUPLO_CUSTOM_EVENTS` | Platform constants | + +Models (`UserTenant`, `UserPrincipal`, `KeyValuePair`, workspace / settings / infrastructure types) are exported +for typing responses. They are types only — they give you no data. + +## Recipes + +Each one is checked against the lib's source. Signals for state, `@if`/`@for`, standalone — [use-ng22](../../use-ng22/SKILL.md). + +### Table slots: filters, actions, alert + +`searchable-datatable` takes four kinds of projected content. The filter / refresh wiring every list needs is +in [02](02-authoring-guide.md#frontend); this adds the rest. + +```html + + + + {{ notice() }} + + +``` + +```ts +import { FilterPill, SearchableDatatableExtraAction } from '@duplocloud-internal/ng-common-lib'; + +protected readonly statusPills: FilterPill[] = [{ value: 'Complete', label: 'Complete' }, { value: 'Failed', label: 'Failed' }]; +protected readonly status = signal(''); // '' is the All pill +protected readonly actions = [new SearchableDatatableExtraAction({ name: 'sync', label: 'Sync now', icon: 'refresh-cw' })]; +protected onAction(name: string): void { /* name === 'sync' */ } +``` + +- **`[datatable-filters]`** sits beside the search box. The pill only reports a value — fold `status()` into the + same `computed()` that applies the search term, as in [02](02-authoring-guide.md#frontend). +- **`[datatable-actions]`** goes in the toolbar's action area. +- **`ngb-alert`** renders only while `[showAlert]` is true. +- **The default slot** takes your ``s. +- **`[extraActions]`** become items in the toolbar's "Actions" dropdown (`extraActionsButtonCaption` renames it); + `icon` is a feather name; `(action)` emits the clicked action's `name`. +- **`addLabel`** is the Add button's text — Title Case, matching the page it opens. + +### Expandable list + +A short list whose rows open in place, for a child collection inside a detail tab. Import the class +`ExpandableListComponent`. + +```html + + + {{ child.name }} + @if (!expanded) {
{{ child.description }}
} +
+ {{ child.description }} + + View + +
+``` + +- **It filters itself** over `searchFields` (case-insensitive) — unlike the table, no `computed()` needed. +- Rows are read by `id` / `name` / `description` — rename with `idKey` / `titleKey` / `descriptionKey`. +- The template reference names are fixed: `#elRow`, `#elDetail`, `#elActions`, `#elMark`, `#elToolbar`. +- Each template gets `$implicit` (the item — `let-child`), `expanded` and `index`. +- One row opens at a time; `[multiExpand]="true"` allows several. + +### Confirm dialogs + +```ts +import { firstValueFrom } from 'rxjs'; +import { DeleteConfirmationModalService } from '@duplocloud-internal/ng-common-lib'; + +private readonly deleteModal = inject(DeleteConfirmationModalService); + +protected remove(it: Widget): void { + this.deleteModal + .openGeneric('Widget', it.name, () => firstValueFrom(this.svc.remove(it.id))) + .then(() => this.router.navigate(['../..'], { relativeTo: this.route })) + .catch(() => undefined); +} +``` + +`openGeneric(entityType, entityIdentifier, onconfirm, blockUI?, action?, successMessage?, errorMessage?)`: + +- The user must **type `entityIdentifier`** before the confirm button enables. +- **`onconfirm` must return a Promise** — wrap an Observable in `firstValueFrom`. +- **The returned Promise rejects on cancel as well as on failure** — always end with `.catch`, or a cancel + becomes an unhandled rejection. +- **It toasts the outcome itself**: success, and the failure's real reason via `extractErrorMessage`. Don't + also report the failure from inside `onconfirm`. +- For deprovision, pass `action`: `openGeneric('Widget', it.name, fn, undefined, 'Deprovision')` — the title, + message and toast all use it. + +A plain yes/no works the same way: `ConfirmationModalService.openWithMessage(header, message, successMessage, +onconfirm)` — same Promise contract, same toasts, no typed confirmation. + +### JSON / YAML viewer + +For a "Raw" tab showing the spec or result as the API returned it. Both come from `CommonLibComponentsModule`. + +```html + + +``` + +- **Pass the object** — the viewer serializes it. +- **Height is the window height minus `heightToReduce`** (default `100`), sized for a modal. Inside a detail + tab, raise it so the editor doesn't run past the page. +- Read-only by default. `[enableEdit]` (JSON) / `[canEditYml]` (YAML) + `(editedMessage)` turn on editing. + +### Copy buttons + +```html +{{ it.result?.arn }} + + +``` + +- `copy-to-clipboard` is an icon that confirms with a tooltip. `[source]` takes a string or an `ElementRef` + (copies its text). +- `app-code-block-with-copy` renders the value in a code block and confirms with a **toast**. `displayCode` + shows a mask while copying the real `code`. + +### Terminal output + +Import the class `XtermBlockComponent`. + +```html + +``` + +- Plain `\n` line breaks are fine (converted for the terminal); ANSI colour codes render. +- **`[cmdOutput]` is written once, when the terminal first renders — later changes are ignored.** For output + that grows, call `writeOutput(fullText, true)` on a `viewChild(XtermBlockComponent)`; it redraws, so pass the + whole text each time. + +### Small pieces + +```html +@if (loading()) { } +{{ it.createdAt | timeAgo }} +

+```
+
+- **`prettyJson` returns highlighted HTML** — bind it to `[innerHTML]`. Inside `{{ }}` you get the raw tags.
+  Takes an object or a JSON string.
+- **`timeAgo` doesn't tick.** It recomputes only when its input changes.
+- `SpinnerComponent`, `TimeAgoPipe` and `PrettyJsonPipe` are standalone — import the classes.
+
+### Breadcrumbs
+
+```ts
+import { REMOTE_CoreConfigService } from '@duplocloud-internal/ng-common-lib';
+
+private readonly coreConfig = inject(REMOTE_CoreConfigService as any);
+
+ngOnInit(): void {
+  this.coreConfig.setExtraBreadcrumbs([{ name: this.editMode ? 'Edit Widget' : 'Add Widget', isLink: false }]);
+}
+```
+
+- Each entry is `{ name, isLink, link? }`. `setLastExtraBreadcrumbs(label)` is the one-entry shorthand.
+- The host resets them on every navigation, so set them in `ngOnInit` and never clear them.
+
+## Host services — use the `REMOTE_*` tokens
+
+Inject by token, never by class. All seven are provided by the host (`src/app/app.module.ts`).
+
+```ts
+private readonly session = inject(REMOTE_UserSession as any);
+```
+
+| Token | Gives you | Covered in |
+|---|---|---|
+| `REMOTE_UserSession` | The logged-in user, current workspace, `getTenantRefreshTimer()` | [02](02-authoring-guide.md#frontend), [13-current-user](13-current-user.md) |
+| `REMOTE_DuploHttpClient` | The host's HTTP client, with auth attached | [02](02-authoring-guide.md#frontend) |
+| `REMOTE_SystemFeatures` | Feature flags | — |
+| `REMOTE_CoreConfigService` | Host layout config, breadcrumbs | [breadcrumbs](#breadcrumbs) |
+| `REMOTE_AuthNZService` | Auth state | — |
+| `REMOTE_AIAutomationService` | AI UI-automation hooks | — |
+| `REMOTE_CanvasRendererRegistry` | Register a canvas renderer | [02](02-authoring-guide.md#frontend) |
+
+## Not for remotes
+
+| Export | Why |
+|---|---|
+| `CommonLibModule.forRoot` | Host bootstrap — sets the app-wide API URL and analytics config |
+| `RootImportsModule` | `CommonLibComponentsModule` already re-exports it; don't import it a second time |
+| `[appTableHelp]`, `customDataKeyToValue` | Exported, but no module declares them, so no template can use them |
+| `AuthNZGuard`, `SubscriptionsGuard`, `SystemSettingGuard` | Host routing; your routes are already behind them |
+| `BearerTokenInterceptor`, `DuploApiInterceptor`, `ErrorInterceptor`, the `DuploCi*` interceptors | Host HTTP pipeline; `REMOTE_DuploHttpClient` already goes through it |
+| `UserSession`, `DuploHttpClient`, `SystemFeatures`, `CoreConfigService` (the classes) | Use the `REMOTE_*` token instead |
+| `CoreMenuService`, `MixpanelService`, `AnalyticsConsentService`, `CoreTranslationService` | Host-level state |
+| `AISystemSettings*`, `WorkspaceSystemSettings*`, `HdUserProfileDataSource` | Platform-internal API wrappers — call your own route instead ([02](02-authoring-guide.md#frontend)) |
+| `[disableForReadonlyUser]`, `[hideForReadonlyUser]` | No effect in a remote (see [Read first](#read-first--how-the-lib-reaches-your-remote)) |
+
+## Checking an exact API
+
+After `npm install`, the full typed surface is one file:
+
+```bash
+grep -A40 'declare class SearchableDatatableComponent' \
+  frontend/node_modules/@duplocloud-internal/ng-common-lib/types/duplocloud-internal-ng-common-lib.d.ts
+```
+
+The `static ɵcmp` line lists the selector, every input (with `"required": true` marking the mandatory ones)
+and every output. A symbol missing from that file is not exported — don't deep-import around it.
diff --git a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/package-lock.json b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/package-lock.json
index 5ac1535..25c8ccd 100644
--- a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/package-lock.json
+++ b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/package-lock.json
@@ -8,7 +8,7 @@
       "name": "duplo-extension-helloworld-remote",
       "version": "0.1.0",
       "dependencies": {
-        "@angular-architects/native-federation": "^22.0.6",
+        "@angular-architects/native-federation": "22.0.6",
         "@angular/cdk": "^22.1.0",
         "@angular/common": "^22.1.0",
         "@angular/compiler": "^22.1.0",
@@ -17,7 +17,7 @@
         "@angular/material": "^22.1.0",
         "@angular/platform-browser": "^22.1.0",
         "@angular/router": "^22.1.0",
-        "@duplocloud-internal/ng-common-lib": "file:vendor/duplocloud-internal-ng-common-lib-0.2.0.tgz",
+        "@duplocloud-internal/ng-common-lib": "file:vendor/duplocloud-internal-ng-common-lib-0.3.0.tgz",
         "@ng-bootstrap/ng-bootstrap": "^21.0.0",
         "@ng-select/ng-select": "^23.5.1",
         "@ngbracket/ngx-layout": "^22.0.1",
@@ -32,7 +32,7 @@
         "monaco-editor": "^0.55.1",
         "ngx-echarts": "^22.0.0",
         "ngx-markdown": "^22.0.0",
-        "ngx-monaco-editor-v2": "^22.0.4",
+        "ngx-monaco-editor-v2": "22.0.4",
         "ngx-toastr": "^20.0.5",
         "rxjs": "^7.8.0",
         "tslib": "^2.6.0",
@@ -45,6 +45,25 @@
         "typescript": "6.0.3"
       }
     },
+    "node_modules/@aiden0z/pptx-renderer": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@aiden0z/pptx-renderer/-/pptx-renderer-1.3.0.tgz",
+      "integrity": "sha512-VEXSn2vmdgTwWaw96bhsEyZiSxlrhF5O5dONI+dSBXmg1WH2XM9O1eyY2LFQ68J1SY+iZnE5BVgFGGX6iox9Og==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "echarts": "^6.0.0",
+        "jszip": "^3.10.1",
+        "mtx-decompressor": "^1.4.2"
+      },
+      "peerDependencies": {
+        "pdfjs-dist": ">=5 <7"
+      },
+      "peerDependenciesMeta": {
+        "pdfjs-dist": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@algolia/abtesting": {
       "version": "1.18.0",
       "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.0.tgz",
@@ -1874,13 +1893,14 @@
       }
     },
     "node_modules/@duplocloud-internal/ng-common-lib": {
-      "version": "0.2.0",
-      "resolved": "file:vendor/duplocloud-internal-ng-common-lib-0.2.0.tgz",
-      "integrity": "sha512-/69ZgZvvSNMoGO2GQnySpW7Upfd9zVmtNqFWTa/2oQHWcIIJ+M8JdBiv3jfeyk3kG6SfZHvdPD8OylfFLnjRng==",
+      "version": "0.3.0",
+      "resolved": "file:vendor/duplocloud-internal-ng-common-lib-0.3.0.tgz",
+      "integrity": "sha512-bJHET6Rxn07EExil8KomZu3ZkpXYixtccCm5GDDOLzM+Z8ajHtZVlskUlb51MlcihGYEl09dVJgCXRaHaYIPig==",
       "bundleDependencies": [
         "ng-block-ui"
       ],
       "dependencies": {
+        "@aiden0z/pptx-renderer": "^1.2.4",
         "@codemirror/lang-css": "^6.3.1",
         "@codemirror/lang-html": "^6.4.9",
         "@codemirror/lang-javascript": "^6.2.3",
@@ -1900,6 +1920,7 @@
         "cron-validate": "^1.4.3",
         "diff": "^9.0.0",
         "diff2html": "^3.4.48",
+        "docx-preview": "^0.4.0",
         "feather-icons": "^4.29.2",
         "file-saver": "^2.0.5",
         "lodash": "^4.17.20",
@@ -6037,6 +6058,12 @@
         "url": "https://opencollective.com/core-js"
       }
     },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+      "license": "MIT"
+    },
     "node_modules/cors": {
       "version": "2.8.6",
       "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -6186,6 +6213,15 @@
         "node": ">=0.3.1"
       }
     },
+    "node_modules/docx-preview": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.4.1.tgz",
+      "integrity": "sha512-Dv4g+LeE0swHpPvNC0lAT0YsR5Zq5pAxw7d3vHQ8EChBLgIrqqe9VPL5nVt2HBJap8bEZbXUI0TJob7kBeNU9w==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "jszip": ">=3.0.0"
+      }
+    },
     "node_modules/dom-serializer": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -7116,6 +7152,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/immediate": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+      "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+      "license": "MIT"
+    },
     "node_modules/immutable": {
       "version": "5.1.9",
       "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
@@ -7136,7 +7178,6 @@
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-      "dev": true,
       "license": "ISC"
     },
     "node_modules/ini": {
@@ -7245,6 +7286,12 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "license": "MIT"
+    },
     "node_modules/isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -7344,6 +7391,27 @@
       ],
       "license": "MIT"
     },
+    "node_modules/jszip": {
+      "version": "3.10.2",
+      "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
+      "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
+      "license": "(MIT OR GPL-3.0-or-later)",
+      "dependencies": {
+        "lie": "~3.3.0",
+        "pako": "~1.0.2",
+        "readable-stream": "~2.3.6",
+        "setimmediate": "^1.0.5"
+      }
+    },
+    "node_modules/lie": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+      "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "immediate": "~3.0.5"
+      }
+    },
     "node_modules/listr2": {
       "version": "10.2.1",
       "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",
@@ -7880,6 +7948,12 @@
         "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
       }
     },
+    "node_modules/mtx-decompressor": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mtx-decompressor/-/mtx-decompressor-1.6.0.tgz",
+      "integrity": "sha512-YJ+ULX3VTzDK/k+h35xCrFey/vMyNTdKflCUtX/DPurR3KdhHKXC5bhslLFPY0jyODrwNWJJxSqvnBiGATRsRQ==",
+      "license": "MPL-2.0"
+    },
     "node_modules/mute-stream": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz",
@@ -8450,6 +8524,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/pako": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+      "license": "(MIT AND Zlib)"
+    },
     "node_modules/parse5": {
       "version": "8.0.1",
       "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -8692,6 +8772,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "license": "MIT"
+    },
     "node_modules/property-expr": {
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
@@ -8779,6 +8865,21 @@
         "node": ">= 0.10"
       }
     },
+    "node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
     "node_modules/readdirp": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@@ -8932,6 +9033,12 @@
         "tslib": "^2.1.0"
       }
     },
+    "node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
     "node_modules/safer-buffer": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -9046,6 +9153,12 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/setimmediate": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+      "license": "MIT"
+    },
     "node_modules/setprototypeof": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -9374,6 +9487,15 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
     "node_modules/string-width": {
       "version": "8.2.2",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
@@ -9623,6 +9745,12 @@
         "browserslist": ">= 4.21.0"
       }
     },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
     "node_modules/validate-npm-package-name": {
       "version": "7.0.2",
       "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",
diff --git a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/package.json b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/package.json
index 600fcb3..997ae88 100644
--- a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/package.json
+++ b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/package.json
@@ -25,7 +25,7 @@
     "@angular/material": "^22.1.0",
     "@angular/platform-browser": "^22.1.0",
     "@angular/router": "^22.1.0",
-    "@duplocloud-internal/ng-common-lib": "file:vendor/duplocloud-internal-ng-common-lib-0.2.0.tgz",
+    "@duplocloud-internal/ng-common-lib": "file:vendor/duplocloud-internal-ng-common-lib-0.3.0.tgz",
     "@ng-bootstrap/ng-bootstrap": "^21.0.0",
     "@ng-select/ng-select": "^23.5.1",
     "@ngbracket/ngx-layout": "^22.0.1",
@@ -40,7 +40,7 @@
     "monaco-editor": "^0.55.1",
     "ngx-echarts": "^22.0.0",
     "ngx-markdown": "^22.0.0",
-    "ngx-monaco-editor-v2": "^22.0.4",
+    "ngx-monaco-editor-v2": "22.0.4",
     "ngx-toastr": "^20.0.5",
     "rxjs": "^7.8.0",
     "tslib": "^2.6.0",
diff --git a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/vendor/duplocloud-internal-ng-common-lib-0.2.0.tgz b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/vendor/duplocloud-internal-ng-common-lib-0.2.0.tgz
deleted file mode 100644
index d1bb71a..0000000
Binary files a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/vendor/duplocloud-internal-ng-common-lib-0.2.0.tgz and /dev/null differ
diff --git a/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/vendor/duplocloud-internal-ng-common-lib-0.3.0.tgz b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/vendor/duplocloud-internal-ng-common-lib-0.3.0.tgz
new file mode 100644
index 0000000..32ecf3d
Binary files /dev/null and b/.claude/skills/duplo-extension-dev/templates/helloworld/frontend/vendor/duplocloud-internal-ng-common-lib-0.3.0.tgz differ
diff --git a/.env.example b/.env.example
index 35c824d..c8de660 100644
--- a/.env.example
+++ b/.env.example
@@ -11,10 +11,10 @@
 #   • Agent : a main tag (no extension-specific changes)
 # ─────────────────────────────────────────────────────────────────────────────
 
-STUDIO_TAG=branch-feature-DUPLOAI-2626-licensing-f89ba804
-AGENT_TAG=dev-0.1.2-d854e69
-UI_TAG=df3765aed25602b0d945a4e21b4f0f77bbd1e741
-XTERM_TAG=main-27ee707
+STUDIO_TAG=dev-1.0.6-45708c38
+AGENT_TAG=dev-0.1.2-4b7fcf1
+UI_TAG=e67ae329e9466304450e68a59e4f59d8bc97cb05
+XTERM_TAG=dev-0.1.2-6cdf181
 
 # Qdrant vector database (Knowledge Base). The API key is fixed and not a secret — Qdrant is bound to
 # the compose network and localhost only. Change it if you like; compose and scripts/register-qdrant.sh
@@ -100,20 +100,35 @@ AIStudio__IsMasterDisabled=true
 AIStudio__DevKitMode=true
 
 # LLM provider for the agent (run.sh sets these from your --model choice).
-#   anthropic              → ANTHROPIC_API_KEY + CLAUDE_MODEL=claude-sonnet-4-6
-#   bedrock                → AWS_* + CLAUDE_MODEL=us.anthropic.claude-sonnet-4-6
+#   anthropic              → ANTHROPIC_API_KEY + CLAUDE_MODEL=claude-sonnet-5 (+ CLAUDE_EXTRA_MODELS=claude-opus-5)
+#   bedrock                → AWS_* + CLAUDE_MODEL=us.anthropic.claude-sonnet-5 (+ CLAUDE_EXTRA_MODELS=us.anthropic.claude-opus-5)
 #   gateway                → ANTHROPIC_BASE_URL (+ ANTHROPIC_AUTH_TOKEN if the gateway needs one) +
 #                            CLAUDE_MODEL named the way the gateway lists it. Any Anthropic-compatible
 #                            endpoint: OpenRouter, Bifrost, LiteLLM, Snowflake Cortex. The agent takes this
 #                            path only when ANTHROPIC_API_KEY is EMPTY (key + URL means "proxy in front of
 #                            the real Anthropic API"), so run.sh blanks the key when you pick gateway.
+#   subscription           → CLAUDE_CODE_OAUTH_TOKEN + a bare CLAUDE_MODEL (claude-sonnet-5, plus
+#                            CLAUDE_EXTRA_MODELS=claude-opus-5). Runs the
+#                            agent on YOUR Claude Code subscription, minted on your machine with
+#                            `claude setup-token`. Usage counts against your own Claude Code limits and
+#                            the token authenticates as you, so this is a local-development choice, not
+#                            something to put in a shared stack. Sits next-to-last in the agent's
+#                            precedence chain, so run.sh blanks ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL
+#                            when you pick it — otherwise either would win and the token never be read.
+#                            Ticket titles are not generated on this path (the title LLM is Bedrock-only).
 #   bedrock-instance-role  → AWS_REGION only; the agent's credential chain falls through to the EC2
 #                            instance role via IMDS, so no keys are stored here. Offered by run.sh
 #                            only when scripts/detect-bedrock.sh proves the role can invoke the model
 #                            AND containers can reach IMDS (needs an IMDSv2 hop limit of 2).
 DEVKIT_MODEL=
 CLAUDE_MODEL=
+# Comma-separated extra model ids offered in the ticket LLM picker next to CLAUDE_MODEL (the default).
+# Registration-only — the agent itself is driven by CLAUDE_MODEL. Blank for gateway only.
+CLAUDE_EXTRA_MODELS=
 ANTHROPIC_API_KEY=
+# subscription only — a long-lived token from `claude setup-token` (sk-ant-oat01-…), NOT the
+# short-lived credential in your OS keychain and NOT an sk-ant-api… API key.
+CLAUDE_CODE_OAUTH_TOKEN=
 # gateway only. The CLAUDE_CODE_* knobs are forwarded to the agent only when set:
 #   CLAUDE_CODE_MAX_CONTEXT_TOKENS            run.sh sets 262144 when you pick gateway. A gateway usually
 #                                             serves model ids the Claude CLI doesn't recognise, and an
@@ -145,14 +160,3 @@ DUPLO_ADMIN_TOKEN=
 EXTENSION_DEV_WORKSPACE_ID=
 EXTENSION_DEV_PERMSET_ID=
 EXTENSION_DEV_PERMSETGROUP_ID=
-
-# Usage metrics. run.sh prompts on first run; the default is opted in. 1 = on, 0 = off.
-# To change afterwards: set DUPLO_USAGE_METRICS to 0 or 1 and re-run ./run.sh — it re-derives
-# METRICS_CONF, which picks the nginx fragment that adds or strips the UI's Mixpanel key.
-# See PRIVACY.md. Both MUST ship blank so a first run actually prompts.
-#
-#   DUPLO_USAGE_METRICS=1  (opted IN)   → METRICS_CONF=metrics-on.conf   → key present in the UI
-#   DUPLO_USAGE_METRICS=0  (opted OUT)  → METRICS_CONF=metrics-off.conf  → key stripped from the UI
-DUPLO_USAGE_METRICS=
-# Derived by run.sh from DUPLO_USAGE_METRICS — do not hand-edit; changing this alone does nothing.
-METRICS_CONF=
diff --git a/.gitleaksignore b/.gitleaksignore
deleted file mode 100644
index d01232b..0000000
--- a/.gitleaksignore
+++ /dev/null
@@ -1,7 +0,0 @@
-# Triaged gitleaks findings — reviewed and confirmed not secrets.
-#
-# Mixpanel project token in the usage-metrics nginx config. Mixpanel project tokens are
-# client-side identifiers shipped in the browser bundle of every deployment (write-only:
-# they can submit events, not read data), so this value is public by design. The same
-# value also appears in nginx/metrics-off.conf, where it is stripped back out.
-38dac4f2deea579259f27e227a6cb863c594c179:nginx/metrics-on.conf:generic-api-key:10
diff --git a/PRIVACY.md b/PRIVACY.md
index 5cdf193..bc73ed4 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -1,24 +1,13 @@
 # Privacy
 
-The dev kit's UI sends product usage metrics to DuploCloud via Mixpanel. This page says exactly
-what that means and how to turn it off.
+The dev kit's UI can send product usage metrics to DuploCloud via Mixpanel. This page says exactly
+what that means.
 
-## Opting out
+## Opting in
 
-At install, `./run.sh` asks:
-
-    Opt out of usage metrics? [y/N]:
-
-The default is opted in. Non-interactively, use `./run.sh --no-metrics` (or `--metrics` to opt
-back in). With no TTY, the default applies and nothing blocks.
-
-**After install:** set `DUPLO_USAGE_METRICS=0` in `.env`, re-run `./run.sh`, and reload any open UI
-tab — a tab already loaded keeps using the JavaScript it fetched before the change.
-
-Opting out is real, not a flag the UI is trusted to honor. The Mixpanel key is compiled into the
-UI image's JavaScript at build time, so `./run.sh` mounts an nginx config that rewrites the served
-bundle to control whether that key reaches your browser at all. Without a key the analytics
-library is never initialized and no request is made.
+Usage metrics are controlled inside the portal UI itself — the product asks for your consent there.
+The dev kit does not prompt for it, has no `.env` setting for it, and does not modify the served UI
+bundle: the nginx config in `nginx/default.conf` is a plain same-origin proxy to the studio.
 
 ## What is collected
 
@@ -85,8 +74,8 @@ on our behalf as our analytics provider. No advertisers, no data brokers, no oth
 
 ## Third party
 
-Mixpanel is the only analytics processor the dev kit enables. Turning metrics off means their
-library is never initialized in your browser, so no request is made to them at all.
+Mixpanel is the only analytics processor the UI integrates. If you do not opt in, their library is
+never initialized in your browser, so no request is made to them at all.
 
 The UI bundle also contains a Userflow integration, but the dev kit never supplies it a key, so it
 never activates.
diff --git a/README.md b/README.md
index eb4338d..7591daa 100644
--- a/README.md
+++ b/README.md
@@ -100,7 +100,7 @@ guides.
 | [NOTICE](NOTICE) | The boundary: what Apache-2.0 covers, what it doesn't (the container images and the vendored UI library), and the third-party images. Apache-2.0 §4(d) requires you to keep it in any redistribution. |
 | [TERMS.md](TERMS.md) | The proprietary pieces that are *not* Apache-licensed: the **`quay.io/duplocloud/*` container images** (local development only, **no modification**, production requires a separate license) and the **vendored `@duplocloud-internal/ng-common-lib` tarball** (build against it and ship what your build bundles — nothing else). Also confirms you own your extensions. |
 | [SECURITY.md](SECURITY.md) | Reporting a vulnerability. **Email `ai-reporting@duplocloud.net` — don't open a public issue.** |
-| [PRIVACY.md](PRIVACY.md) | The usage metrics the UI sends, tied to your sign-in email — what is collected, what is not, and how to opt out at install (`./run.sh --no-metrics`) or afterwards (`DUPLO_USAGE_METRICS=0`, re-run). |
+| [PRIVACY.md](PRIVACY.md) | The usage metrics the UI sends if you opt in, tied to your sign-in email — what is collected and what is not. |
 | [CONTRIBUTING.md](CONTRIBUTING.md) | PRs improving the *framework* are welcome. Your own extensions stay in your repo and don't belong upstream. |
 | [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Contributor Covenant 2.1 — the behaviour expected in issues and pull requests here, and what happens when it isn't met. Report a problem to `ai-reporting@duplocloud.net`. |
 | [SUPPORT.md](SUPPORT.md) | Where to get help and what to expect: **GitHub Issues, best effort, no SLA.** Also what belongs somewhere else. |
diff --git a/docker-compose.yml b/docker-compose.yml
index 6da6b8d..b3e32fd 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,7 +8,7 @@
 
 services:
   mongo:
-    image: mongo:6.0
+    image: mongo:7.0.14
     restart: unless-stopped
     ports:
       - "${MONGO_PORT:-27017}:27017"
@@ -105,9 +105,14 @@ services:
       - CCA_DISABLE_BWRAP=true
       - CLAUDECODE=''
       # ── LLM provider (agent picks by precedence: ANTHROPIC_API_KEY → gateway (ANTHROPIC_BASE_URL
-      #    without a key) → Azure Foundry → AWS Bedrock). Set the one you use in .env; the rest stay empty. ──
-      - CLAUDE_MODEL=${CLAUDE_MODEL:-claude-sonnet-4-6}
+      #    without a key) → Azure Foundry → CLAUDE_CODE_OAUTH_TOKEN → AWS Bedrock). Set the one you use
+      #    in .env; the rest stay empty. ──
+      - CLAUDE_MODEL=${CLAUDE_MODEL:-claude-sonnet-5}
       - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
+      # Your own Claude Code subscription (`./run.sh --model subscription`). Sits next-to-last in the
+      # agent's precedence chain, deliberately: a token exported in a shell must never displace a
+      # provider someone configured on purpose. Local development only — it authenticates as one person.
+      - CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-}
       # LLM gateway (OpenRouter, Bifrost, LiteLLM, Snowflake Cortex …). The CLAUDE_CODE_* knobs are
       # optional per-gateway tuning the agent forwards to the Claude CLI only when set.
       - ANTHROPIC_BASE_URL=${ANTHROPIC_BASE_URL:-}
@@ -140,11 +145,6 @@ services:
     volumes:
       # Published image serves the SPA only; this conf proxies API prefixes to the studio (same-origin, no CORS).
       - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
-      # Selected by run.sh from the DUPLO_USAGE_METRICS answer: metrics-on.conf inserts the
-      # Mixpanel key, metrics-off.conf strips it. Included by default.conf. The fallback is
-      # metrics-off.conf so a stack brought up without run.sh (no METRICS_CONF in .env) fails
-      # closed — no key — rather than tracking someone who never answered. See PRIVACY.md.
-      - ./nginx/${METRICS_CONF:-metrics-off.conf}:/etc/nginx/metrics.conf:ro
     depends_on:
       - duplo-ai-studio
 
diff --git a/docs/README.md b/docs/README.md
index 65e0d5f..413e47a 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -36,7 +36,7 @@ Worked examples live in [`samples/`](../samples) — start with
 | [NOTICE](../NOTICE) | The boundary: what Apache-2.0 covers and what it does not |
 | [TERMS.md](../TERMS.md) | The proprietary container images and vendored library — local development only |
 | [SECURITY.md](../SECURITY.md) | Reporting a vulnerability |
-| [PRIVACY.md](../PRIVACY.md) | The usage metrics the UI sends, what is collected, and how to opt out |
+| [PRIVACY.md](../PRIVACY.md) | The usage metrics the UI sends if you opt in, and what is collected |
 | [CONTRIBUTING.md](../CONTRIBUTING.md) | Framework PRs welcome; your extensions stay in your repo |
 | [CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md) | Contributor Covenant 2.1 — expected behaviour in issues and PRs |
 | [SUPPORT.md](../SUPPORT.md) | Where to get help and what to expect: GitHub Issues, best effort, no SLA |
diff --git a/docs/architecture.md b/docs/architecture.md
index f3017a4..e846f0f 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -12,7 +12,7 @@ Nothing here builds from source. Every service pulls a published image pinned by
 | `duplo-ai-studio` | `quay.io/duplocloud/backend` | 60031 | The platform API, serving `/v1/aiservicedesk`. Owns extensions, workspaces, resources, and tickets, and enforces the license in `Licensing__Token`. |
 | `duplo-ui` | `quay.io/duplocloud/duplo-ai-helpdesk-ui` | 4210 | The Angular portal. The image serves the SPA only; the mounted `nginx/default.conf` proxies API prefixes to the studio, so the browser sees one origin and there is no CORS. |
 | `claude-code-agent` | `quay.io/duplocloud/duplo-agent` | 8010 | The LLM agent that executes provisioning tickets. Calls back to the studio at `http://duplo-ai-studio:60021`. |
-| `mongo` | `mongo:6.0` | 27018 | All platform state, including the `loaded_extensions` records. |
+| `mongo` | `mongo:7.0.14` | 27018 | All platform state, including the `loaded_extensions` records. Matches the version pinned in the helpdesk-helm chart. |
 | `qdrant` | `qdrant/qdrant:v1.18.2` | 6333 | The vector database behind the Knowledge Base. The studio reaches it in-network at `http://qdrant:6333`; the host port is published so its dashboard is available for debugging. Runs with RBAC on, so the studio can mint read-only per-collection tokens instead of sharing the master key. |
 | `xterm` | `quay.io/duplocloud/duplo-xterm` | 6061 | In-browser terminal. The browser reaches it on the published host port, so `AIStudio__XtermHost` must be host-reachable. |
 | `init-perms` | `busybox:1.36` | — | One-shot. Chowns the shared named volumes to uid 1001 so the non-root studio and agent can write them. Named volumes are created root-owned, so this runs on every startup. |
diff --git a/docs/cli-reference.md b/docs/cli-reference.md
index cdf9810..e706a21 100644
--- a/docs/cli-reference.md
+++ b/docs/cli-reference.md
@@ -21,7 +21,8 @@ it is safe and silent.
 ./run.sh --email you@yourcompany.com --password 'pw' --model anthropic --anthropic-key sk-ant-...
 ./run.sh --model bedrock --aws-access-key-id AKIA... --aws-secret-access-key ... [--aws-session-token ...] [--aws-region us-west-2]
 ./run.sh --model bedrock-instance-role [--aws-region us-east-1]
-./run.sh --model gateway --gateway-url https://openrouter.ai/api --gateway-token sk-or-... [--gateway-model anthropic/claude-sonnet-4.6]
+./run.sh --model gateway --gateway-url https://openrouter.ai/api --gateway-token sk-or-... [--gateway-model anthropic/claude-sonnet-5]
+./run.sh --model subscription --subscription-token sk-ant-oat01-... [--subscription-model claude-sonnet-5]
 ```
 
 On an EC2 host the provider prompt first probes whether the instance role can actually invoke Bedrock
@@ -35,11 +36,13 @@ On an EC2 host the provider prompt first probes whether the instance role can ac
 | `--non-interactive`, `-y` | Never prompt. A missing required value is an error instead: `Missing  — pass its flag (non-interactive).` |
 | `--email ` | Admin email (your UI login, and the address the license is issued to). Must be a **work** address; personal domains are rejected by the license server. |
 | `--password ` | Admin password. |
-| `--model <1\|2\|3\|4\|anthropic\|bedrock\|gateway\|bedrock-instance-role>` | LLM provider. `1` is anthropic, `2` is bedrock, `3` is gateway, `4` is bedrock-instance-role. Option `4` is only offered at the prompt when the probe proves the role can invoke Bedrock, but `--model bedrock-instance-role` can be passed directly — it then probes and **fails** rather than falling back, since you asked for it explicitly. |
+| `--model <1\|2\|3\|4\|5\|anthropic\|bedrock\|gateway\|bedrock-instance-role\|subscription>` | LLM provider. `1` is anthropic, `2` is bedrock, `3` is gateway, `4` is bedrock-instance-role, `5` is subscription. Option `4` is only offered at the prompt when the probe proves the role can invoke Bedrock, but `--model bedrock-instance-role` can be passed directly — it then probes and **fails** rather than falling back, since you asked for it explicitly. |
 | `--anthropic-key ` | Anthropic API key. |
+| `--subscription-token ` | `subscription` only. A long-lived Claude Code token, minted with `claude setup-token` on your own machine — it opens a browser, so it cannot be done from inside the dev kit. Expect `sk-ant-oat01-…`; an `sk-ant-api…` key pasted here is caught with a note, since it would otherwise be sent as a bearer token and fail with a puzzling 401. |
+| `--subscription-model ` | `subscription` only. A **bare** first-party model id (`claude-sonnet-5`, `claude-opus-5`). Defaults to `claude-sonnet-5`. A `us.anthropic.*` inference-profile id left over from a Bedrock run is rejected by the first-party API, so it is swapped for the default with a note rather than failing. |
 | `--gateway-url ` | `gateway` only. Base URL of an Anthropic-compatible LLM gateway — OpenRouter (`https://openrouter.ai/api`), a Bifrost running on this machine (`http://host.docker.internal:8080` — the agent runs in a container, so `localhost` would be the container itself), LiteLLM, Snowflake Cortex. Without `/v1/messages`; a trailing slash is stripped. Must start with `http://` or `https://`. |
 | `--gateway-token ` | `gateway` only. The API key / bearer token the gateway expects. Optional: omit for an unauthenticated gateway. Pass `none` to blank a token saved from an earlier run. |
-| `--gateway-model ` | `gateway` only. The model name **as the gateway lists it** (OpenRouter: `anthropic/claude-sonnet-4.6`; a pass-through gateway usually takes the plain `claude-sonnet-4-6`). Defaults to `claude-sonnet-4-6`. |
+| `--gateway-model ` | `gateway` only. The model name **as the gateway lists it** (OpenRouter: `anthropic/claude-sonnet-5`; a pass-through gateway usually takes the plain `claude-sonnet-5`). Defaults to `claude-sonnet-5`. |
 | `--gateway-max-context-tokens ` | `gateway` only, never prompted. Overrides `CLAUDE_CODE_MAX_CONTEXT_TOKENS`, which `gateway` sets to **262144** by default (a gateway usually serves model ids the Claude CLI doesn't recognise, and those have no known context window). |
 | `--gateway-compact-window ` | `gateway` only, never prompted. Overrides `CLAUDE_CODE_AUTO_COMPACT_WINDOW`, default **200000**. Keep it below the max-context value. |
 | `--gateway-disable-betas <0\|1>` | `gateway` only, never prompted, unset by default. Sets `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` — needed for Snowflake Cortex. See [configuration](configuration.md#llm-provider). |
@@ -47,8 +50,6 @@ On an EC2 host the provider prompt first probes whether the instance role can ac
 | `--aws-secret-access-key ` | Bedrock credentials. |
 | `--aws-session-token ` | Bedrock session token, if you use temporary credentials. |
 | `--aws-region ` | Bedrock region. Defaults to `us-west-2` for `bedrock`. For `bedrock-instance-role` it skips the probe and uses this region directly. |
-| `--no-metrics` | Opt out of usage metrics. The default is opted **in**. |
-| `--metrics` | Opt back in. |
 | `--studio-tag ` | Override and **pin** `STUDIO_TAG`. It stops tracking `.env.example`. |
 | `--ui-tag ` | Override and pin `UI_TAG`. |
 | `--agent-tag ` | Override and pin `AGENT_TAG`. |
@@ -65,7 +66,7 @@ On an EC2 host the provider prompt first probes whether the instance role can ac
 5. Obtains a license for that address if `Licensing__Token` is empty — see
    [configuration.md § Licensing](configuration.md#licensing). This is where a run can wait on you: the
    license server emails the address a link, and the run polls for up to 2 minutes after it is clicked.
-6. Resolves password, LLM provider, and the metrics choice: flag, then `.env`, then prompt.
+6. Resolves password and LLM provider: flag, then `.env`, then prompt.
 7. Generates `Encryption__MasterKey` and `Authentication__JwtSharedSecret` once, if unset.
 8. `docker compose pull`, then `docker compose up -d`.
 9. Waits up to ~4.5 minutes for the studio to answer on `/healthz` — an anonymous route, because this
@@ -126,7 +127,7 @@ combine.
 `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `Encryption__MasterKey`,
 `Authentication__JwtSharedSecret`, `DUPLO_ADMIN_TOKEN`, `EXTENSION_DEV_WORKSPACE_ID`,
 `EXTENSION_DEV_PERMSET_ID`, `EXTENSION_DEV_PERMSETGROUP_ID`, `QDRANT_PROVIDER_ID`, `QDRANT_SCOPE_ID`,
-`QDRANT_COLLECTION_ID`, `DUPLO_USAGE_METRICS`, `METRICS_CONF`.
+`QDRANT_COLLECTION_ID`.
 
 `--reset` is the **only** thing that clears provider credentials — normal restarts never touch them, so
 use it to switch providers cleanly. Blanking `AWS_REGION` is also what forces `bedrock-instance-role` to
@@ -177,11 +178,11 @@ ERROR: extension naming validation failed — fix the ✗ items above (reference
 
 | Script | What it does |
 | --- | --- |
-| `switch-llm.sh ` | Switch the running agent's LLM provider in place — no `--reset`, no DB wipe, no re-licensing. Stashes the outgoing provider's credentials in `.env` as `_STASH_=` (restored automatically if you switch back) and blanks the live ones, since the agent picks its provider by precedence and a leftover key would otherwise keep winning. Recreates `claude-code-agent` and re-runs `register-llm.sh`. `status` shows the active provider and which others have a usable stash. Same flags as `run.sh`'s provider arms (`--gateway-url`, `--aws-access-key-id`, etc.) — see `--help`. |
+| `switch-llm.sh ` | Switch the running agent's LLM provider in place — no `--reset`, no DB wipe, no re-licensing. Stashes the outgoing provider's credentials in `.env` as `_STASH_=` (restored automatically if you switch back) and blanks the live ones, since the agent picks its provider by precedence and a leftover key would otherwise keep winning. Recreates `claude-code-agent` and re-runs `register-llm.sh`. `status` shows the active provider and which others have a usable stash. Same flags as `run.sh`'s provider arms (`--gateway-url`, `--aws-access-key-id`, etc.) — see `--help`. |
 | `register-agent.sh [workspace-id]` | Register the bundled `claude-code-agent` and attach it to a workspace. Provisioning tickets are assigned from the workspace's agent list, so a resource only provisions once its workspace has an agent. Local stack only. |
-| `register-llm.sh [model-id]` | Register the model the agent actually runs on and make it the sole System default, so the ticket LLM picker offers exactly that model. Provider-agnostic: defaults to `CLAUDE_MODEL`, which `run.sh` sets per provider — a bare id for direct Anthropic, a `us.anthropic.*` inference-profile id for either Bedrock mode. The two are not interchangeable. `LLM_PROVIDER_LABEL` suffixes the display name. |
+| `register-llm.sh [default-id [extra-id...]]` | Register the models the agent can run on and make them the only System models, with the first as the default — so the ticket LLM picker offers exactly those. Provider-agnostic: defaults to `CLAUDE_MODEL` + `CLAUDE_EXTRA_MODELS`, which `run.sh` sets per provider — bare ids for direct Anthropic (`claude-sonnet-5`, `claude-opus-5`), `us.anthropic.*` inference-profile ids for either Bedrock mode. The two are not interchangeable. `LLM_PROVIDER_LABEL` suffixes the display names. |
 | `register-qdrant.sh [workspace-id]` | Turn the `qdrant` container into a usable Knowledge Base: register it as a `vectorDatabase` provider, create a `qdrant` scope over it so the provider appears in the ticket scope picker, attach that scope to the workspace, and create an empty `devkit-docs` collection owned by it. The workspace defaults to `EXTENSION_DEV_WORKSPACE_ID`; with none the collection is created admin-managed. The collection provisions asynchronously — it shows Pending, then Ready about a minute later. Uploading documents into it additionally needs AWS Bedrock credentials with access to `cohere.embed-v4`, the seeded embedding model; without them the collection still reaches Ready but every upload fails (see [configuration.md](configuration.md#knowledge-base)). `QDRANT_INTERNAL_URL` overrides the Qdrant URL, which must be compose-network-resolvable (`http://qdrant:6333`) because the studio is what dials it. |
-| `detect-bedrock.sh [model-id] [region]` | Probe whether this host is an EC2 instance whose IAM role can invoke Bedrock. Makes a real 1-token Converse call against the model (default `us.anthropic.claude-sonnet-4-6`) and separately checks a container can reach IMDS. Needs only `python3` and `curl` — SigV4 is signed with the standard library, no AWS CLI or boto3. Prints `BEDROCK_REGION`, `AWS_ROLE`, `CONTAINER_IMDS` (`ok` / `blocked` / `unknown:`), and `BEDROCK_REASON` as `KEY=value` lines for `run.sh` to consume; safe to run standalone. |
+| `detect-bedrock.sh [model-id] [region]` | Probe whether this host is an EC2 instance whose IAM role can invoke Bedrock. Makes a real 1-token Converse call against the model (default `us.anthropic.claude-sonnet-5`) and separately checks a container can reach IMDS. Needs only `python3` and `curl` — SigV4 is signed with the standard library, no AWS CLI or boto3. Prints `BEDROCK_REGION`, `AWS_ROLE`, `CONTAINER_IMDS` (`ok` / `blocked` / `unknown:`), and `BEDROCK_REASON` as `KEY=value` lines for `run.sh` to consume; safe to run standalone. |
 | `setup-google-auth.sh [--force] [--grant --sa ]` | One-time setup for keyless GCP scopes. Ensures `gcloud auth application-default login` has run — plain `gcloud auth login` does not write the ADC file the studio reads. |
 | `refresh-common-lib.sh ` | Refresh the vendored `@duplocloud-internal/ng-common-lib` tarball across every sample and the skill template. See [UPGRADING-ng-common-lib.md](UPGRADING-ng-common-lib.md). |
 
diff --git a/docs/configuration.md b/docs/configuration.md
index e58d968..30c5f2a 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -164,13 +164,20 @@ Managed by `./run.sh` — leave them blank in a fresh `.env` and let it prompt.
 ## LLM provider
 
 `run.sh` sets these from your `--model` choice. Precedence in the agent is `ANTHROPIC_API_KEY` → gateway
-(`ANTHROPIC_BASE_URL` with the key **empty**) → Azure → Bedrock.
+(`ANTHROPIC_BASE_URL` with the key **empty**) → Azure → `CLAUDE_CODE_OAUTH_TOKEN` → Bedrock.
+
+Subscription auth sits next-to-last on purpose: a token exported in someone's shell must never displace a
+provider that was configured deliberately. The cost of that ordering is that the `subscription` arm has to
+blank `ANTHROPIC_API_KEY` and `ANTHROPIC_BASE_URL`, or either would win the chain and the token would never
+be read.
 
 | Variable | Effect |
 | --- | --- |
-| `DEVKIT_MODEL` | `anthropic`, `bedrock`, `gateway`, or `bedrock-instance-role`. Chooses which credential block below is used. |
-| `CLAUDE_MODEL` | The model id the agent calls. `claude-sonnet-4-6` for Anthropic; `us.anthropic.claude-sonnet-4-6` for either Bedrock mode; for `gateway`, whatever the gateway calls it (OpenRouter: `anthropic/claude-sonnet-4.6`). Not interchangeable — the direct Anthropic API rejects the `us.*` prefix and Bedrock requires it. |
+| `DEVKIT_MODEL` | `anthropic`, `bedrock`, `gateway`, `bedrock-instance-role`, or `subscription`. Chooses which credential block below is used. |
+| `CLAUDE_MODEL` | The model id the agent calls, and the System default in the ticket LLM picker. `claude-sonnet-5` for Anthropic; `us.anthropic.claude-sonnet-5` for either Bedrock mode; for `gateway`, whatever the gateway calls it (OpenRouter: `anthropic/claude-sonnet-5`); for `subscription`, a first-party id the Claude CLI accepts. Not interchangeable — the direct Anthropic API rejects the `us.*` prefix and Bedrock requires it. |
+| `CLAUDE_EXTRA_MODELS` | Comma-separated extra model ids registered alongside `CLAUDE_MODEL` so they show in the picker. `claude-opus-5` for Anthropic and `subscription` (both call the first-party API with bare ids), `us.anthropic.claude-opus-5` for Bedrock, blank for `gateway` (a gateway names models its own way, so only the id you supplied is registered). Registration-only; the agent runs on `CLAUDE_MODEL`. |
 | `ANTHROPIC_API_KEY` | Direct Anthropic API key. Blanked by `gateway`: key **and** URL together mean "proxy in front of the real Anthropic API", a different agent code path that would send the gateway's token nowhere useful. |
+| `CLAUDE_CODE_OAUTH_TOKEN` | `subscription`: a long-lived Claude Code token from `claude setup-token`, run on your own machine (it needs a browser). Not the short-lived credential in your OS keychain, and not an `sk-ant-api…` API key — those are three different auth schemes to the Claude CLI. Runs the agent on your personal Claude Code subscription, so usage counts against your own limits and every ticket authenticates as you: local development only, never a shared stack. Ticket titles are not generated on this path, since the title LLM is Bedrock-only. |
 | `ANTHROPIC_BASE_URL` | `gateway`: base URL of any Anthropic-compatible endpoint — OpenRouter, Bifrost, LiteLLM, Snowflake Cortex. The gateway holds the real provider credentials; Bedrock is never enabled on this path. |
 | `ANTHROPIC_AUTH_TOKEN` | `gateway`: bearer token / API key the gateway expects. Blank for an unauthenticated gateway (a local Bifrost, say). |
 | `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | `gateway` only — `run.sh` sets **262144** when you pick it. Declares the model's real context window. A gateway usually serves model ids the Claude CLI doesn't recognise, and an unrecognised id has no known window, so long sessions fail with a 400 instead of compacting. Override with `--gateway-max-context-tokens`, or edit `.env` (re-runs keep whatever is there). Not set by any other provider — those name models the CLI already knows. |
@@ -241,23 +248,10 @@ works against the API.
 
 ## Usage metrics
 
-The portal sends product usage metrics to DuploCloud via Mixpanel, tied to the email you sign in with.
-[PRIVACY.md](../PRIVACY.md) lists exactly what is and is not collected.
-
-| Variable | Default | Effect |
-| --- | --- | --- |
-| `DUPLO_USAGE_METRICS` | *(blank — `run.sh` prompts on first run)* | `1` opted in, `0` opted out. Ships blank so a first run actually asks. |
-| `METRICS_CONF` | *(derived)* | The nginx fragment implementing the choice — `metrics-on.conf` or `metrics-off.conf`. **Do not hand-edit; changing it alone does nothing.** `run.sh` re-derives it from `DUPLO_USAGE_METRICS` on every run. |
-
-`./run.sh` prompts on first run and the default is opted **in**. Non-interactively, use `--no-metrics` or
-`--metrics`; with no TTY the default applies silently rather than blocking.
-
-**To change your mind:** set `DUPLO_USAGE_METRICS` to `0` or `1`, re-run `./run.sh`, and reload any open UI
-tab — a tab already loaded keeps using the JavaScript it fetched before the change.
-
-The opt-out is enforced at the proxy, not by trusting the UI. The Mixpanel key is compiled into the
-published image's Angular bundle at build time, so `run.sh` mounts an nginx config that rewrites the served
-bundle. Opted out, the key never reaches your browser and the analytics library is never initialized.
+The portal UI asks for consent to send product usage metrics to DuploCloud via Mixpanel. That choice is
+made in the UI, not by `run.sh` — there is no dev-kit prompt, flag, or `.env` variable for it, and
+`nginx/default.conf` serves the published UI bundle unmodified. [PRIVACY.md](../PRIVACY.md) lists exactly
+what is and is not collected.
 
 ### `.env.defaults`
 
diff --git a/docs/faq.md b/docs/faq.md
index bc295b7..d9dbc0b 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -108,23 +108,19 @@ The stack runs entirely on your machine. Three things leave it:
 
 - **Your LLM provider.** The agent sends prompts to Anthropic, Azure, or Bedrock using the key **you**
   supply, under that provider's terms.
-- **Usage metrics**, unless you opt out — see the next question and [PRIVACY.md](../PRIVACY.md).
+- **Usage metrics**, if you opt in inside the UI — see the next question and [PRIVACY.md](../PRIVACY.md).
 - **Image pulls** from `quay.io`.
 
 Your tickets, extensions, and platform data stay in your local Mongo and file store.
 
-### What is collected, and how do I opt out?
+### What is collected?
 
-The portal sends product usage metrics to DuploCloud via Mixpanel. It is **not anonymous** — the metrics
-are tied to the email you sign in with, along with your username, roles, and email domain. What is sent is
-which features you used and the *names* of objects you created, not their contents.
-[PRIVACY.md](../PRIVACY.md) enumerates every event and property.
+The portal sends product usage metrics to DuploCloud via Mixpanel **only if you opt in inside the UI**. It
+is **not anonymous** — the metrics are tied to the email you sign in with, along with your username, roles,
+and email domain. What is sent is which features you used and the *names* of objects you created, not
+their contents. [PRIVACY.md](../PRIVACY.md) enumerates every event and property.
 
-`./run.sh` asks on first run, defaulting to opted in. To opt out at install use `./run.sh --no-metrics`;
-afterwards set `DUPLO_USAGE_METRICS=0` in `.env`, re-run `./run.sh`, and reload any open UI tab.
-
-The opt-out is enforced by the proxy rather than trusted to the UI — opted out, the Mixpanel key never
-reaches your browser. See [configuration.md](configuration.md#usage-metrics).
+The dev kit itself has no metrics prompt or setting; the consent lives in the product UI.
 
 ### What happens when my trial expires?
 
diff --git a/docs/getting-started/connect-aws.md b/docs/getting-started/connect-aws.md
index 78457c6..4293703 100644
--- a/docs/getting-started/connect-aws.md
+++ b/docs/getting-started/connect-aws.md
@@ -177,7 +177,7 @@ Switch back to **AI DevOps** with the app switcher at the top left before you st
 
 3. Leave the model selector at the bottom right of that box alone. It is already set, and reads your
    model followed by `(Direct Anthropic)` with a sub-line of `SDK: local-agent` — for example
-   `claude-sonnet-4-6 (Direct Anthropic)`. There is no separate agent to choose.
+   `claude-sonnet-5 (Direct Anthropic)` (with `claude-opus-5` also offered). There is no separate agent to choose.
 
 4. Open **Select Scopes** and pick **`aws-readonly`**. Each entry shows the scope name with a small
    type badge beside it — `aws` for this one.
diff --git a/docs/getting-started/connect-kubernetes.md b/docs/getting-started/connect-kubernetes.md
index 0792c2d..78d2b23 100644
--- a/docs/getting-started/connect-kubernetes.md
+++ b/docs/getting-started/connect-kubernetes.md
@@ -334,7 +334,7 @@ Switch back to **AI DevOps** with the app switcher at the top left before you st
 
 3. Leave the model selector at the bottom right of that box alone. It is already set, and reads your
    model followed by `(Direct Anthropic)` with a sub-line of `SDK: local-agent` — for example
-   `claude-sonnet-4-6 (Direct Anthropic)`. There is no separate agent to choose.
+   `claude-sonnet-5 (Direct Anthropic)` (with `claude-opus-5` also offered). There is no separate agent to choose.
 
 4. Check the scope. The scope selector is the second dropdown at the bottom right of the ticket box,
    next to the model selector. If `eks-readonly` is your only scope it is **already selected** and
diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md
index b1b5e27..b9fd8c5 100644
--- a/docs/getting-started/install.md
+++ b/docs/getting-started/install.md
@@ -135,7 +135,6 @@ already set, so a second run is silent.
 | `Select LLM provider:` → `Enter 1 or 2:` | `1` for `anthropic (API key)`, `2` for `bedrock (AWS keys)`. On an EC2 instance with a working Bedrock role, option `3` uses that role with no keys. |
 | `Anthropic API key:` *(provider 1)* | Your `sk-ant-…` key. Not echoed. |
 | `AWS access key id:` / `AWS secret access key:` *(provider 2)* | Your Bedrock credentials. |
-| `Opt out of usage metrics? [y/N]:` | Enter keeps you opted **in**. `y` opts out. |
 
 The provider menu prints exactly this:
 
@@ -150,17 +149,6 @@ On an EC2 instance with a working role, the menu also prints
 `3) bedrock via this EC2 instance role —  @ , no keys` and the prompt becomes
 `Enter 1, 2 or 3:`.
 
-The metrics question is last, on purpose:
-
-```
-DuploCloud collects product usage metrics from this dev kit.
-Opt out of usage metrics? [y/N]:
-```
-
-What is and is not collected is spelled out in [PRIVACY.md](../../PRIVACY.md). You can change your mind
-afterwards — set `DUPLO_USAGE_METRICS` to `0` or `1`, re-run `./run.sh`, and reload the UI tab. See
-[configuration.md](../configuration.md#usage-metrics).
-
 Every `./run.sh` flag is in the [CLI reference](../cli-reference.md#runsh).
 
 ## 1.6 The ready banner
@@ -177,9 +165,7 @@ You are done when you see:
   API       http://localhost:60031
   Workspace extension-dev  (68d1f0c2a4b95e0c3d7e1a42)  ·  agent registered + attached
   Token     DUPLO_ADMIN_TOKEN set in .env (permanent)
-  LLM       System default → claude-sonnet-4-6 (direct Anthropic)
-  Metrics   on (opted in)
-            change: set DUPLO_USAGE_METRICS=0|1 in .env, re-run ./run.sh, reload the UI tab  ·  see PRIVACY.md
+  LLM       System default → claude-sonnet-5 (direct Anthropic); also available: claude-opus-5
 
 Build & deploy your extension (scripts read the target from .env — no DUPLO_BASE= prefix needed):
   ./scripts/build-extension.sh  extensions/               # your extensions live in extensions//
diff --git a/docs/quickstart.md b/docs/quickstart.md
index ffb04d1..8a16c06 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -47,8 +47,7 @@ docker --version && docker compose version
    request without sending a second email.
 
    The remaining prompts: `Admin password:` (remembered by the database, not changeable by editing `.env`
-   later), `Select LLM provider:` → `Enter 1 or 2:` (`1` anthropic, `2` bedrock) and its key, and
-   `Opt out of usage metrics? [y/N]:`.
+   later) and `Select LLM provider:` → `Enter 1 or 2:` (`1` anthropic, `2` bedrock) and its key.
 
 4. `./run.sh` prints a `✔ Platform ready` summary when the stack is up. Sign in at
     with that email and password. You land with the `extension-dev` workspace
diff --git a/hackday/Installing DevKit.md b/hackday/Installing DevKit.md
index 61fc372..e7b27e5 100644
--- a/hackday/Installing DevKit.md	
+++ b/hackday/Installing DevKit.md	
@@ -75,7 +75,7 @@ extension directory — do it now so your Hack Day work has somewhere to live.
 > request is discarded and your trial is still intact.
 
 The remaining prompts: `Admin password:` (stored in the database — editing `.env` later won't change it),
-`Select LLM provider:` (`1` anthropic, `2` bedrock) and its key, and `Opt out of usage metrics? [y/N]:`.
+and `Select LLM provider:` (`1` anthropic, `2` bedrock) and its key.
 
 **4. Sign in.** `./run.sh` prints `✔ Platform ready` when the stack is up. Go to
 **** and sign in with that email and password.
diff --git a/hackday/Sponsor Integrations.md b/hackday/Sponsor Integrations.md
index a43f60f..a37a93c 100644
--- a/hackday/Sponsor Integrations.md	
+++ b/hackday/Sponsor Integrations.md	
@@ -139,77 +139,128 @@ The agent can now read and write Neo4j data.
 
 ## Crusoe / Nebius (via OpenRouter)
 
-Run the agent's model on Crusoe or Nebius GPUs. 
-
+Run the agent's model on Crusoe or Nebius GPUs. OpenRouter fronts both providers; a **preset**
+pins your requests to the one you picked. No code changes — the dev kit already speaks to any
+Anthropic-compatible gateway.
 
 ### 1. OpenRouter account
 
 1. Sign up at [openrouter.ai](https://openrouter.ai)
-2. **Settings → Credits**
-3. **Settings → Keys** → create one. **Copy it now** (`sk-or-v1-…`) — shown once
+2. Left sidebar → **Credits** → add credits ($10 is plenty for Hack Day)
+3. Left sidebar → **API Keys** → create one. **Copy it now** (`sk-or-v1-…`) — shown once
 
 ### 2. Create a preset per provider
 
-**Settings → Presets → New Preset**
+Without a preset, OpenRouter routes each request to *any* provider serving that model — your
+"Nebius" traffic can silently land on Alibaba. The preset is the pin.
+
+Left sidebar → **Presets** → **New Preset**. The form is long; you touch exactly **three
+sections** and leave everything else blank:
+
+1. **Basic Info → Name** — name it for the pairing, e.g. `duplo-nebius-glm`. The **Slug**
+   auto-fills; that slug is your model string later: `@preset/duplo-nebius-glm`
 
-1. **Name** it for the pairing, e.g. `duplo-nebius-qwen`. The auto-filled **slug** is what you reference as `@preset/duplo-nebius-qwen`
-2. **Add model** — e.g. `qwen3-235b-a22b-2507`
-   - **Must support tool calling**, or the agent can never call a tool. Filter for it on [openrouter.ai/models](https://openrouter.ai/models)
+   ![Name and slug](images/openrouter-01-preset-name.png)
+
+2. **Models → Add model** — search your model (e.g. `glm-5.1`) and click the match. The panel on
+   the right shows the canonical id (`z-ai/glm-5.1`) and its **Context** size — note that number,
+   step 4 needs it.
+   - The model **must support tool calling**, or the agent can never call a tool — filter on
+     [openrouter.ai/models](https://openrouter.ai/models?supported_parameters=tools)
    - Skip `:free` variants
-   - Watch for near-duplicates — plain `qwen/qwen3-235b-a22b-2507` is the Instruct one, not Thinking
-3. Check **Include Provider Preferences**:
-   - Type the provider name (`nebius`) and check it — this is what actually pins the request
-   - **Allow fallbacks → No.** Left on, a busy provider gets silently swapped and the pin is meaningless
-4. **Leave everything else blank.** Values here *override* every request — a max-tokens here becomes a hard ceiling on agent output
-5. Save, and repeat for the next pairing
+   - Watch near-duplicates (Instruct vs Thinking editions) — the right panel's id is the truth
+
+   ![Model picker — id and context in the right panel](images/openrouter-02-model-picker.png)
+
+3. **Provider Routing** — check **Include Provider Preferences**, then two settings inside it:
+   - Scroll to the checklist titled **`only`**. There are three look-alike provider checklists
+     (`order`, `only`, `ignore`) — **`only`** ("provider slugs to allow") is the one that pins.
+     Search your provider (`nebius` / `crusoe`) and tick it.
+
+     ![The only list with Nebius ticked](images/openrouter-03-only-provider.png)
+
+   - Keep scrolling to **Allow fallbacks** → **No**. Left on Yes, a busy provider is silently
+     swapped out and your pin is meaningless.
+
+     ![Allow fallbacks set to No](images/openrouter-04-allow-fallbacks-no.png)
+
+4. **Save Preset** (top right). The preset page must show, under **Provider Preferences**:
 
-**Validated pairings:**
+   ```json
+   { "only": ["nebius"], "allow_fallbacks": false }
+   ```
 
-| Preset | Model | Provider | Context |
+   ![Saved preset with the pinning JSON](images/openrouter-05-saved.png)
+
+**Touch nothing else.** Every value set in a preset (system prompt, temperature, max tokens)
+silently overrides *every* request the agent makes — a max-tokens here becomes a hard ceiling
+on agent output.
+
+**Validated pairings** (each verified end-to-end through the dev kit):
+
+| Preset | Model | Provider (`only`) | Context |
 | --- | --- | --- | --- |
+| `duplo-nebius-glm` | `z-ai/glm-5.1` | nebius | 204800 |
 | `duplo-nebius-qwen` | `qwen/qwen3-235b-a22b-2507` | nebius | 262144 |
+| `duplo-crusoe-glm` | `z-ai/glm-5.3` | crusoe | 1310720 |
 | `duplo-crusoe-kimi` | `moonshotai/kimi-k2.6` | crusoe | 262144 |
-| — | `z-ai/glm-5.3` | crusoe | 1310720 |
-| — | `openai/gpt-oss-120b` | nebius | 131072 |
 
 ### 3. Confirm the pin
 
-Check the preset works before pointing the agent at it — otherwise a bad preset looks like a dev-kit problem.
+Ten seconds now saves an hour later — a mis-pinned preset looks exactly like a dev-kit bug.
 
 ```bash
 curl -s https://openrouter.ai/api/v1/messages \
   -H "content-type: application/json" \
   -H "authorization: Bearer sk-or-v1-..." \
   -H "anthropic-version: 2023-06-01" \
-  -d '{"model":"@preset/duplo-nebius-qwen","max_tokens":50,
+  -d '{"model":"@preset/duplo-nebius-glm","max_tokens":50,
        "messages":[{"role":"user","content":"Say only OK"}]}'
 ```
 
-Look for `"provider"` matching what you pinned, and `"usage": {"is_byok": false}` while on credits.
+Look for `"provider"` matching what you pinned.
 
 | Result | Cause |
 | --- | --- |
-| Wrong provider | Model string isn't `@preset/`, or fallbacks left on |
-| `rate_limit_exceeded` / `upstream_provider_shared_pool` | That provider is saturated — retry, switch model, or go BYOK |
+| Wrong provider | Model string isn't `@preset/`, or Allow fallbacks is still Yes |
+| Error mentioning no allowed providers | The provider ticked under `only` doesn't serve that model |
+| `rate_limit_exceeded` / `upstream_provider_shared_pool` | Provider saturated — retry, switch model, or go BYOK |
 
 ### 4. Point the agent at it
 
-> **Already ran `./run.sh` and picked `anthropic` or `bedrock`?** That's the normal case — you don't need to start over. Run the script below to re-point the same platform at OpenRouter. Your existing Anthropic key / AWS keys are stashed in `.env`, not thrown away, so `./scripts/switch-llm.sh anthropic` (or `bedrock`) switches you back later without re-entering anything.
+Use the model's **Context** number from step 2 (or the pairings table) — the CLI assumes 200K
+for model ids it doesn't recognize, and if the real window is smaller the session hard-fails
+mid-run instead of compacting. Keep the compact window a bit under it.
+
+**Haven't run `./run.sh` yet?** Start on OpenRouter directly — pick option `3` (LLM gateway)
+at the provider prompt, or skip every prompt with flags:
 
 ```bash
-./scripts/switch-llm.sh gateway \
+./run.sh --model gateway \
   --gateway-url https://openrouter.ai/api \
   --gateway-token sk-or-v1-... \
-  --gateway-model @preset/duplo-nebius-qwen \
-  --gateway-max-context-tokens 262144 \
-  --gateway-compact-window 200000
+  --gateway-model @preset/duplo-nebius-glm \
+  --gateway-max-context-tokens 204800 \
+  --gateway-compact-window 160000
 ```
 
-Haven't run `./run.sh` yet? Start on OpenRouter instead of switching later.  During the execution of `./run.sh` you will be given the choise to use an LLM gateway. 
+**Already running on `anthropic` or `bedrock`?** That's the normal case — don't start over.
+This re-points the same platform at OpenRouter; your existing keys are stashed in `.env`, so
+`./scripts/switch-llm.sh anthropic` (or `bedrock`) switches back later without re-entering
+anything:
 
-> **Set the context window correctly.** The CLI assumes 200K for unrecognized model ids; if the real window is smaller the session hard-fails mid-run instead of compacting — that's what `--gateway-max-context-tokens`/`--gateway-compact-window` are for, and the table above has each model's real window.
+```bash
+./scripts/switch-llm.sh gateway \
+  --gateway-url https://openrouter.ai/api \
+  --gateway-token sk-or-v1-... \
+  --gateway-model @preset/duplo-nebius-glm \
+  --gateway-max-context-tokens 204800 \
+  --gateway-compact-window 160000
+```
 
-Verify in the UI: create a ticket and check the LLM picker now offers your preset (e.g. `@preset/duplo-nebius-qwen (LLM Gateway)`).
+**Verify:** `./scripts/switch-llm.sh status` prints `Active provider: gateway`. In the UI,
+create a ticket — the LLM picker offers your preset (e.g. `@preset/duplo-nebius-glm
+(LLM Gateway)`) — and the agent's reply on that ticket is served by your pinned provider.
 
 ---
 
diff --git a/hackday/images/openrouter-01-preset-name.png b/hackday/images/openrouter-01-preset-name.png
new file mode 100644
index 0000000..37eb24f
Binary files /dev/null and b/hackday/images/openrouter-01-preset-name.png differ
diff --git a/hackday/images/openrouter-02-model-picker.png b/hackday/images/openrouter-02-model-picker.png
new file mode 100644
index 0000000..b993e73
Binary files /dev/null and b/hackday/images/openrouter-02-model-picker.png differ
diff --git a/hackday/images/openrouter-03-only-provider.png b/hackday/images/openrouter-03-only-provider.png
new file mode 100644
index 0000000..f2da9e4
Binary files /dev/null and b/hackday/images/openrouter-03-only-provider.png differ
diff --git a/hackday/images/openrouter-04-allow-fallbacks-no.png b/hackday/images/openrouter-04-allow-fallbacks-no.png
new file mode 100644
index 0000000..1502890
Binary files /dev/null and b/hackday/images/openrouter-04-allow-fallbacks-no.png differ
diff --git a/hackday/images/openrouter-05-saved.png b/hackday/images/openrouter-05-saved.png
new file mode 100644
index 0000000..050dab4
Binary files /dev/null and b/hackday/images/openrouter-05-saved.png differ
diff --git a/nginx/default.conf b/nginx/default.conf
index 4c07c06..9202532 100644
--- a/nginx/default.conf
+++ b/nginx/default.conf
@@ -8,11 +8,6 @@ server {
     root /usr/share/nginx/html;
     index index.html;
 
-    # Usage-metrics opt-in/opt-out: adds or strips the UI's Mixpanel key in the served bundle.
-    # docker-compose mounts nginx/metrics-on.conf or metrics-off.conf here per the .env answer.
-    # See PRIVACY.md.
-    include /etc/nginx/metrics.conf;
-
     # Extension bundles (POST …/admin/extensions/load-bundle) can be large. The studio accepts up to
     # 1GB; without this nginx caps the proxied body at its 1MB default and returns 413 before it reaches
     # the studio. Match the studio's limit.
@@ -34,7 +29,16 @@ server {
     # MUST precede the remoteEntry no-cache block below: nginx matches regex locations in order, and that
     # block would otherwise swallow proxied extension FE bundles (/v1/.../fe/remoteEntry.json) and 404 them.
     location ~ ^/(v1|v3|api|admin|access|auth|healthz|swagger|signin-google)(/|$) {
-        proxy_pass http://duplo-ai-studio:60021;
+        # Resolve the studio per request rather than once at nginx startup. A literal hostname in
+        # proxy_pass is resolved a single time when the config loads and the IP is cached for the life
+        # of the container, so recreating the studio (any compose change to it OR to mongo, which it
+        # depends_on) strands this container proxying to a dead address — every API call 502s and the
+        # login page renders with no auth methods, since it is those 502s that decide what to show.
+        # Docker's embedded DNS at 127.0.0.11 plus a variable in proxy_pass forces a fresh lookup.
+        # $request_uri is required: a variable proxy_pass no longer forwards the URI implicitly.
+        resolver 127.0.0.11 valid=10s ipv6=off;
+        set $studio http://duplo-ai-studio:60021;
+        proxy_pass $studio$request_uri;
         proxy_http_version 1.1;
         proxy_set_header Host $http_host;
         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
diff --git a/nginx/metrics-off.conf b/nginx/metrics-off.conf
deleted file mode 100644
index 4557ba4..0000000
--- a/nginx/metrics-off.conf
+++ /dev/null
@@ -1,13 +0,0 @@
-# Usage metrics OFF — opted out. Mounted at /etc/nginx/metrics.conf by docker-compose when
-# METRICS_CONF=metrics-off.conf, and included by default.conf.
-#
-# Removes the baked-in Mixpanel key from the served bundle, so MixpanelService.initMixpanel()
-# sees an empty token and never calls mixpanel.init(). Nothing is posted, and the key does not
-# reach the browser at all. A no-op against an image whose key is already empty.
-#
-# The literal below is pinned to the key in the current UI_TAG. If duplo-ui ever rotates the token,
-# a UI_TAG bump would silently restore tracking for opted-out users. Re-verify when bumping UI_TAG:
-#   docker run --rm --entrypoint sh  -c 'grep -roh "mixpanelToken:\"[^\"]*\"" /usr/share/nginx/html | sort -u'
-sub_filter_types application/javascript text/javascript;
-sub_filter_once off;
-sub_filter '"24cdbeb56cb7efdd61a77ca27481af15"' '""';
diff --git a/nginx/metrics-on.conf b/nginx/metrics-on.conf
deleted file mode 100644
index 7c49f82..0000000
--- a/nginx/metrics-on.conf
+++ /dev/null
@@ -1,10 +0,0 @@
-# Usage metrics ON — opted in. Mounted at /etc/nginx/metrics.conf by docker-compose when
-# METRICS_CONF=metrics-on.conf, and included by default.conf.
-#
-# The UI image bakes the Mixpanel key into its Angular bundle at build time (angular.json swaps in
-# environment.prod.ts), so there is nothing to configure at runtime — we rewrite the served JS.
-# Images built from duplo-ui master carry an EMPTY key, so opting in means inserting it here.
-# Against an image that already carries the key this is a no-op (the empty-string pattern misses).
-sub_filter_types application/javascript text/javascript;
-sub_filter_once off;
-sub_filter 'mixpanelToken:""' 'mixpanelToken:"24cdbeb56cb7efdd61a77ca27481af15"';
diff --git a/packages/duplocloud-internal-ng-common-lib-0.2.0.tgz b/packages/duplocloud-internal-ng-common-lib-0.2.0.tgz
deleted file mode 100644
index d1bb71a..0000000
Binary files a/packages/duplocloud-internal-ng-common-lib-0.2.0.tgz and /dev/null differ
diff --git a/packages/duplocloud-internal-ng-common-lib-0.3.0.tgz b/packages/duplocloud-internal-ng-common-lib-0.3.0.tgz
new file mode 100644
index 0000000..32ecf3d
Binary files /dev/null and b/packages/duplocloud-internal-ng-common-lib-0.3.0.tgz differ
diff --git a/run.sh b/run.sh
index b07082c..410fab3 100755
--- a/run.sh
+++ b/run.sh
@@ -23,11 +23,15 @@
 #                                  this also replaces it: the saved request id can only verify the address it
 #                                  was made for, so a new address drops it and requests again. That is how you
 #                                  get out of a mistyped email whose verification link you never received.
-#   ./run.sh --no-metrics          opt out of usage metrics (default is opted in); --metrics opts back in.
-#   ./run.sh --email a@b.com --password 'pw' --model anthropic --anthropic-key sk-... --no-metrics   non-interactive.
+#   ./run.sh --email a@b.com --password 'pw' --model anthropic --anthropic-key sk-...   non-interactive.
 #   ./run.sh --model bedrock --aws-access-key-id AKIA... --aws-secret-access-key ... [--aws-session-token ...] [--aws-region us-west-2]
 #   ./run.sh --model bedrock-instance-role [--aws-region us-east-1]   Bedrock via this EC2 instance's IAM role (no keys).
-#   ./run.sh --model gateway --gateway-url https://openrouter.ai/api --gateway-token sk-or-... [--gateway-model anthropic/claude-sonnet-4.6]
+#   ./run.sh --model subscription --subscription-token sk-ant-oat01-...   run on your own Claude Code
+#                                  subscription instead of a billed API key (mint the token with
+#                                  `claude setup-token` on this machine). Local development only: it
+#                                  authenticates as you and counts against your Claude Code limits.
+#                                  Ticket titles are not generated on this path (title LLM is Bedrock-only).
+#   ./run.sh --model gateway --gateway-url https://openrouter.ai/api --gateway-token sk-or-... [--gateway-model anthropic/claude-sonnet-5]
 #                                  any Anthropic-compatible LLM gateway (OpenRouter, Bifrost, LiteLLM, …). Optional
 #                                  Sets a 262144-token context window and a 200000 compact threshold (a gateway
 #                                  usually serves model ids the CLI does not know, which otherwise have none);
@@ -46,8 +50,9 @@
 set -euo pipefail
 cd "$(dirname "$0")"
 ENV=.env
-. ./scripts/_metrics.sh
 . ./scripts/_provider_gateway.sh
+. ./scripts/_provider_subscription.sh
+. ./scripts/_studio_api.sh
 
 # ── flags ────────────────────────────────────────────────────────────────────
 RESET=0; RESET_LICENSE=0; NONINTERACTIVE=0
@@ -55,7 +60,8 @@ F_EMAIL=""; F_PASSWORD=""; F_MODEL=""; F_ANTHROPIC=""; F_LICENSE=""
 F_AWS_KEY=""; F_AWS_SECRET=""; F_AWS_TOKEN=""; F_AWS_REGION=""
 F_GATEWAY_URL=""; F_GATEWAY_TOKEN=""; F_GATEWAY_MODEL=""
 F_GATEWAY_DISABLE_BETAS=""; F_GATEWAY_MAX_CONTEXT=""; F_GATEWAY_COMPACT_WINDOW=""
-F_STUDIO_TAG=""; F_UI_TAG=""; F_AGENT_TAG=""; F_METRICS=""
+F_SUBSCRIPTION_TOKEN=""; F_SUBSCRIPTION_MODEL=""
+F_STUDIO_TAG=""; F_UI_TAG=""; F_AGENT_TAG=""
 while [ $# -gt 0 ]; do
   case "$1" in
     --reset) RESET=1 ;;
@@ -78,6 +84,8 @@ while [ $# -gt 0 ]; do
     --aws-secret-access-key) F_AWS_SECRET="$2"; shift ;;
     --aws-session-token) F_AWS_TOKEN="$2"; shift ;;
     --aws-region) F_AWS_REGION="$2"; shift ;;
+    --subscription-token) F_SUBSCRIPTION_TOKEN="$2"; shift ;;
+    --subscription-model) F_SUBSCRIPTION_MODEL="$2"; shift ;;
     --gateway-url) F_GATEWAY_URL="$2"; shift ;;
     --gateway-token) F_GATEWAY_TOKEN="$2"; shift ;;
     --gateway-model) F_GATEWAY_MODEL="$2"; shift ;;
@@ -87,8 +95,6 @@ while [ $# -gt 0 ]; do
     --studio-tag) F_STUDIO_TAG="$2"; shift ;;
     --ui-tag) F_UI_TAG="$2"; shift ;;
     --agent-tag) F_AGENT_TAG="$2"; shift ;;
-    --metrics) F_METRICS=1 ;;
-    --no-metrics) F_METRICS=0 ;;
     -h|--help) sed -n '2,/^[^#]/p' "$0" | grep -E '^#( |$)' | sed 's/^#//'; exit 0 ;;
     *) echo "Unknown flag: $1" >&2; exit 1 ;;
   esac
@@ -579,11 +585,10 @@ if [ "$RESET" = 1 ]; then
   docker compose down -v 2>/dev/null || true
   for k in Authentication__LocalAdminEmail Authentication__LocalAdminPassword Authentication__SuperUsers \
            DEVKIT_MODEL ANTHROPIC_API_KEY AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN \
-           "${GATEWAY_KEYS[@]}" \
+           CLAUDE_MODEL CLAUDE_EXTRA_MODELS "${GATEWAY_KEYS[@]}" "${SUBSCRIPTION_KEYS[@]}" \
            Encryption__MasterKey Authentication__JwtSharedSecret DUPLO_ADMIN_TOKEN EXTENSION_DEV_WORKSPACE_ID \
            EXTENSION_DEV_PERMSET_ID EXTENSION_DEV_PERMSETGROUP_ID \
-           QDRANT_PROVIDER_ID QDRANT_SCOPE_ID QDRANT_COLLECTION_ID \
-           DUPLO_USAGE_METRICS METRICS_CONF; do
+           QDRANT_PROVIDER_ID QDRANT_SCOPE_ID QDRANT_COLLECTION_ID; do
     setenv "$k" ""
   done
   # Also drop any _STASH_= lines scripts/switch-llm.sh left behind for a provider swap — a reset
@@ -637,7 +642,10 @@ resolve() { # flagval envkey prompt [secret]
 
 # The Bedrock model id the dev kit runs on — also what detect-bedrock.sh probes, so a successful
 # probe proves invoke permission on the exact model the agent will call (not just "some" Bedrock access).
-BEDROCK_PROBE_MODEL="us.anthropic.claude-sonnet-4-6"
+# BEDROCK_EXTRA_MODELS are offered alongside it in the ticket LLM picker (not probed — a missing Opus
+# inference profile shows up as a failed ticket, not a failed setup).
+BEDROCK_PROBE_MODEL="us.anthropic.claude-sonnet-5"
+BEDROCK_EXTRA_MODELS="us.anthropic.claude-opus-5"
 
 echo "==> Setup (prompts appear only for values not already set)…"
 # Very basic email sanity check: name@example.com (no spaces).
@@ -839,7 +847,7 @@ fi
 PASSWORD="$(resolve "$F_PASSWORD" Authentication__LocalAdminPassword 'Admin password' secret)"
 MODEL="$F_MODEL"; [ -z "$MODEL" ] && MODEL="$(getenv DEVKIT_MODEL)"
 if [ -z "$MODEL" ]; then
-  [ "$NONINTERACTIVE" = 1 ] && { echo "Missing DEVKIT_MODEL — pass --model 1|2|3|4|anthropic|bedrock|gateway|bedrock-instance-role." >&2; exit 1; }
+  [ "$NONINTERACTIVE" = 1 ] && { echo "Missing DEVKIT_MODEL — pass --model 1|2|3|4|5|anthropic|bedrock|gateway|bedrock-instance-role|subscription." >&2; exit 1; }
   # On an EC2 dev box the instance profile is usually already allowed to invoke Bedrock, in which
   # case no keys are needed at all — offer that as option 4 but never preselect it. Options 1–3 are
   # fixed regardless of the probe so `--model 3` always means the same thing on every machine.
@@ -872,9 +880,9 @@ if [ -z "$MODEL" ]; then
           printf '      could not confirm containers can reach IMDS (%s); if the agent later fails to\n' "${CONTAINER_IMDS#unknown:}" >&2
           printf '      authenticate, raise the IMDSv2 hop limit to 2 (see README).\n' >&2 ;;
     esac
-    printf 'Select LLM provider:\n  1) anthropic (API key)\n  2) bedrock (AWS keys)\n  3) LLM gateway (OpenRouter, Bifrost, LiteLLM, … — any Anthropic-compatible endpoint)\n  4) bedrock via this EC2 instance role — %s @ %s, no keys%s\n' \
+    printf 'Select LLM provider:\n  1) anthropic (API key)\n  2) bedrock (AWS keys)\n  3) LLM gateway (OpenRouter, Bifrost, LiteLLM, … — any Anthropic-compatible endpoint)\n  4) bedrock via this EC2 instance role — %s @ %s, no keys%s\n  5) Claude Code subscription (your own, via `claude setup-token`)\n' \
       "$AWS_ROLE" "$BEDROCK_REGION" "$IMDS_CAVEAT" >&2
-    read -r -p 'Enter 1, 2, 3 or 4: ' MODEL
+    read -r -p 'Enter 1, 2, 3, 4 or 5: ' MODEL
   else
     if [ "$BEDROCK_AVAILABLE" = 1 ]; then
       # Host reached IMDS but a container couldn't — almost always the IMDSv2 PUT-response hop limit
@@ -884,13 +892,13 @@ if [ -z "$MODEL" ]; then
     else
       echo "    ✗ no usable instance-role Bedrock access${BEDROCK_REASON:+ ($BEDROCK_REASON)}." >&2
     fi
-    printf 'Select LLM provider:\n  1) anthropic (API key)\n  2) bedrock (AWS keys)\n  3) LLM gateway (OpenRouter, Bifrost, LiteLLM, … — any Anthropic-compatible endpoint)\n' >&2
-    read -r -p 'Enter 1, 2 or 3: ' MODEL
+    printf 'Select LLM provider:\n  1) anthropic (API key)\n  2) bedrock (AWS keys)\n  3) LLM gateway (OpenRouter, Bifrost, LiteLLM, … — any Anthropic-compatible endpoint)\n  5) Claude Code subscription (your own, via `claude setup-token`)\n' >&2
+    read -r -p 'Enter 1, 2, 3 or 5: ' MODEL
   fi
 fi
 MODEL="$(printf '%s' "$MODEL" | tr '[:upper:]' '[:lower:]')"
 # accept numeric from menu/--model/.env
-case "$MODEL" in 1) MODEL=anthropic;; 2) MODEL=bedrock;; 3) MODEL=gateway;; 4) MODEL=bedrock-instance-role;; esac
+case "$MODEL" in 1) MODEL=anthropic;; 2) MODEL=bedrock;; 3) MODEL=gateway;; 4) MODEL=bedrock-instance-role;; 5) MODEL=subscription;; esac
 
 setenv Authentication__LocalAdminEmail "$EMAIL"
 setenv Authentication__LocalAdminPassword "$PASSWORD"
@@ -905,7 +913,8 @@ setenv AIStudio__IsMasterDisabled true
 if [ "$MODEL" = anthropic ]; then
   KEY="$(resolve "$F_ANTHROPIC" ANTHROPIC_API_KEY 'Anthropic API key' secret)"
   setenv ANTHROPIC_API_KEY "$KEY"
-  setenv CLAUDE_MODEL "claude-sonnet-4-6"
+  setenv CLAUDE_MODEL "claude-sonnet-5"
+  setenv CLAUDE_EXTRA_MODELS "claude-opus-5"
   # A gateway URL left over from a previous provider choice would make the agent send this key to the
   # gateway instead of api.anthropic.com (key + URL = "proxy in front of Anthropic"). Warn, don't clear.
   [ -z "$(getenv ANTHROPIC_BASE_URL)" ] || echo "    note: ANTHROPIC_BASE_URL is still set in .env — the agent will send your Anthropic key THERE, not to api.anthropic.com. Clear it (or ./run.sh --reset) unless that's intended."
@@ -954,6 +963,7 @@ elif [ "$MODEL" = bedrock-instance-role ]; then
   for k in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN ANTHROPIC_API_KEY ANTHROPIC_BASE_URL; do setenv "$k" ""; done
   setenv AWS_REGION "$RG"
   setenv CLAUDE_MODEL "$BEDROCK_PROBE_MODEL"
+  setenv CLAUDE_EXTRA_MODELS "$BEDROCK_EXTRA_MODELS"
   echo "    using EC2 instance role${AWS_ROLE:+ ($AWS_ROLE)} for Bedrock in $RG — no keys stored in .env."
 elif [ "$MODEL" = bedrock ]; then
   AK="$(resolve "$F_AWS_KEY" AWS_ACCESS_KEY_ID 'AWS access key id')"
@@ -961,7 +971,8 @@ elif [ "$MODEL" = bedrock ]; then
   ST="$F_AWS_TOKEN"; [ -z "$ST" ] && ST="$(getenv AWS_SESSION_TOKEN)"
   RG="$F_AWS_REGION"; [ -z "$RG" ] && RG="$(getenv AWS_REGION)"; [ -z "$RG" ] && RG="us-west-2"
   setenv AWS_ACCESS_KEY_ID "$AK"; setenv AWS_SECRET_ACCESS_KEY "$SK"; setenv AWS_SESSION_TOKEN "$ST"; setenv AWS_REGION "$RG"
-  setenv CLAUDE_MODEL "us.anthropic.claude-sonnet-4-6"
+  setenv CLAUDE_MODEL "$BEDROCK_PROBE_MODEL"
+  setenv CLAUDE_EXTRA_MODELS "$BEDROCK_EXTRA_MODELS"
   # The agent picks its provider by precedence ANTHROPIC_API_KEY → gateway → Azure → Bedrock (docker-compose.yml),
   # so a leftover Anthropic key silently wins over Bedrock. We no longer clear it (only --reset does) — warn.
   [ -z "$(getenv ANTHROPIC_API_KEY)" ] || echo "    note: ANTHROPIC_API_KEY is still set in .env — the agent prefers it over Bedrock. Clear it (or ./run.sh --reset) to force Bedrock."
@@ -969,23 +980,14 @@ elif [ "$MODEL" = bedrock ]; then
 elif [ "$MODEL" = gateway ]; then
   # Prompts, validation and the load-bearing ANTHROPIC_API_KEY blank all live in scripts/_provider_gateway.sh.
   provider_gateway_configure || exit 1
+elif [ "$MODEL" = subscription ]; then
+  # Prompt, validation and the load-bearing ANTHROPIC_API_KEY/ANTHROPIC_BASE_URL blanks live in
+  # scripts/_provider_subscription.sh.
+  provider_subscription_configure || exit 1
 else
-  echo "Unknown model '$MODEL' (use anthropic, bedrock, gateway, or bedrock-instance-role)." >&2; exit 1
+  echo "Unknown model '$MODEL' (use anthropic, bedrock, gateway, bedrock-instance-role, or subscription)." >&2; exit 1
 fi
 
-# ── usage metrics ────────────────────────────────────────────────────────────
-# Deliberately the LAST prompt: a consent question should stand on its own, not sit wedged between
-# "pick a provider" and "paste your API key".
-#
-# Opted in by default. Opting out mounts an nginx fragment that strips the Mixpanel key from the
-# served UI bundle, so the browser never receives it. Both keys are written on EVERY run: compose
-# falls back to metrics-off.conf when METRICS_CONF is unset or blank, so an opted-in user must have
-# it written explicitly. Re-deriving it every run also makes flipping DUPLO_USAGE_METRICS in .env by
-# hand and re-running a supported post-install opt-out.
-METRICS="$(metrics_resolve "$F_METRICS" "$(getenv DUPLO_USAGE_METRICS)" "$NONINTERACTIVE")"
-setenv DUPLO_USAGE_METRICS "$METRICS"
-setenv METRICS_CONF "$(metrics_conf_for "$METRICS")"
-
 # Optional image-tag overrides
 [ -n "$F_STUDIO_TAG" ] && setenv STUDIO_TAG "$F_STUDIO_TAG"
 [ -n "$F_UI_TAG" ] && setenv UI_TAG "$F_UI_TAG"
@@ -1048,9 +1050,10 @@ d=json.load(sys.stdin); items=d.get("data",{}); items=items.get("items",items) i
 print(next((w["id"] for w in (items or []) if w.get("name")=="extension-dev"), ""))' 2>/dev/null || true)"
   if [ -z "$WS" ]; then
     echo "==> Creating 'extension-dev' workspace…"
-    WS="$(curl -fsS --max-time 10 -X POST "$API/v1/aiservicedesk/admin/data/workspaces" \
-        -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" --data '{"name":"extension-dev"}' \
-        | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["id"])')"
+    # Everything after this needs the workspace, so a refused create (most often an expired license —
+    # the studio answers 400 license_limit_exceeded) stops here with the server's reason, not a traceback.
+    WS="$(studio_create_id "$API/v1/aiservicedesk/admin/data/workspaces" '{"name":"extension-dev"}')" \
+      || { echo "Could not create the extension-dev workspace — fix the cause above and re-run ./run.sh." >&2; exit 1; }
   fi
   setenv EXTENSION_DEV_WORKSPACE_ID "$WS"
 fi
@@ -1074,10 +1077,9 @@ if ! data_exists "$PS" permissionset; then
   PS="$(data_id_by_name permissionset extension-dev-access)"
   if [ -z "$PS" ]; then
     echo "==> Creating 'extension-dev-access' permission set…"
-    PS="$(curl -fsS --max-time 10 -X POST "$API/v1/aiservicedesk/admin/data/permissionset" \
-        -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
-        --data "$(W="$WS" python3 -c 'import json,os;print(json.dumps({"name":"extension-dev-access","allowedWorkspaces":[{"workspaceId":os.environ["W"]}]}))')" \
-        | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["id"])')"
+    PS="$(studio_create_id "$API/v1/aiservicedesk/admin/data/permissionset" \
+        "$(W="$WS" python3 -c 'import json,os;print(json.dumps({"name":"extension-dev-access","allowedWorkspaces":[{"workspaceId":os.environ["W"]}]}))')")" \
+      || echo "    (permission set not created — UI access to the workspace must be granted by hand)" >&2
   fi
   [ -n "$PS" ] && setenv EXTENSION_DEV_PERMSET_ID "$PS"
 fi
@@ -1086,10 +1088,9 @@ if ! data_exists "$PSG" permissionsetgroup; then
   PSG="$(data_id_by_name permissionsetgroup extension-dev-group)"
   if [ -z "$PSG" ]; then
     echo "==> Assigning $EMAIL to the permission set…"
-    PSG="$(curl -fsS --max-time 10 -X POST "$API/v1/aiservicedesk/admin/data/permissionsetgroup" \
-        -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
-        --data "$(P="$PS" E="$EMAIL" python3 -c 'import json,os;print(json.dumps({"name":"extension-dev-group","permissionSets":[os.environ["P"]],"userStringHandle":[os.environ["E"]]}))')" \
-        | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["id"])')"
+    PSG="$(studio_create_id "$API/v1/aiservicedesk/admin/data/permissionsetgroup" \
+        "$(P="$PS" E="$EMAIL" python3 -c 'import json,os;print(json.dumps({"name":"extension-dev-group","permissionSets":[os.environ["P"]],"userStringHandle":[os.environ["E"]]}))')")" \
+      || echo "    (permission set group not created — assign $EMAIL to the permission set by hand)" >&2
   fi
   [ -n "$PSG" ] && setenv EXTENSION_DEV_PERMSETGROUP_ID "$PSG"
 fi
@@ -1130,20 +1131,23 @@ fi
 
 # ── register the model the agent runs on as the System default (every provider) ───
 # The studio registers no LLM model of its own, so without this the ticket LLM picker has nothing to
-# offer whichever provider you picked. register-llm.sh reads CLAUDE_MODEL from .env — already set above
-# to the right id per provider (bare claude-* for direct Anthropic, us.anthropic.* for either Bedrock
-# mode) — so the same call registers the correct variant and makes it the sole System default.
+# offer whichever provider you picked. register-llm.sh reads CLAUDE_MODEL + CLAUDE_EXTRA_MODELS from .env
+# — already set above to the right ids per provider (bare claude-* for direct Anthropic, us.anthropic.*
+# for either Bedrock mode) — so the same call registers the correct variants, makes them the only System
+# models, and CLAUDE_MODEL the default.
 LLM_LINE=""
 case "$MODEL" in
   anthropic)             LLM_DESC="direct Anthropic" ;;
   bedrock)               LLM_DESC="AWS Bedrock" ;;
   gateway)               LLM_DESC="LLM Gateway" ;;
+  subscription)          LLM_DESC="Claude Code subscription" ;;
   bedrock-instance-role) LLM_DESC="AWS Bedrock via EC2 instance role" ;;
 esac
 echo "==> Registering $(getenv CLAUDE_MODEL) ($LLM_DESC) as the System default LLM…"
 if LLM_PROVIDER_LABEL="$LLM_DESC" ./scripts/register-llm.sh; then
   LLM_LINE="
   LLM       System default → $(getenv CLAUDE_MODEL) ($LLM_DESC)"
+  [ -z "$(getenv CLAUDE_EXTRA_MODELS)" ] || LLM_LINE="$LLM_LINE; also available: $(getenv CLAUDE_EXTRA_MODELS)"
 else
   echo "    (LLM registration failed — run ./scripts/register-llm.sh manually)"
 fi
@@ -1165,15 +1169,10 @@ case "$KB_RC" in
   *) echo "    (knowledge base setup failed — run ./scripts/register-qdrant.sh manually)" ;;
 esac
 
-if [ "$METRICS" = 1 ]; then
-  METRICS_STATE="on (opted in)"
-else
-  METRICS_STATE="off (opted out — the UI is served without the Mixpanel key)"
-fi
-
 PROVIDER_DESC="$MODEL"
 [ "$MODEL" = bedrock-instance-role ] && PROVIDER_DESC="bedrock via EC2 instance role${AWS_ROLE:+ ($AWS_ROLE)} @ $(getenv AWS_REGION) — no keys in .env"
 [ "$MODEL" = gateway ] && PROVIDER_DESC="LLM gateway @ $(getenv ANTHROPIC_BASE_URL)"
+[ "$MODEL" = subscription ] && PROVIDER_DESC="Claude Code subscription (model $(getenv CLAUDE_MODEL)) — your own token, local dev only"
 
 cat <               # your extensions live in extensions//
diff --git a/samples/helloworld/frontend/package-lock.json b/samples/helloworld/frontend/package-lock.json
index b369777..794b6e2 100644
--- a/samples/helloworld/frontend/package-lock.json
+++ b/samples/helloworld/frontend/package-lock.json
@@ -8,7 +8,7 @@
       "name": "duplo-extension-helloworld-remote",
       "version": "0.1.0",
       "dependencies": {
-        "@angular-architects/native-federation": "^22.0.6",
+        "@angular-architects/native-federation": "22.0.6",
         "@angular/cdk": "^22.1.0",
         "@angular/common": "^22.1.0",
         "@angular/compiler": "^22.1.0",
@@ -17,7 +17,7 @@
         "@angular/material": "^22.1.0",
         "@angular/platform-browser": "^22.1.0",
         "@angular/router": "^22.1.0",
-        "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.2.0.tgz",
+        "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.3.0.tgz",
         "@ng-bootstrap/ng-bootstrap": "^21.0.0",
         "@ng-select/ng-select": "^23.5.1",
         "@ngbracket/ngx-layout": "^22.0.1",
@@ -32,7 +32,7 @@
         "monaco-editor": "^0.55.1",
         "ngx-echarts": "^22.0.0",
         "ngx-markdown": "^22.0.0",
-        "ngx-monaco-editor-v2": "^22.0.4",
+        "ngx-monaco-editor-v2": "22.0.4",
         "ngx-toastr": "^20.0.5",
         "rxjs": "^7.8.0",
         "tslib": "^2.6.0",
@@ -45,6 +45,25 @@
         "typescript": "6.0.3"
       }
     },
+    "node_modules/@aiden0z/pptx-renderer": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@aiden0z/pptx-renderer/-/pptx-renderer-1.3.0.tgz",
+      "integrity": "sha512-VEXSn2vmdgTwWaw96bhsEyZiSxlrhF5O5dONI+dSBXmg1WH2XM9O1eyY2LFQ68J1SY+iZnE5BVgFGGX6iox9Og==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "echarts": "^6.0.0",
+        "jszip": "^3.10.1",
+        "mtx-decompressor": "^1.4.2"
+      },
+      "peerDependencies": {
+        "pdfjs-dist": ">=5 <7"
+      },
+      "peerDependenciesMeta": {
+        "pdfjs-dist": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@algolia/abtesting": {
       "version": "1.18.0",
       "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.0.tgz",
@@ -1874,13 +1893,14 @@
       }
     },
     "node_modules/@duplocloud-internal/ng-common-lib": {
-      "version": "0.2.0",
-      "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.2.0.tgz",
-      "integrity": "sha512-/69ZgZvvSNMoGO2GQnySpW7Upfd9zVmtNqFWTa/2oQHWcIIJ+M8JdBiv3jfeyk3kG6SfZHvdPD8OylfFLnjRng==",
+      "version": "0.3.0",
+      "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.3.0.tgz",
+      "integrity": "sha512-bJHET6Rxn07EExil8KomZu3ZkpXYixtccCm5GDDOLzM+Z8ajHtZVlskUlb51MlcihGYEl09dVJgCXRaHaYIPig==",
       "bundleDependencies": [
         "ng-block-ui"
       ],
       "dependencies": {
+        "@aiden0z/pptx-renderer": "^1.2.4",
         "@codemirror/lang-css": "^6.3.1",
         "@codemirror/lang-html": "^6.4.9",
         "@codemirror/lang-javascript": "^6.2.3",
@@ -1900,6 +1920,7 @@
         "cron-validate": "^1.4.3",
         "diff": "^9.0.0",
         "diff2html": "^3.4.48",
+        "docx-preview": "^0.4.0",
         "feather-icons": "^4.29.2",
         "file-saver": "^2.0.5",
         "lodash": "^4.17.20",
@@ -6037,6 +6058,12 @@
         "url": "https://opencollective.com/core-js"
       }
     },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+      "license": "MIT"
+    },
     "node_modules/cors": {
       "version": "2.8.6",
       "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -6186,6 +6213,15 @@
         "node": ">=0.3.1"
       }
     },
+    "node_modules/docx-preview": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.4.1.tgz",
+      "integrity": "sha512-Dv4g+LeE0swHpPvNC0lAT0YsR5Zq5pAxw7d3vHQ8EChBLgIrqqe9VPL5nVt2HBJap8bEZbXUI0TJob7kBeNU9w==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "jszip": ">=3.0.0"
+      }
+    },
     "node_modules/dom-serializer": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -7116,6 +7152,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/immediate": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+      "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+      "license": "MIT"
+    },
     "node_modules/immutable": {
       "version": "5.1.9",
       "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
@@ -7136,7 +7178,6 @@
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-      "dev": true,
       "license": "ISC"
     },
     "node_modules/ini": {
@@ -7245,6 +7286,12 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "license": "MIT"
+    },
     "node_modules/isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -7344,6 +7391,27 @@
       ],
       "license": "MIT"
     },
+    "node_modules/jszip": {
+      "version": "3.10.2",
+      "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
+      "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
+      "license": "(MIT OR GPL-3.0-or-later)",
+      "dependencies": {
+        "lie": "~3.3.0",
+        "pako": "~1.0.2",
+        "readable-stream": "~2.3.6",
+        "setimmediate": "^1.0.5"
+      }
+    },
+    "node_modules/lie": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+      "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "immediate": "~3.0.5"
+      }
+    },
     "node_modules/listr2": {
       "version": "10.2.1",
       "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",
@@ -7880,6 +7948,12 @@
         "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
       }
     },
+    "node_modules/mtx-decompressor": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mtx-decompressor/-/mtx-decompressor-1.6.0.tgz",
+      "integrity": "sha512-YJ+ULX3VTzDK/k+h35xCrFey/vMyNTdKflCUtX/DPurR3KdhHKXC5bhslLFPY0jyODrwNWJJxSqvnBiGATRsRQ==",
+      "license": "MPL-2.0"
+    },
     "node_modules/mute-stream": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz",
@@ -8450,6 +8524,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/pako": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+      "license": "(MIT AND Zlib)"
+    },
     "node_modules/parse5": {
       "version": "8.0.1",
       "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -8692,6 +8772,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "license": "MIT"
+    },
     "node_modules/property-expr": {
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
@@ -8779,6 +8865,21 @@
         "node": ">= 0.10"
       }
     },
+    "node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
     "node_modules/readdirp": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@@ -8932,6 +9033,12 @@
         "tslib": "^2.1.0"
       }
     },
+    "node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
     "node_modules/safer-buffer": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -9046,6 +9153,12 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/setimmediate": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+      "license": "MIT"
+    },
     "node_modules/setprototypeof": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -9374,6 +9487,15 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
     "node_modules/string-width": {
       "version": "8.2.2",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
@@ -9623,6 +9745,12 @@
         "browserslist": ">= 4.21.0"
       }
     },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
     "node_modules/validate-npm-package-name": {
       "version": "7.0.2",
       "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",
diff --git a/samples/helloworld/frontend/package.json b/samples/helloworld/frontend/package.json
index a644a35..9ba2bf5 100644
--- a/samples/helloworld/frontend/package.json
+++ b/samples/helloworld/frontend/package.json
@@ -25,7 +25,7 @@
     "@angular/material": "^22.1.0",
     "@angular/platform-browser": "^22.1.0",
     "@angular/router": "^22.1.0",
-    "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.2.0.tgz",
+    "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.3.0.tgz",
     "@ng-bootstrap/ng-bootstrap": "^21.0.0",
     "@ng-select/ng-select": "^23.5.1",
     "@ngbracket/ngx-layout": "^22.0.1",
@@ -40,7 +40,7 @@
     "monaco-editor": "^0.55.1",
     "ngx-echarts": "^22.0.0",
     "ngx-markdown": "^22.0.0",
-    "ngx-monaco-editor-v2": "^22.0.4",
+    "ngx-monaco-editor-v2": "22.0.4",
     "ngx-toastr": "^20.0.5",
     "rxjs": "^7.8.0",
     "tslib": "^2.6.0",
diff --git a/samples/network-stack/frontend/package-lock.json b/samples/network-stack/frontend/package-lock.json
index ebad66d..a6d02c2 100644
--- a/samples/network-stack/frontend/package-lock.json
+++ b/samples/network-stack/frontend/package-lock.json
@@ -8,7 +8,7 @@
       "name": "duplo-extension-network-stack-remote",
       "version": "0.1.0",
       "dependencies": {
-        "@angular-architects/native-federation": "^22.0.6",
+        "@angular-architects/native-federation": "22.0.6",
         "@angular/cdk": "^22.1.0",
         "@angular/common": "^22.1.0",
         "@angular/compiler": "^22.1.0",
@@ -17,7 +17,7 @@
         "@angular/material": "^22.1.0",
         "@angular/platform-browser": "^22.1.0",
         "@angular/router": "^22.1.0",
-        "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.2.0.tgz",
+        "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.3.0.tgz",
         "@ng-bootstrap/ng-bootstrap": "^21.0.0",
         "@ng-select/ng-select": "^23.5.1",
         "@ngbracket/ngx-layout": "^22.0.1",
@@ -32,7 +32,7 @@
         "monaco-editor": "^0.55.1",
         "ngx-echarts": "^22.0.0",
         "ngx-markdown": "^22.0.0",
-        "ngx-monaco-editor-v2": "^22.0.4",
+        "ngx-monaco-editor-v2": "22.0.4",
         "ngx-toastr": "^20.0.5",
         "rxjs": "^7.8.0",
         "tslib": "^2.6.0",
@@ -45,6 +45,25 @@
         "typescript": "6.0.3"
       }
     },
+    "node_modules/@aiden0z/pptx-renderer": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@aiden0z/pptx-renderer/-/pptx-renderer-1.3.0.tgz",
+      "integrity": "sha512-VEXSn2vmdgTwWaw96bhsEyZiSxlrhF5O5dONI+dSBXmg1WH2XM9O1eyY2LFQ68J1SY+iZnE5BVgFGGX6iox9Og==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "echarts": "^6.0.0",
+        "jszip": "^3.10.1",
+        "mtx-decompressor": "^1.4.2"
+      },
+      "peerDependencies": {
+        "pdfjs-dist": ">=5 <7"
+      },
+      "peerDependenciesMeta": {
+        "pdfjs-dist": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@algolia/abtesting": {
       "version": "1.18.0",
       "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.0.tgz",
@@ -1874,13 +1893,14 @@
       }
     },
     "node_modules/@duplocloud-internal/ng-common-lib": {
-      "version": "0.2.0",
-      "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.2.0.tgz",
-      "integrity": "sha512-/69ZgZvvSNMoGO2GQnySpW7Upfd9zVmtNqFWTa/2oQHWcIIJ+M8JdBiv3jfeyk3kG6SfZHvdPD8OylfFLnjRng==",
+      "version": "0.3.0",
+      "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.3.0.tgz",
+      "integrity": "sha512-bJHET6Rxn07EExil8KomZu3ZkpXYixtccCm5GDDOLzM+Z8ajHtZVlskUlb51MlcihGYEl09dVJgCXRaHaYIPig==",
       "bundleDependencies": [
         "ng-block-ui"
       ],
       "dependencies": {
+        "@aiden0z/pptx-renderer": "^1.2.4",
         "@codemirror/lang-css": "^6.3.1",
         "@codemirror/lang-html": "^6.4.9",
         "@codemirror/lang-javascript": "^6.2.3",
@@ -1900,6 +1920,7 @@
         "cron-validate": "^1.4.3",
         "diff": "^9.0.0",
         "diff2html": "^3.4.48",
+        "docx-preview": "^0.4.0",
         "feather-icons": "^4.29.2",
         "file-saver": "^2.0.5",
         "lodash": "^4.17.20",
@@ -6037,6 +6058,12 @@
         "url": "https://opencollective.com/core-js"
       }
     },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+      "license": "MIT"
+    },
     "node_modules/cors": {
       "version": "2.8.6",
       "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -6186,6 +6213,15 @@
         "node": ">=0.3.1"
       }
     },
+    "node_modules/docx-preview": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.4.1.tgz",
+      "integrity": "sha512-Dv4g+LeE0swHpPvNC0lAT0YsR5Zq5pAxw7d3vHQ8EChBLgIrqqe9VPL5nVt2HBJap8bEZbXUI0TJob7kBeNU9w==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "jszip": ">=3.0.0"
+      }
+    },
     "node_modules/dom-serializer": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -7116,6 +7152,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/immediate": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+      "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+      "license": "MIT"
+    },
     "node_modules/immutable": {
       "version": "5.1.9",
       "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
@@ -7136,7 +7178,6 @@
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-      "dev": true,
       "license": "ISC"
     },
     "node_modules/ini": {
@@ -7245,6 +7286,12 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "license": "MIT"
+    },
     "node_modules/isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -7344,6 +7391,27 @@
       ],
       "license": "MIT"
     },
+    "node_modules/jszip": {
+      "version": "3.10.2",
+      "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
+      "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
+      "license": "(MIT OR GPL-3.0-or-later)",
+      "dependencies": {
+        "lie": "~3.3.0",
+        "pako": "~1.0.2",
+        "readable-stream": "~2.3.6",
+        "setimmediate": "^1.0.5"
+      }
+    },
+    "node_modules/lie": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+      "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "immediate": "~3.0.5"
+      }
+    },
     "node_modules/listr2": {
       "version": "10.2.1",
       "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",
@@ -7880,6 +7948,12 @@
         "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
       }
     },
+    "node_modules/mtx-decompressor": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mtx-decompressor/-/mtx-decompressor-1.6.0.tgz",
+      "integrity": "sha512-YJ+ULX3VTzDK/k+h35xCrFey/vMyNTdKflCUtX/DPurR3KdhHKXC5bhslLFPY0jyODrwNWJJxSqvnBiGATRsRQ==",
+      "license": "MPL-2.0"
+    },
     "node_modules/mute-stream": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz",
@@ -8450,6 +8524,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/pako": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+      "license": "(MIT AND Zlib)"
+    },
     "node_modules/parse5": {
       "version": "8.0.1",
       "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -8692,6 +8772,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "license": "MIT"
+    },
     "node_modules/property-expr": {
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
@@ -8779,6 +8865,21 @@
         "node": ">= 0.10"
       }
     },
+    "node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
     "node_modules/readdirp": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@@ -8932,6 +9033,12 @@
         "tslib": "^2.1.0"
       }
     },
+    "node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
     "node_modules/safer-buffer": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -9046,6 +9153,12 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/setimmediate": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+      "license": "MIT"
+    },
     "node_modules/setprototypeof": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -9374,6 +9487,15 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
     "node_modules/string-width": {
       "version": "8.2.2",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
@@ -9623,6 +9745,12 @@
         "browserslist": ">= 4.21.0"
       }
     },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
     "node_modules/validate-npm-package-name": {
       "version": "7.0.2",
       "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",
diff --git a/samples/network-stack/frontend/package.json b/samples/network-stack/frontend/package.json
index 54e6e5b..7936475 100644
--- a/samples/network-stack/frontend/package.json
+++ b/samples/network-stack/frontend/package.json
@@ -25,7 +25,7 @@
     "@angular/material": "^22.1.0",
     "@angular/platform-browser": "^22.1.0",
     "@angular/router": "^22.1.0",
-    "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.2.0.tgz",
+    "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.3.0.tgz",
     "@ng-bootstrap/ng-bootstrap": "^21.0.0",
     "@ng-select/ng-select": "^23.5.1",
     "@ngbracket/ngx-layout": "^22.0.1",
@@ -40,7 +40,7 @@
     "monaco-editor": "^0.55.1",
     "ngx-echarts": "^22.0.0",
     "ngx-markdown": "^22.0.0",
-    "ngx-monaco-editor-v2": "^22.0.4",
+    "ngx-monaco-editor-v2": "22.0.4",
     "ngx-toastr": "^20.0.5",
     "rxjs": "^7.8.0",
     "tslib": "^2.6.0",
diff --git a/samples/parent-child/frontend/package-lock.json b/samples/parent-child/frontend/package-lock.json
index 48466e7..bb84011 100644
--- a/samples/parent-child/frontend/package-lock.json
+++ b/samples/parent-child/frontend/package-lock.json
@@ -8,7 +8,7 @@
       "name": "duplo-extension-parentchild-remote",
       "version": "0.1.0",
       "dependencies": {
-        "@angular-architects/native-federation": "^22.0.6",
+        "@angular-architects/native-federation": "22.0.6",
         "@angular/cdk": "^22.1.0",
         "@angular/common": "^22.1.0",
         "@angular/compiler": "^22.1.0",
@@ -17,7 +17,7 @@
         "@angular/material": "^22.1.0",
         "@angular/platform-browser": "^22.1.0",
         "@angular/router": "^22.1.0",
-        "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.2.0.tgz",
+        "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.3.0.tgz",
         "@ng-bootstrap/ng-bootstrap": "^21.0.0",
         "@ng-select/ng-select": "^23.5.1",
         "@ngbracket/ngx-layout": "^22.0.1",
@@ -32,7 +32,7 @@
         "monaco-editor": "^0.55.1",
         "ngx-echarts": "^22.0.0",
         "ngx-markdown": "^22.0.0",
-        "ngx-monaco-editor-v2": "^22.0.4",
+        "ngx-monaco-editor-v2": "22.0.4",
         "ngx-toastr": "^20.0.5",
         "rxjs": "^7.8.0",
         "tslib": "^2.6.0",
@@ -45,6 +45,25 @@
         "typescript": "6.0.3"
       }
     },
+    "node_modules/@aiden0z/pptx-renderer": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@aiden0z/pptx-renderer/-/pptx-renderer-1.3.0.tgz",
+      "integrity": "sha512-VEXSn2vmdgTwWaw96bhsEyZiSxlrhF5O5dONI+dSBXmg1WH2XM9O1eyY2LFQ68J1SY+iZnE5BVgFGGX6iox9Og==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "echarts": "^6.0.0",
+        "jszip": "^3.10.1",
+        "mtx-decompressor": "^1.4.2"
+      },
+      "peerDependencies": {
+        "pdfjs-dist": ">=5 <7"
+      },
+      "peerDependenciesMeta": {
+        "pdfjs-dist": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@algolia/abtesting": {
       "version": "1.18.0",
       "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.0.tgz",
@@ -1874,13 +1893,14 @@
       }
     },
     "node_modules/@duplocloud-internal/ng-common-lib": {
-      "version": "0.2.0",
-      "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.2.0.tgz",
-      "integrity": "sha512-/69ZgZvvSNMoGO2GQnySpW7Upfd9zVmtNqFWTa/2oQHWcIIJ+M8JdBiv3jfeyk3kG6SfZHvdPD8OylfFLnjRng==",
+      "version": "0.3.0",
+      "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.3.0.tgz",
+      "integrity": "sha512-bJHET6Rxn07EExil8KomZu3ZkpXYixtccCm5GDDOLzM+Z8ajHtZVlskUlb51MlcihGYEl09dVJgCXRaHaYIPig==",
       "bundleDependencies": [
         "ng-block-ui"
       ],
       "dependencies": {
+        "@aiden0z/pptx-renderer": "^1.2.4",
         "@codemirror/lang-css": "^6.3.1",
         "@codemirror/lang-html": "^6.4.9",
         "@codemirror/lang-javascript": "^6.2.3",
@@ -1900,6 +1920,7 @@
         "cron-validate": "^1.4.3",
         "diff": "^9.0.0",
         "diff2html": "^3.4.48",
+        "docx-preview": "^0.4.0",
         "feather-icons": "^4.29.2",
         "file-saver": "^2.0.5",
         "lodash": "^4.17.20",
@@ -6037,6 +6058,12 @@
         "url": "https://opencollective.com/core-js"
       }
     },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+      "license": "MIT"
+    },
     "node_modules/cors": {
       "version": "2.8.6",
       "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -6186,6 +6213,15 @@
         "node": ">=0.3.1"
       }
     },
+    "node_modules/docx-preview": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.4.1.tgz",
+      "integrity": "sha512-Dv4g+LeE0swHpPvNC0lAT0YsR5Zq5pAxw7d3vHQ8EChBLgIrqqe9VPL5nVt2HBJap8bEZbXUI0TJob7kBeNU9w==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "jszip": ">=3.0.0"
+      }
+    },
     "node_modules/dom-serializer": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -7116,6 +7152,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/immediate": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+      "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+      "license": "MIT"
+    },
     "node_modules/immutable": {
       "version": "5.1.9",
       "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
@@ -7136,7 +7178,6 @@
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-      "dev": true,
       "license": "ISC"
     },
     "node_modules/ini": {
@@ -7245,6 +7286,12 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "license": "MIT"
+    },
     "node_modules/isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -7344,6 +7391,27 @@
       ],
       "license": "MIT"
     },
+    "node_modules/jszip": {
+      "version": "3.10.2",
+      "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
+      "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
+      "license": "(MIT OR GPL-3.0-or-later)",
+      "dependencies": {
+        "lie": "~3.3.0",
+        "pako": "~1.0.2",
+        "readable-stream": "~2.3.6",
+        "setimmediate": "^1.0.5"
+      }
+    },
+    "node_modules/lie": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+      "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "immediate": "~3.0.5"
+      }
+    },
     "node_modules/listr2": {
       "version": "10.2.1",
       "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",
@@ -7880,6 +7948,12 @@
         "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
       }
     },
+    "node_modules/mtx-decompressor": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mtx-decompressor/-/mtx-decompressor-1.6.0.tgz",
+      "integrity": "sha512-YJ+ULX3VTzDK/k+h35xCrFey/vMyNTdKflCUtX/DPurR3KdhHKXC5bhslLFPY0jyODrwNWJJxSqvnBiGATRsRQ==",
+      "license": "MPL-2.0"
+    },
     "node_modules/mute-stream": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz",
@@ -8450,6 +8524,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/pako": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+      "license": "(MIT AND Zlib)"
+    },
     "node_modules/parse5": {
       "version": "8.0.1",
       "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -8692,6 +8772,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "license": "MIT"
+    },
     "node_modules/property-expr": {
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
@@ -8779,6 +8865,21 @@
         "node": ">= 0.10"
       }
     },
+    "node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
     "node_modules/readdirp": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@@ -8932,6 +9033,12 @@
         "tslib": "^2.1.0"
       }
     },
+    "node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
     "node_modules/safer-buffer": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -9046,6 +9153,12 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/setimmediate": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+      "license": "MIT"
+    },
     "node_modules/setprototypeof": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -9374,6 +9487,15 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
     "node_modules/string-width": {
       "version": "8.2.2",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
@@ -9623,6 +9745,12 @@
         "browserslist": ">= 4.21.0"
       }
     },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
     "node_modules/validate-npm-package-name": {
       "version": "7.0.2",
       "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",
diff --git a/samples/parent-child/frontend/package.json b/samples/parent-child/frontend/package.json
index 672d0ae..4d0e8c1 100644
--- a/samples/parent-child/frontend/package.json
+++ b/samples/parent-child/frontend/package.json
@@ -25,7 +25,7 @@
     "@angular/material": "^22.1.0",
     "@angular/platform-browser": "^22.1.0",
     "@angular/router": "^22.1.0",
-    "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.2.0.tgz",
+    "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.3.0.tgz",
     "@ng-bootstrap/ng-bootstrap": "^21.0.0",
     "@ng-select/ng-select": "^23.5.1",
     "@ngbracket/ngx-layout": "^22.0.1",
@@ -40,7 +40,7 @@
     "monaco-editor": "^0.55.1",
     "ngx-echarts": "^22.0.0",
     "ngx-markdown": "^22.0.0",
-    "ngx-monaco-editor-v2": "^22.0.4",
+    "ngx-monaco-editor-v2": "22.0.4",
     "ngx-toastr": "^20.0.5",
     "rxjs": "^7.8.0",
     "tslib": "^2.6.0",
diff --git a/samples/passthrough-configmap/frontend/package-lock.json b/samples/passthrough-configmap/frontend/package-lock.json
index f4a6c87..837f47c 100644
--- a/samples/passthrough-configmap/frontend/package-lock.json
+++ b/samples/passthrough-configmap/frontend/package-lock.json
@@ -8,7 +8,7 @@
       "name": "duplo-extension-passthrough-configmap-remote",
       "version": "0.1.0",
       "dependencies": {
-        "@angular-architects/native-federation": "^22.0.6",
+        "@angular-architects/native-federation": "22.0.6",
         "@angular/cdk": "^22.1.0",
         "@angular/common": "^22.1.0",
         "@angular/compiler": "^22.1.0",
@@ -17,7 +17,7 @@
         "@angular/material": "^22.1.0",
         "@angular/platform-browser": "^22.1.0",
         "@angular/router": "^22.1.0",
-        "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.2.0.tgz",
+        "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.3.0.tgz",
         "@ng-bootstrap/ng-bootstrap": "^21.0.0",
         "@ng-select/ng-select": "^23.5.1",
         "@ngbracket/ngx-layout": "^22.0.1",
@@ -32,7 +32,7 @@
         "monaco-editor": "^0.55.1",
         "ngx-echarts": "^22.0.0",
         "ngx-markdown": "^22.0.0",
-        "ngx-monaco-editor-v2": "^22.0.4",
+        "ngx-monaco-editor-v2": "22.0.4",
         "ngx-toastr": "^20.0.5",
         "rxjs": "^7.8.0",
         "tslib": "^2.6.0",
@@ -45,6 +45,25 @@
         "typescript": "6.0.3"
       }
     },
+    "node_modules/@aiden0z/pptx-renderer": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@aiden0z/pptx-renderer/-/pptx-renderer-1.3.0.tgz",
+      "integrity": "sha512-VEXSn2vmdgTwWaw96bhsEyZiSxlrhF5O5dONI+dSBXmg1WH2XM9O1eyY2LFQ68J1SY+iZnE5BVgFGGX6iox9Og==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "echarts": "^6.0.0",
+        "jszip": "^3.10.1",
+        "mtx-decompressor": "^1.4.2"
+      },
+      "peerDependencies": {
+        "pdfjs-dist": ">=5 <7"
+      },
+      "peerDependenciesMeta": {
+        "pdfjs-dist": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@algolia/abtesting": {
       "version": "1.18.0",
       "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.0.tgz",
@@ -1874,13 +1893,14 @@
       }
     },
     "node_modules/@duplocloud-internal/ng-common-lib": {
-      "version": "0.2.0",
-      "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.2.0.tgz",
-      "integrity": "sha512-/69ZgZvvSNMoGO2GQnySpW7Upfd9zVmtNqFWTa/2oQHWcIIJ+M8JdBiv3jfeyk3kG6SfZHvdPD8OylfFLnjRng==",
+      "version": "0.3.0",
+      "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.3.0.tgz",
+      "integrity": "sha512-bJHET6Rxn07EExil8KomZu3ZkpXYixtccCm5GDDOLzM+Z8ajHtZVlskUlb51MlcihGYEl09dVJgCXRaHaYIPig==",
       "bundleDependencies": [
         "ng-block-ui"
       ],
       "dependencies": {
+        "@aiden0z/pptx-renderer": "^1.2.4",
         "@codemirror/lang-css": "^6.3.1",
         "@codemirror/lang-html": "^6.4.9",
         "@codemirror/lang-javascript": "^6.2.3",
@@ -1900,6 +1920,7 @@
         "cron-validate": "^1.4.3",
         "diff": "^9.0.0",
         "diff2html": "^3.4.48",
+        "docx-preview": "^0.4.0",
         "feather-icons": "^4.29.2",
         "file-saver": "^2.0.5",
         "lodash": "^4.17.20",
@@ -6037,6 +6058,12 @@
         "url": "https://opencollective.com/core-js"
       }
     },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+      "license": "MIT"
+    },
     "node_modules/cors": {
       "version": "2.8.6",
       "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -6186,6 +6213,15 @@
         "node": ">=0.3.1"
       }
     },
+    "node_modules/docx-preview": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.4.1.tgz",
+      "integrity": "sha512-Dv4g+LeE0swHpPvNC0lAT0YsR5Zq5pAxw7d3vHQ8EChBLgIrqqe9VPL5nVt2HBJap8bEZbXUI0TJob7kBeNU9w==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "jszip": ">=3.0.0"
+      }
+    },
     "node_modules/dom-serializer": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -7116,6 +7152,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/immediate": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+      "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+      "license": "MIT"
+    },
     "node_modules/immutable": {
       "version": "5.1.9",
       "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
@@ -7136,7 +7178,6 @@
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-      "dev": true,
       "license": "ISC"
     },
     "node_modules/ini": {
@@ -7245,6 +7286,12 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "license": "MIT"
+    },
     "node_modules/isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -7344,6 +7391,27 @@
       ],
       "license": "MIT"
     },
+    "node_modules/jszip": {
+      "version": "3.10.2",
+      "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
+      "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
+      "license": "(MIT OR GPL-3.0-or-later)",
+      "dependencies": {
+        "lie": "~3.3.0",
+        "pako": "~1.0.2",
+        "readable-stream": "~2.3.6",
+        "setimmediate": "^1.0.5"
+      }
+    },
+    "node_modules/lie": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+      "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "immediate": "~3.0.5"
+      }
+    },
     "node_modules/listr2": {
       "version": "10.2.1",
       "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",
@@ -7880,6 +7948,12 @@
         "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
       }
     },
+    "node_modules/mtx-decompressor": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mtx-decompressor/-/mtx-decompressor-1.6.0.tgz",
+      "integrity": "sha512-YJ+ULX3VTzDK/k+h35xCrFey/vMyNTdKflCUtX/DPurR3KdhHKXC5bhslLFPY0jyODrwNWJJxSqvnBiGATRsRQ==",
+      "license": "MPL-2.0"
+    },
     "node_modules/mute-stream": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz",
@@ -8450,6 +8524,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/pako": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+      "license": "(MIT AND Zlib)"
+    },
     "node_modules/parse5": {
       "version": "8.0.1",
       "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -8692,6 +8772,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "license": "MIT"
+    },
     "node_modules/property-expr": {
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
@@ -8779,6 +8865,21 @@
         "node": ">= 0.10"
       }
     },
+    "node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
     "node_modules/readdirp": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@@ -8932,6 +9033,12 @@
         "tslib": "^2.1.0"
       }
     },
+    "node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
     "node_modules/safer-buffer": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -9046,6 +9153,12 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/setimmediate": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+      "license": "MIT"
+    },
     "node_modules/setprototypeof": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -9374,6 +9487,15 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
     "node_modules/string-width": {
       "version": "8.2.2",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
@@ -9623,6 +9745,12 @@
         "browserslist": ">= 4.21.0"
       }
     },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
     "node_modules/validate-npm-package-name": {
       "version": "7.0.2",
       "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",
diff --git a/samples/passthrough-configmap/frontend/package.json b/samples/passthrough-configmap/frontend/package.json
index 3e03c47..3e5b9e9 100644
--- a/samples/passthrough-configmap/frontend/package.json
+++ b/samples/passthrough-configmap/frontend/package.json
@@ -25,7 +25,7 @@
     "@angular/material": "^22.1.0",
     "@angular/platform-browser": "^22.1.0",
     "@angular/router": "^22.1.0",
-    "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.2.0.tgz",
+    "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.3.0.tgz",
     "@ng-bootstrap/ng-bootstrap": "^21.0.0",
     "@ng-select/ng-select": "^23.5.1",
     "@ngbracket/ngx-layout": "^22.0.1",
@@ -40,7 +40,7 @@
     "monaco-editor": "^0.55.1",
     "ngx-echarts": "^22.0.0",
     "ngx-markdown": "^22.0.0",
-    "ngx-monaco-editor-v2": "^22.0.4",
+    "ngx-monaco-editor-v2": "22.0.4",
     "ngx-toastr": "^20.0.5",
     "rxjs": "^7.8.0",
     "tslib": "^2.6.0",
diff --git a/samples/worker-appstack/frontend/package-lock.json b/samples/worker-appstack/frontend/package-lock.json
index 719f88e..ee93081 100644
--- a/samples/worker-appstack/frontend/package-lock.json
+++ b/samples/worker-appstack/frontend/package-lock.json
@@ -8,7 +8,7 @@
       "name": "duplo-extension-worker-appstack-remote",
       "version": "0.1.0",
       "dependencies": {
-        "@angular-architects/native-federation": "^22.0.6",
+        "@angular-architects/native-federation": "22.0.6",
         "@angular/cdk": "^22.1.0",
         "@angular/common": "^22.1.0",
         "@angular/compiler": "^22.1.0",
@@ -17,7 +17,7 @@
         "@angular/material": "^22.1.0",
         "@angular/platform-browser": "^22.1.0",
         "@angular/router": "^22.1.0",
-        "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.2.0.tgz",
+        "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.3.0.tgz",
         "@ng-bootstrap/ng-bootstrap": "^21.0.0",
         "@ng-select/ng-select": "^23.5.1",
         "@ngbracket/ngx-layout": "^22.0.1",
@@ -32,7 +32,7 @@
         "monaco-editor": "^0.55.1",
         "ngx-echarts": "^22.0.0",
         "ngx-markdown": "^22.0.0",
-        "ngx-monaco-editor-v2": "^22.0.4",
+        "ngx-monaco-editor-v2": "22.0.4",
         "ngx-toastr": "^20.0.5",
         "rxjs": "^7.8.0",
         "tslib": "^2.6.0",
@@ -45,6 +45,25 @@
         "typescript": "6.0.3"
       }
     },
+    "node_modules/@aiden0z/pptx-renderer": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@aiden0z/pptx-renderer/-/pptx-renderer-1.3.0.tgz",
+      "integrity": "sha512-VEXSn2vmdgTwWaw96bhsEyZiSxlrhF5O5dONI+dSBXmg1WH2XM9O1eyY2LFQ68J1SY+iZnE5BVgFGGX6iox9Og==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "echarts": "^6.0.0",
+        "jszip": "^3.10.1",
+        "mtx-decompressor": "^1.4.2"
+      },
+      "peerDependencies": {
+        "pdfjs-dist": ">=5 <7"
+      },
+      "peerDependenciesMeta": {
+        "pdfjs-dist": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@algolia/abtesting": {
       "version": "1.18.0",
       "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.0.tgz",
@@ -1874,13 +1893,14 @@
       }
     },
     "node_modules/@duplocloud-internal/ng-common-lib": {
-      "version": "0.2.0",
-      "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.2.0.tgz",
-      "integrity": "sha512-/69ZgZvvSNMoGO2GQnySpW7Upfd9zVmtNqFWTa/2oQHWcIIJ+M8JdBiv3jfeyk3kG6SfZHvdPD8OylfFLnjRng==",
+      "version": "0.3.0",
+      "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.3.0.tgz",
+      "integrity": "sha512-bJHET6Rxn07EExil8KomZu3ZkpXYixtccCm5GDDOLzM+Z8ajHtZVlskUlb51MlcihGYEl09dVJgCXRaHaYIPig==",
       "bundleDependencies": [
         "ng-block-ui"
       ],
       "dependencies": {
+        "@aiden0z/pptx-renderer": "^1.2.4",
         "@codemirror/lang-css": "^6.3.1",
         "@codemirror/lang-html": "^6.4.9",
         "@codemirror/lang-javascript": "^6.2.3",
@@ -1900,6 +1920,7 @@
         "cron-validate": "^1.4.3",
         "diff": "^9.0.0",
         "diff2html": "^3.4.48",
+        "docx-preview": "^0.4.0",
         "feather-icons": "^4.29.2",
         "file-saver": "^2.0.5",
         "lodash": "^4.17.20",
@@ -6037,6 +6058,12 @@
         "url": "https://opencollective.com/core-js"
       }
     },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+      "license": "MIT"
+    },
     "node_modules/cors": {
       "version": "2.8.6",
       "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -6186,6 +6213,15 @@
         "node": ">=0.3.1"
       }
     },
+    "node_modules/docx-preview": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.4.1.tgz",
+      "integrity": "sha512-Dv4g+LeE0swHpPvNC0lAT0YsR5Zq5pAxw7d3vHQ8EChBLgIrqqe9VPL5nVt2HBJap8bEZbXUI0TJob7kBeNU9w==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "jszip": ">=3.0.0"
+      }
+    },
     "node_modules/dom-serializer": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -7116,6 +7152,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/immediate": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+      "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+      "license": "MIT"
+    },
     "node_modules/immutable": {
       "version": "5.1.9",
       "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
@@ -7136,7 +7178,6 @@
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-      "dev": true,
       "license": "ISC"
     },
     "node_modules/ini": {
@@ -7245,6 +7286,12 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "license": "MIT"
+    },
     "node_modules/isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -7344,6 +7391,27 @@
       ],
       "license": "MIT"
     },
+    "node_modules/jszip": {
+      "version": "3.10.2",
+      "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
+      "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
+      "license": "(MIT OR GPL-3.0-or-later)",
+      "dependencies": {
+        "lie": "~3.3.0",
+        "pako": "~1.0.2",
+        "readable-stream": "~2.3.6",
+        "setimmediate": "^1.0.5"
+      }
+    },
+    "node_modules/lie": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+      "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "immediate": "~3.0.5"
+      }
+    },
     "node_modules/listr2": {
       "version": "10.2.1",
       "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",
@@ -7880,6 +7948,12 @@
         "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
       }
     },
+    "node_modules/mtx-decompressor": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mtx-decompressor/-/mtx-decompressor-1.6.0.tgz",
+      "integrity": "sha512-YJ+ULX3VTzDK/k+h35xCrFey/vMyNTdKflCUtX/DPurR3KdhHKXC5bhslLFPY0jyODrwNWJJxSqvnBiGATRsRQ==",
+      "license": "MPL-2.0"
+    },
     "node_modules/mute-stream": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz",
@@ -8450,6 +8524,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/pako": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+      "license": "(MIT AND Zlib)"
+    },
     "node_modules/parse5": {
       "version": "8.0.1",
       "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -8692,6 +8772,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "license": "MIT"
+    },
     "node_modules/property-expr": {
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
@@ -8779,6 +8865,21 @@
         "node": ">= 0.10"
       }
     },
+    "node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
     "node_modules/readdirp": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@@ -8932,6 +9033,12 @@
         "tslib": "^2.1.0"
       }
     },
+    "node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
     "node_modules/safer-buffer": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -9046,6 +9153,12 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/setimmediate": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+      "license": "MIT"
+    },
     "node_modules/setprototypeof": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -9374,6 +9487,15 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
     "node_modules/string-width": {
       "version": "8.2.2",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
@@ -9623,6 +9745,12 @@
         "browserslist": ">= 4.21.0"
       }
     },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
     "node_modules/validate-npm-package-name": {
       "version": "7.0.2",
       "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",
diff --git a/samples/worker-appstack/frontend/package.json b/samples/worker-appstack/frontend/package.json
index 67c5bc1..05e043a 100644
--- a/samples/worker-appstack/frontend/package.json
+++ b/samples/worker-appstack/frontend/package.json
@@ -25,7 +25,7 @@
     "@angular/material": "^22.1.0",
     "@angular/platform-browser": "^22.1.0",
     "@angular/router": "^22.1.0",
-    "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.2.0.tgz",
+    "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.3.0.tgz",
     "@ng-bootstrap/ng-bootstrap": "^21.0.0",
     "@ng-select/ng-select": "^23.5.1",
     "@ngbracket/ngx-layout": "^22.0.1",
@@ -40,7 +40,7 @@
     "monaco-editor": "^0.55.1",
     "ngx-echarts": "^22.0.0",
     "ngx-markdown": "^22.0.0",
-    "ngx-monaco-editor-v2": "^22.0.4",
+    "ngx-monaco-editor-v2": "22.0.4",
     "ngx-toastr": "^20.0.5",
     "rxjs": "^7.8.0",
     "tslib": "^2.6.0",
diff --git a/samples/worker-compute/frontend/package-lock.json b/samples/worker-compute/frontend/package-lock.json
index 9d06faf..10f0d68 100644
--- a/samples/worker-compute/frontend/package-lock.json
+++ b/samples/worker-compute/frontend/package-lock.json
@@ -8,7 +8,7 @@
       "name": "duplo-extension-worker-compute-remote",
       "version": "0.1.0",
       "dependencies": {
-        "@angular-architects/native-federation": "^22.0.6",
+        "@angular-architects/native-federation": "22.0.6",
         "@angular/cdk": "^22.1.0",
         "@angular/common": "^22.1.0",
         "@angular/compiler": "^22.1.0",
@@ -17,7 +17,7 @@
         "@angular/material": "^22.1.0",
         "@angular/platform-browser": "^22.1.0",
         "@angular/router": "^22.1.0",
-        "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.2.0.tgz",
+        "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.3.0.tgz",
         "@ng-bootstrap/ng-bootstrap": "^21.0.0",
         "@ng-select/ng-select": "^23.5.1",
         "@ngbracket/ngx-layout": "^22.0.1",
@@ -32,7 +32,7 @@
         "monaco-editor": "^0.55.1",
         "ngx-echarts": "^22.0.0",
         "ngx-markdown": "^22.0.0",
-        "ngx-monaco-editor-v2": "^22.0.4",
+        "ngx-monaco-editor-v2": "22.0.4",
         "ngx-toastr": "^20.0.5",
         "rxjs": "^7.8.0",
         "tslib": "^2.6.0",
@@ -45,6 +45,25 @@
         "typescript": "6.0.3"
       }
     },
+    "node_modules/@aiden0z/pptx-renderer": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@aiden0z/pptx-renderer/-/pptx-renderer-1.3.0.tgz",
+      "integrity": "sha512-VEXSn2vmdgTwWaw96bhsEyZiSxlrhF5O5dONI+dSBXmg1WH2XM9O1eyY2LFQ68J1SY+iZnE5BVgFGGX6iox9Og==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "echarts": "^6.0.0",
+        "jszip": "^3.10.1",
+        "mtx-decompressor": "^1.4.2"
+      },
+      "peerDependencies": {
+        "pdfjs-dist": ">=5 <7"
+      },
+      "peerDependenciesMeta": {
+        "pdfjs-dist": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@algolia/abtesting": {
       "version": "1.18.0",
       "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.0.tgz",
@@ -1874,13 +1893,14 @@
       }
     },
     "node_modules/@duplocloud-internal/ng-common-lib": {
-      "version": "0.2.0",
-      "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.2.0.tgz",
-      "integrity": "sha512-/69ZgZvvSNMoGO2GQnySpW7Upfd9zVmtNqFWTa/2oQHWcIIJ+M8JdBiv3jfeyk3kG6SfZHvdPD8OylfFLnjRng==",
+      "version": "0.3.0",
+      "resolved": "file:../../../packages/duplocloud-internal-ng-common-lib-0.3.0.tgz",
+      "integrity": "sha512-bJHET6Rxn07EExil8KomZu3ZkpXYixtccCm5GDDOLzM+Z8ajHtZVlskUlb51MlcihGYEl09dVJgCXRaHaYIPig==",
       "bundleDependencies": [
         "ng-block-ui"
       ],
       "dependencies": {
+        "@aiden0z/pptx-renderer": "^1.2.4",
         "@codemirror/lang-css": "^6.3.1",
         "@codemirror/lang-html": "^6.4.9",
         "@codemirror/lang-javascript": "^6.2.3",
@@ -1900,6 +1920,7 @@
         "cron-validate": "^1.4.3",
         "diff": "^9.0.0",
         "diff2html": "^3.4.48",
+        "docx-preview": "^0.4.0",
         "feather-icons": "^4.29.2",
         "file-saver": "^2.0.5",
         "lodash": "^4.17.20",
@@ -6037,6 +6058,12 @@
         "url": "https://opencollective.com/core-js"
       }
     },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+      "license": "MIT"
+    },
     "node_modules/cors": {
       "version": "2.8.6",
       "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -6186,6 +6213,15 @@
         "node": ">=0.3.1"
       }
     },
+    "node_modules/docx-preview": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.4.1.tgz",
+      "integrity": "sha512-Dv4g+LeE0swHpPvNC0lAT0YsR5Zq5pAxw7d3vHQ8EChBLgIrqqe9VPL5nVt2HBJap8bEZbXUI0TJob7kBeNU9w==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "jszip": ">=3.0.0"
+      }
+    },
     "node_modules/dom-serializer": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -7116,6 +7152,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/immediate": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+      "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+      "license": "MIT"
+    },
     "node_modules/immutable": {
       "version": "5.1.9",
       "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz",
@@ -7136,7 +7178,6 @@
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-      "dev": true,
       "license": "ISC"
     },
     "node_modules/ini": {
@@ -7245,6 +7286,12 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/isarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+      "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+      "license": "MIT"
+    },
     "node_modules/isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -7344,6 +7391,27 @@
       ],
       "license": "MIT"
     },
+    "node_modules/jszip": {
+      "version": "3.10.2",
+      "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz",
+      "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==",
+      "license": "(MIT OR GPL-3.0-or-later)",
+      "dependencies": {
+        "lie": "~3.3.0",
+        "pako": "~1.0.2",
+        "readable-stream": "~2.3.6",
+        "setimmediate": "^1.0.5"
+      }
+    },
+    "node_modules/lie": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+      "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+      "license": "MIT",
+      "dependencies": {
+        "immediate": "~3.0.5"
+      }
+    },
     "node_modules/listr2": {
       "version": "10.2.1",
       "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",
@@ -7880,6 +7948,12 @@
         "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
       }
     },
+    "node_modules/mtx-decompressor": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mtx-decompressor/-/mtx-decompressor-1.6.0.tgz",
+      "integrity": "sha512-YJ+ULX3VTzDK/k+h35xCrFey/vMyNTdKflCUtX/DPurR3KdhHKXC5bhslLFPY0jyODrwNWJJxSqvnBiGATRsRQ==",
+      "license": "MPL-2.0"
+    },
     "node_modules/mute-stream": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz",
@@ -8450,6 +8524,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/pako": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+      "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+      "license": "(MIT AND Zlib)"
+    },
     "node_modules/parse5": {
       "version": "8.0.1",
       "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -8692,6 +8772,12 @@
         "node": "^20.17.0 || >=22.9.0"
       }
     },
+    "node_modules/process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "license": "MIT"
+    },
     "node_modules/property-expr": {
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
@@ -8779,6 +8865,21 @@
         "node": ">= 0.10"
       }
     },
+    "node_modules/readable-stream": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+      "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      }
+    },
     "node_modules/readdirp": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
@@ -8932,6 +9033,12 @@
         "tslib": "^2.1.0"
       }
     },
+    "node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+      "license": "MIT"
+    },
     "node_modules/safer-buffer": {
       "version": "2.1.2",
       "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -9046,6 +9153,12 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/setimmediate": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+      "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+      "license": "MIT"
+    },
     "node_modules/setprototypeof": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -9374,6 +9487,15 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.1.0"
+      }
+    },
     "node_modules/string-width": {
       "version": "8.2.2",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
@@ -9623,6 +9745,12 @@
         "browserslist": ">= 4.21.0"
       }
     },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
     "node_modules/validate-npm-package-name": {
       "version": "7.0.2",
       "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",
diff --git a/samples/worker-compute/frontend/package.json b/samples/worker-compute/frontend/package.json
index 426ed5f..4427c4c 100644
--- a/samples/worker-compute/frontend/package.json
+++ b/samples/worker-compute/frontend/package.json
@@ -25,7 +25,7 @@
     "@angular/material": "^22.1.0",
     "@angular/platform-browser": "^22.1.0",
     "@angular/router": "^22.1.0",
-    "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.2.0.tgz",
+    "@duplocloud-internal/ng-common-lib": "file:../../../packages/duplocloud-internal-ng-common-lib-0.3.0.tgz",
     "@ng-bootstrap/ng-bootstrap": "^21.0.0",
     "@ng-select/ng-select": "^23.5.1",
     "@ngbracket/ngx-layout": "^22.0.1",
@@ -40,7 +40,7 @@
     "monaco-editor": "^0.55.1",
     "ngx-echarts": "^22.0.0",
     "ngx-markdown": "^22.0.0",
-    "ngx-monaco-editor-v2": "^22.0.4",
+    "ngx-monaco-editor-v2": "22.0.4",
     "ngx-toastr": "^20.0.5",
     "rxjs": "^7.8.0",
     "tslib": "^2.6.0",
diff --git a/scripts/_metrics.sh b/scripts/_metrics.sh
deleted file mode 100644
index 11b356f..0000000
--- a/scripts/_metrics.sh
+++ /dev/null
@@ -1,42 +0,0 @@
-# Sourced by run.sh — usage-metrics opt-in/opt-out resolution.
-#
-# The UI ships as a published image with the Mixpanel key baked into its Angular bundle at build
-# time, so the opt-out is applied by nginx: docker-compose mounts nginx/metrics-{on,off}.conf over
-# /etc/nginx/metrics.conf and default.conf includes it. Design:
-# docs/plans/2026-08-01-usage-metrics-optout-design.md
-
-# metrics_conf_for <1|0> → the nginx fragment filename implementing that choice.
-metrics_conf_for() {
-  if [ "$1" = 1 ]; then printf 'metrics-on.conf'; else printf 'metrics-off.conf'; fi
-}
-
-# metrics_resolve    → 1 (opted in) or 0 (opted out).
-#
-# Precedence: --metrics/--no-metrics > .env DUPLO_USAGE_METRICS > prompt. The default is opted IN.
-#
-# Never a TTY trap: under --non-interactive/-y, or with no TTY on stdin, take the default silently
-# instead of failing. Unlike the email/password prompts this value has a defensible default, so a
-# missing answer is not an error.
-# NOTE: when run.sh's resolve() grows general TTY gating, fold this check into it there.
-metrics_resolve() {
-  local flagval="$1" envval="$2" noninteractive="$3" ans=""
-  if [ -n "$flagval" ]; then printf '%s' "$flagval"; return; fi
-  if [ -n "$envval" ]; then
-    # Anything that is not exactly 1 means off — fail closed. Canonicalize to 1/0 on the way out so
-    # .env converges to the value it actually behaves as, instead of storing a junk string forever.
-    if [ "$envval" = 1 ]; then printf '1'; else
-      [ "$envval" = 0 ] || printf 'DUPLO_USAGE_METRICS=%s is not 1 or 0 — treating as opted out.\n' "$envval" >&2
-      printf '0'
-    fi
-    return
-  fi
-  if [ "$noninteractive" = 1 ] || [ ! -t 0 ]; then printf '1'; return; fi
-  printf '%s\n' 'DuploCloud collects product usage metrics from this dev kit.' >&2
-  # EOF (Ctrl-D) takes the default rather than aborting the run — this value has one, unlike the
-  # email/password prompts. Written explicitly so it does not depend on inherit_errexit being unset.
-  if ! read -r -p 'Opt out of usage metrics? [y/N]: ' ans; then printf '\n' >&2; ans=""; fi
-  case "$(printf '%s' "$ans" | tr '[:upper:]' '[:lower:]')" in
-    y|yes) printf '0' ;;
-    *)     printf '1' ;;
-  esac
-}
diff --git a/scripts/_provider_gateway.sh b/scripts/_provider_gateway.sh
index 1428230..b062248 100644
--- a/scripts/_provider_gateway.sh
+++ b/scripts/_provider_gateway.sh
@@ -12,7 +12,7 @@
 # real Anthropic API" — a different code path that would send the gateway's token nowhere useful — so
 # blanking ANTHROPIC_API_KEY here is load-bearing, exactly like bedrock-instance-role blanks AWS keys.
 
-GATEWAY_DEFAULT_MODEL="claude-sonnet-4-6"
+GATEWAY_DEFAULT_MODEL="claude-sonnet-5"
 # Context-window defaults, set on the gateway path ONLY. A gateway usually serves model ids the Claude
 # CLI does not recognise, and an unrecognised id has no known context window: long sessions then hard-400
 # instead of compacting. Declaring both up front makes that work without the user having to know the
@@ -64,8 +64,8 @@ provider_gateway_configure() {
     2. an access token         — the API key or bearer token the gateway gave you. Just press Enter
                                  if your gateway doesn't require one (a local Bifrost, for instance).
     3. a model name            — exactly as your gateway lists it. OpenRouter uses names like
-                                 anthropic/claude-sonnet-4.6; a pass-through gateway usually takes
-                                 the plain Anthropic id, e.g. claude-sonnet-4-6.
+                                 anthropic/claude-sonnet-5; a pass-through gateway usually takes
+                                 the plain Anthropic id, e.g. claude-sonnet-5.
 
 TXT
   fi
@@ -97,6 +97,7 @@ TXT
   setenv ANTHROPIC_BASE_URL "$url"
   setenv ANTHROPIC_AUTH_TOKEN "$token"
   setenv CLAUDE_MODEL "$model"
+  setenv CLAUDE_EXTRA_MODELS ""  # gateways name models their own way — register only the one you gave us
   setenv ANTHROPIC_API_KEY ""   # load-bearing — see header
 
   # Per-gateway tuning. Flag > value already in .env > default. The two window sizes always end up set
diff --git a/scripts/_provider_subscription.sh b/scripts/_provider_subscription.sh
new file mode 100644
index 0000000..a08dad0
--- /dev/null
+++ b/scripts/_provider_subscription.sh
@@ -0,0 +1,126 @@
+# shellcheck shell=bash
+# Sourced by run.sh — the `subscription` LLM provider arm: run the agent on a Claude Code
+# subscription token instead of a per-token API key, so ticket work and local extension
+# development bill to the same place.
+#
+# WHY a separate file: like the gateway arm, this one has a prompt, validation and a
+# load-bearing set of blanks, which is more than the two-line inline arms carry. Expects the
+# caller to define getenv/setenv, NONINTERACTIVE and the F_SUBSCRIPTION_* flag variables.
+#
+# Where the token comes from: `claude setup-token` on the developer's own machine (it needs a
+# browser, so it cannot be done from here). That is a long-lived token intended for handing to
+# a headless environment — not the short-lived access token in the OS keychain.
+#
+# How the agent picks this path (core/agent_setup/llm_provider.py in
+# claude-code-generic-ai-agent): CLAUDE_CODE_OAUTH_TOKEN set, and every provider above it in
+# the precedence chain unset. Subscription auth sits LAST, just ahead of the Bedrock fallback,
+# so that a token exported in someone's shell can never silently displace a deliberately
+# configured provider. The flip side is that this arm has to clear the two keys the dev kit
+# itself can set — ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL — or they would win the chain and
+# the token would never be reached. Same load-bearing blanking as bedrock-instance-role's AWS
+# keys and gateway's ANTHROPIC_API_KEY, and for the same reason.
+#
+# This is for local development. The token authenticates as one person against their own
+# subscription, so it does not belong in a shared or deployed stack.
+
+SUBSCRIPTION_DEFAULT_MODEL="claude-sonnet-5"
+# Registered alongside the default so the ticket LLM picker offers both. Bare ids, same as the
+# `anthropic` arm: this path is the first-party API, which is exactly what those ids name.
+SUBSCRIPTION_EXTRA_MODELS="claude-opus-5"
+
+# Strip leading/trailing whitespace from a pasted token. Normalization, NOT validation: no valid
+# opaque bearer token has whitespace at its edges, so this cannot turn away a good token whatever
+# shape tokens take next. Deliberately no length or stricter format check here — per the header,
+# the token's shape is not a documented contract, and rejecting a valid future token with a
+# confident error message would be worse than the 401 it was meant to prevent.
+_subscription_trim() {
+  local s="$1"
+  s="${s#"${s%%[![:space:]]*}"}"
+  s="${s%"${s##*[![:space:]]}"}"
+  printf '%s' "$s"
+}
+
+# Every key this arm may write. run.sh --reset clears them all so switching providers is clean.
+# shellcheck disable=SC2034  # consumed by run.sh --reset
+SUBSCRIPTION_KEYS=(CLAUDE_CODE_OAUTH_TOKEN)
+
+provider_subscription_configure() {
+  local token model
+  if [ "$NONINTERACTIVE" != 1 ] && [ -z "$F_SUBSCRIPTION_TOKEN" ] && [ -z "$(getenv CLAUDE_CODE_OAUTH_TOKEN)" ]; then
+    # Keep this short: the user has already chosen this path from the menu, so the rationale is
+    # settled — all they need now is the command to run and what to paste back.
+    cat >&2 <<'TXT'
+
+  Run the following command in another terminal:
+
+      claude setup-token
+
+  That returns a long-lived token beginning sk-ant-oat01- — copy it and paste it below.
+
+TXT
+  fi
+
+  token="$(_subscription_trim "$F_SUBSCRIPTION_TOKEN")"
+  [ -z "$token" ] && token="$(_subscription_trim "$(getenv CLAUDE_CODE_OAUTH_TOKEN)")"
+  if [ -z "$token" ]; then
+    [ "$NONINTERACTIVE" = 1 ] && { echo "Missing subscription token — pass --subscription-token  (non-interactive), or run 'claude setup-token' to mint one." >&2; return 1; }
+    read -rs -p "Claude Code subscription token (from 'claude setup-token'): " token; echo >&2
+    token="$(_subscription_trim "$token")"
+  fi
+  [ -n "$token" ] || { echo "No subscription token given — run 'claude setup-token' to mint one." >&2; return 1; }
+  # Warn rather than reject: the prefix is not a documented contract, so a future token shape
+  # should not be turned away by the dev kit. An API key pasted here IS worth catching, though —
+  # it would otherwise be sent as a bearer token and fail with a puzzling 401.
+  case "$token" in
+    sk-ant-oat*) ;;
+    sk-ant-api*) echo "    note: that looks like an Anthropic API key (sk-ant-api…), not a subscription token. Use ./run.sh --model anthropic for a key, or run 'claude setup-token' for a token." >&2 ;;
+    *)           echo "    note: expected a token beginning sk-ant-oat… — continuing anyway, but check it came from 'claude setup-token'." >&2 ;;
+  esac
+
+  model="$F_SUBSCRIPTION_MODEL"; [ -z "$model" ] && model="$(getenv CLAUDE_MODEL)"
+  [ -n "$model" ] || model="$SUBSCRIPTION_DEFAULT_MODEL"
+  # A model id carried over from another provider — a Bedrock inference-profile id or a gateway's
+  # namespaced id — is rejected outright by the first-party API. Fall back rather than fail on
+  # someone else's leftovers.
+  case "$model" in
+    us.*|global.*|*.anthropic.*)
+      echo "    note: CLAUDE_MODEL was '$model' (a Bedrock inference-profile id, which the first-party API rejects) — using $SUBSCRIPTION_DEFAULT_MODEL instead." >&2
+      model="$SUBSCRIPTION_DEFAULT_MODEL" ;;
+    */*)
+      echo "    note: CLAUDE_MODEL was '$model' (a gateway-namespaced id, which the first-party API rejects) — using $SUBSCRIPTION_DEFAULT_MODEL instead." >&2
+      model="$SUBSCRIPTION_DEFAULT_MODEL" ;;
+  esac
+
+  setenv CLAUDE_CODE_OAUTH_TOKEN "$token"
+  setenv CLAUDE_MODEL "$model"
+  # Registration-only extras: what the ticket LLM picker offers next to CLAUDE_MODEL. Overwritten
+  # rather than appended to, because a leftover us.anthropic.* / gateway-namespaced id from a
+  # previous provider would be rejected by the first-party API exactly as CLAUDE_MODEL is above.
+  setenv CLAUDE_EXTRA_MODELS "$SUBSCRIPTION_EXTRA_MODELS"
+  setenv ANTHROPIC_API_KEY ""    # load-bearing — see header
+  setenv ANTHROPIC_BASE_URL ""   # load-bearing — see header
+
+  # Same reason as the anthropic and gateway arms: the agent's title LLM is Bedrock-only and
+  # needs a well-formed region to not crash-loop; with no AWS creds the title call no-ops, so
+  # ticket titles are simply not generated on this path.
+  [ -n "$(getenv AWS_REGION)" ] || setenv AWS_REGION "us-east-1"
+
+  # The blanks above only reach .env. docker compose resolves ${VAR:-} from the calling shell
+  # BEFORE .env, so a key exported there still lands in the agent and wins the precedence chain —
+  # silently, since the agent then just runs on it. Neither script sources .env, so anything
+  # visible here came from the user's shell. Unset them in this process (this file is sourced, so
+  # that is the calling script, never the user's shell) so the compose calls that follow are correct.
+  #
+  # Silent by choice. This does NOT cover a later manual `docker compose up` from the same shell,
+  # which still sees the export and would hand it to the agent in place of the subscription token —
+  # a wrong-provider run with no error, just an unexpected bill.
+  local shadow="" k
+  for k in ANTHROPIC_API_KEY ANTHROPIC_BASE_URL; do
+    [ -n "${!k-}" ] && shadow="$shadow $k"
+  done
+  # shellcheck disable=SC2086  # word-split on purpose: one name per word
+  [ -n "$shadow" ] && unset $shadow
+
+  echo "    using your Claude Code subscription (model: $model)."
+  [ -z "$(getenv AWS_ACCESS_KEY_ID)" ] || echo "    note: AWS keys are still set in .env — harmless (this path never uses them), but ./run.sh --reset clears them if you'd rather they were gone."
+}
diff --git a/scripts/_studio_api.sh b/scripts/_studio_api.sh
new file mode 100644
index 0000000..e020c5d
--- /dev/null
+++ b/scripts/_studio_api.sh
@@ -0,0 +1,32 @@
+# shellcheck shell=bash
+# Sourced by run.sh — the one way it POSTs a record to the local studio and reads back data.id.
+#
+# WHY: `curl -fsS … | python3 -c 'json.load(sys.stdin)["data"]["id"]'` hides every failure. -f makes
+# curl drop the body on a 4xx/5xx, so the studio's reason (e.g. "license has expired … renew it from the
+# License page", code license_limit_exceeded) never reaches the user — they get a bare `curl: (22) … 400`
+# followed by a JSONDecodeError traceback from python reading empty stdin. This helper keeps the body,
+# prints the server's message on stderr, and returns non-zero so the caller can stop cleanly.
+# Expects the caller to define TOK (bearer token).
+
+studio_create_id() { # url json-body → prints data.id on stdout; rc 1 + message on stderr otherwise
+  local url="$1" body="$2" resp http
+  resp="$(curl -sS --max-time 10 -w '\n%{http_code}' -X POST "$url" \
+      -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" --data "$body" 2>&1)" || {
+    echo "    ✗ POST $url failed: ${resp:-}" >&2; return 1; }
+  http="${resp##*$'\n'}"; resp="${resp%$'\n'*}"
+  printf '%s' "$resp" | H="$http" U="$url" python3 -c '
+import sys,json,os
+raw=sys.stdin.read(); http=os.environ["H"]; url=os.environ["U"]
+try: d=json.loads(raw)
+except Exception: d=None
+if http.startswith("2") and isinstance(d,dict) and isinstance(d.get("data"),dict) and d["data"].get("id"):
+    print(d["data"]["id"]); raise SystemExit(0)
+msg=""
+if isinstance(d,dict):
+    errs=d.get("errors")
+    if isinstance(errs,(list,dict)): errs=json.dumps(errs)
+    msg=" ".join(str(x) for x in (d.get("message"), errs) if x)
+    if d.get("code"): msg+=" (%s)"%d["code"]
+msg=msg or raw.strip() or ""
+print("    ✗ POST %s → HTTP %s: %s"%(url,http,msg),file=sys.stderr); raise SystemExit(1)'
+}
diff --git a/scripts/detect-bedrock.sh b/scripts/detect-bedrock.sh
index 9a29136..4081a4b 100755
--- a/scripts/detect-bedrock.sh
+++ b/scripts/detect-bedrock.sh
@@ -6,7 +6,7 @@
 # to IMDS. run.sh calls this to offer that as a provider option before asking for keys.
 #
 # Usage: ./scripts/detect-bedrock.sh [model-id] [region]
-#   model-id  the Bedrock model/inference-profile actually invoked (default us.anthropic.claude-sonnet-4-6)
+#   model-id  the Bedrock model/inference-profile actually invoked (default us.anthropic.claude-sonnet-5)
 #   region    force a region instead of probing the instance region (then us-east-1, us-west-2)
 #
 # Prints eval-able KEY=VALUE lines on stdout, human-readable progress on stderr.
@@ -18,7 +18,7 @@
 set -uo pipefail
 cd "$(dirname "$0")/.."
 
-MODEL_ID="${1:-us.anthropic.claude-sonnet-4-6}"
+MODEL_ID="${1:-us.anthropic.claude-sonnet-5}"
 FORCE_REGION="${2:-}"
 
 # The whole probe is python3; without it we can still answer the question ("no") in the documented
diff --git a/scripts/register-llm.sh b/scripts/register-llm.sh
index 0be675d..9ee1ee2 100755
--- a/scripts/register-llm.sh
+++ b/scripts/register-llm.sh
@@ -1,36 +1,53 @@
 #!/usr/bin/env bash
-# Register the LLM model the agent actually runs on with the studio, and make it the System default.
+# Register the LLM models the agent can run on with the studio, and make CLAUDE_MODEL the System default.
 #
 # WHY: the studio ships no LLM model records of its own, so on a fresh dev-kit DB the ticket LLM picker
-# has nothing to offer. This registers one model, points it at the local agent, and makes it the *only*
-# model in the single System LlmModelMapping so the picker shows exactly the model that will be used.
+# has nothing to offer. This registers each model, points it at the local agent, and makes that set the
+# *only* models in the single System LlmModelMapping so the picker shows exactly what will work.
 #
-# Provider-agnostic: the id comes from CLAUDE_MODEL in .env, which run.sh sets per provider — a bare id
-# (claude-sonnet-4-6) for direct Anthropic, an inference-profile id (us.anthropic.claude-sonnet-4-6) for
-# either Bedrock mode, and whatever the gateway calls the model (e.g. anthropic/claude-sonnet-4.6 on
-# OpenRouter) for the gateway provider. These are not interchangeable: the direct Anthropic API rejects
-# the us.* prefix and Bedrock requires it, so registering the wrong variant yields a picker entry that
-# fails on use.
+# Provider-agnostic: the ids come from .env, which run.sh sets per provider — CLAUDE_MODEL is what the
+# agent runs on by default (the mapping default), CLAUDE_EXTRA_MODELS is a comma-separated list of extra
+# ids to offer alongside it (e.g. Opus next to Sonnet). Bare ids (claude-sonnet-5) for direct Anthropic,
+# inference-profile ids (us.anthropic.claude-sonnet-5) for either Bedrock mode, and whatever the gateway
+# calls the model (e.g. anthropic/claude-sonnet-5 on OpenRouter) for the gateway provider. These are not
+# interchangeable: the direct Anthropic API rejects the us.* prefix and Bedrock requires it, so
+# registering the wrong variant yields a picker entry that fails on use.
 #
-# Usage: ./scripts/register-llm.sh [model-id]
-#   model-id defaults to CLAUDE_MODEL in .env (what the agent container actually uses).
-#   LLM_PROVIDER_LABEL (env, optional) suffixes the display name, e.g. "AWS Bedrock" →
-#   "us.anthropic.claude-sonnet-4-6 (AWS Bedrock)". Defaults to a label inferred from the model id.
+# Usage: ./scripts/register-llm.sh [default-model-id [extra-model-id...]]
+#   With no args, reads CLAUDE_MODEL + CLAUDE_EXTRA_MODELS from .env (what the agent container uses).
+#   LLM_PROVIDER_LABEL (env, optional) suffixes the display names, e.g. "AWS Bedrock" →
+#   "us.anthropic.claude-sonnet-5 (AWS Bedrock)". Defaults to a label inferred from the default id.
 set -euo pipefail
 cd "$(dirname "$0")/.."
 
-source "$(dirname "$0")/_target.sh"   # → BASE_URL + TOKEN from .env per DUPLO_TARGET
+source "$(dirname "$0")/_target.sh"   # → BASE_URL + TOKEN (+ _ENV_FILE, _envv) from .env per DUPLO_TARGET
 [ -n "$TOKEN" ] || { echo "No token resolved — set DUPLO_ADMIN_TOKEN (local) or DUPLO_TOKEN (remote) in .env." >&2; exit 1; }
 
-# Model id: arg > CLAUDE_MODEL in .env. Bare id for direct Anthropic, us.anthropic.* for Bedrock.
-MODEL="${1:-$(grep -E '^CLAUDE_MODEL=' .env 2>/dev/null | head -1 | cut -d= -f2- || true)}"
+# Model ids: args > .env. $1 is the default; the rest (or CLAUDE_EXTRA_MODELS) are the extras.
+if [ $# -gt 0 ]; then
+  MODEL="$1"; shift; EXTRAS=("$@")
+else
+  MODEL="$(_envv CLAUDE_MODEL)"
+  IFS=',' read -r -a EXTRAS <<<"$(_envv CLAUDE_EXTRA_MODELS)"
+fi
 [ -n "$MODEL" ] || { echo "No model id — pass one as \$1 or set CLAUDE_MODEL in .env." >&2; exit 1; }
+# Ordered, de-duplicated, whitespace-trimmed list with the default first.
+# ${A[@]+"${A[@]}"} is the portable empty-array expansion: bash 3.2 (what macOS ships as /bin/bash)
+# treats a bare "${A[@]}" on an empty array as an unbound variable under `set -u` and aborts — which
+# is the normal gateway case (CLAUDE_EXTRA_MODELS blank) and the single-arg case.
+ALL_MODELS=()
+for m in "$MODEL" ${EXTRAS[@]+"${EXTRAS[@]}"}; do
+  m="$(printf '%s' "$m" | tr -d '[:space:]')"; [ -n "$m" ] || continue
+  for seen in ${ALL_MODELS[@]+"${ALL_MODELS[@]}"}; do [ "$seen" = "$m" ] && continue 2; done
+  ALL_MODELS+=("$m")
+done
+[ ${#ALL_MODELS[@]} -gt 0 ] || { echo "No usable model id in CLAUDE_MODEL." >&2; exit 1; }
 
 # Display-name suffix. run.sh passes the exact provider (it knows which Bedrock mode); standalone runs
 # fall back to the shape of the id, which distinguishes Bedrock from direct Anthropic but not which
 # Bedrock credential source is in play. A gateway is inferred from ANTHROPIC_BASE_URL being set in .env
 # (that is also how the agent itself decides), since gateway model ids have no fixed shape.
-if [ -z "${LLM_PROVIDER_LABEL:-}" ] && [ -n "$(grep -E '^ANTHROPIC_BASE_URL=.' .env 2>/dev/null || true)" ]; then
+if [ -z "${LLM_PROVIDER_LABEL:-}" ] && [ -n "$(_envv ANTHROPIC_BASE_URL)" ]; then
   LABEL="LLM Gateway"
 else
   case "$MODEL" in
@@ -50,29 +67,39 @@ print(a["id"] if a else "")' 2>/dev/null || true)
 [ -n "$AGENT_ID" ] || { echo "No agent found — run ./scripts/register-agent.sh first." >&2; exit 1; }
 echo "==> Using agent id: $AGENT_ID"
 
-# ── register the LLM model (idempotent by modelId) ────────────────────────────────
-MODEL_UUID=$(curl -fsS --max-time 15 "$BASE_URL/v1/aiservicedesk/admin/data/Models?filters%5BmodelId%5D=$MODEL" \
-  -H "Authorization: Bearer $TOKEN" 2>/dev/null \
-  | M="$MODEL" python3 -c 'import sys,json,os
+# ── register each LLM model (idempotent by modelId) ───────────────────────────────
+# Prints the studio uuid for a model id, registering it first if needed.
+register_model() { # model-id → uuid on stdout
+  local m="$1" uuid
+  uuid=$(curl -fsS --max-time 15 "$BASE_URL/v1/aiservicedesk/admin/data/Models?filters%5BmodelId%5D=$m" \
+    -H "Authorization: Bearer $TOKEN" 2>/dev/null \
+    | M="$m" python3 -c 'import sys,json,os
 d=json.load(sys.stdin); items=d.get("data",{}); items=items.get("items",items) if isinstance(items,dict) else items
 print(next((x["id"] for x in (items or []) if x.get("modelId")==os.environ["M"] and x.get("isActive",True)), ""))' 2>/dev/null || true)
-if [ -n "$MODEL_UUID" ]; then
-  echo "==> LLM model '$MODEL' already registered (id: $MODEL_UUID)"
-else
-  echo "==> Registering LLM model '$MODEL' → agent $AGENT_ID"
-  MODEL_UUID=$(curl -fsS --max-time 15 -X POST "$BASE_URL/v1/aiservicedesk/admin/data/Models" \
-    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-    --data "$(M="$MODEL" A="$AGENT_ID" L="$LABEL" python3 -c 'import json,os
+  if [ -n "$uuid" ]; then
+    echo "==> LLM model '$m' already registered (id: $uuid)" >&2
+  else
+    echo "==> Registering LLM model '$m' → agent $AGENT_ID" >&2
+    uuid=$(curl -fsS --max-time 15 -X POST "$BASE_URL/v1/aiservicedesk/admin/data/Models" \
+      -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
+      --data "$(M="$m" A="$AGENT_ID" L="$LABEL" python3 -c 'import json,os
 m=os.environ["M"]; label=os.environ["L"]
 print(json.dumps({"modelId":m,"displayName":m+" ("+label+")","agentIds":[os.environ["A"]],"enabled":True,"createdBy":"dev-kit"}))')" \
-    | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['id'])")
-  echo "    model id: $MODEL_UUID"
-fi
-[ -n "$MODEL_UUID" ] || { echo "Failed to register/resolve the LLM model." >&2; exit 1; }
+      | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['id'])")
+    echo "    model id: $uuid" >&2
+  fi
+  [ -n "$uuid" ] || { echo "Failed to register/resolve LLM model '$m'." >&2; exit 1; }
+  printf '%s' "$uuid"
+}
+
+UUIDS=()
+for m in "${ALL_MODELS[@]}"; do UUIDS+=("$(register_model "$m")"); done
+MODEL_UUID="${UUIDS[0]}"
+UUID_CSV="$(IFS=','; printf '%s' "${UUIDS[*]}")"
 
-# ── make it the System model (idempotent) ─────────────────────────────────────────
+# ── make them the System models (idempotent) ──────────────────────────────────────
 # Only one active System mapping may exist. If the seeder already created one (full of broken
-# us.anthropic.* entries), PUT it back with ONLY our model. Otherwise create a fresh one.
+# us.anthropic.* entries), PUT it back with ONLY our models. Otherwise create a fresh one.
 MAPPING=$(curl -fsS --max-time 15 "$BASE_URL/v1/aiservicedesk/admin/data/ModelMappings?filters%5Bscope%5D=System" \
   -H "Authorization: Bearer $TOKEN" 2>/dev/null \
   | python3 -c 'import sys,json
@@ -81,27 +108,28 @@ m=next((x for x in (items or []) if x.get("scope")=="System" and x.get("isActive
 print(json.dumps(m) if m else "")' 2>/dev/null || true)
 
 if [ -z "$MAPPING" ]; then
-  echo "==> Creating System model mapping (default: $MODEL)"
+  echo "==> Creating System model mapping (default: $MODEL; models: ${ALL_MODELS[*]})"
   MAPPING_ID=$(curl -fsS --max-time 15 -X POST "$BASE_URL/v1/aiservicedesk/admin/data/ModelMappings" \
     -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-    --data "$(U="$MODEL_UUID" A="$AGENT_ID" python3 -c 'import json,os
-print(json.dumps({"scope":"System","models":[{"modelId":os.environ["U"],"agentId":os.environ["A"]}],"defaultModelId":os.environ["U"],"createdBy":"dev-kit"}))')" \
+    --data "$(U="$UUID_CSV" A="$AGENT_ID" python3 -c 'import json,os
+us=os.environ["U"].split(","); a=os.environ["A"]
+print(json.dumps({"scope":"System","models":[{"modelId":u,"agentId":a} for u in us],"defaultModelId":us[0],"createdBy":"dev-kit"}))')" \
     | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['id'])")
   echo "    mapping id: $MAPPING_ID"
 else
-  # Already configured? (sole model == ours AND it's the default) → no-op.
-  if printf '%s' "$MAPPING" | U="$MODEL_UUID" python3 -c 'import sys,json,os
-m=json.load(sys.stdin); u=os.environ["U"]
-models=m.get("models") or []
-sys.exit(0 if (len(models)==1 and models[0].get("modelId")==u and m.get("defaultModelId")==u) else 1)' 2>/dev/null; then
+  # Already configured? (model set == ours AND the default is CLAUDE_MODEL) → no-op.
+  if printf '%s' "$MAPPING" | U="$UUID_CSV" python3 -c 'import sys,json,os
+m=json.load(sys.stdin); us=os.environ["U"].split(",")
+have=sorted(x.get("modelId") for x in (m.get("models") or []))
+sys.exit(0 if (have==sorted(us) and m.get("defaultModelId")==us[0]) else 1)' 2>/dev/null; then
     MAPPING_ID=$(printf '%s' "$MAPPING" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("id",""))')
-    echo "==> System model mapping already configured for '$MODEL' (id: $MAPPING_ID) — nothing to do."
+    echo "==> System model mapping already configured for '${ALL_MODELS[*]}' (id: $MAPPING_ID) — nothing to do."
   else
-    echo "==> Replacing System model mapping with only '$MODEL' (removing existing entries)"
-    PAYLOAD=$(printf '%s' "$MAPPING" | U="$MODEL_UUID" A="$AGENT_ID" python3 -c 'import sys,json,os
-m=json.load(sys.stdin); u=os.environ["U"]; a=os.environ["A"]
-m["models"]=[{"modelId":u,"agentId":a}]
-m["defaultModelId"]=u
+    echo "==> Replacing System model mapping with only '${ALL_MODELS[*]}' (removing existing entries)"
+    PAYLOAD=$(printf '%s' "$MAPPING" | U="$UUID_CSV" A="$AGENT_ID" python3 -c 'import sys,json,os
+m=json.load(sys.stdin); us=os.environ["U"].split(","); a=os.environ["A"]
+m["models"]=[{"modelId":u,"agentId":a} for u in us]
+m["defaultModelId"]=us[0]
 m["updatedBy"]="dev-kit"
 print(json.dumps(m))')
     MAPPING_ID=$(printf '%s' "$PAYLOAD" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
@@ -111,4 +139,4 @@ print(json.dumps(m))')
   fi
 fi
 
-echo "==> Done. System default LLM: '$MODEL' (model $MODEL_UUID, mapping $MAPPING_ID)."
+echo "==> Done. System default LLM: '$MODEL' (model $MODEL_UUID, mapping $MAPPING_ID); available: ${ALL_MODELS[*]}."
diff --git a/scripts/switch-llm.sh b/scripts/switch-llm.sh
index 6ec0a6d..9124c57 100755
--- a/scripts/switch-llm.sh
+++ b/scripts/switch-llm.sh
@@ -13,13 +13,14 @@
 #   ./scripts/switch-llm.sh bedrock   [--aws-access-key-id ..] [--aws-secret-access-key ..] \
 #                                     [--aws-session-token ..] [--aws-region ..]
 #   ./scripts/switch-llm.sh bedrock-instance-role [--aws-region ..]
+#   ./scripts/switch-llm.sh subscription [--subscription-token T] [--subscription-model M]
 #   ./scripts/switch-llm.sh gateway   [--gateway-url U] [--gateway-token T] [--gateway-model M] \
 #                                     [--gateway-disable-betas 1] [--gateway-max-context-tokens N] \
 #                                     [--gateway-compact-window N]
 #   ./scripts/switch-llm.sh status
 #
 # Each provider owns a fixed set of .env keys. Switching AWAY from a provider stashes its current,
-# non-empty keys into _STASH_= lines (so switching back later needs no re-entry) and blanks the
+# non-empty keys into _STASH__= lines (so switching back later needs no re-entry) and blanks the
 # live keys — blanking is load-bearing, exactly like run.sh's own arms: the agent picks its provider by
 # precedence ANTHROPIC_API_KEY → gateway (ANTHROPIC_BASE_URL) → Bedrock (docker-compose.yml), so a
 # leftover key from the old provider would silently keep winning. Switching TO a provider restores its
@@ -30,11 +31,13 @@ cd "$(dirname "$0")/.."
 ENV=.env
 [ -f "$ENV" ] || { echo "No .env found — run ./run.sh first to set up the stack." >&2; exit 1; }
 . ./scripts/_provider_gateway.sh
+. ./scripts/_provider_subscription.sh
 
 NONINTERACTIVE=0
 F_ANTHROPIC=""; F_AWS_KEY=""; F_AWS_SECRET=""; F_AWS_TOKEN=""; F_AWS_REGION=""
 F_GATEWAY_URL=""; F_GATEWAY_TOKEN=""; F_GATEWAY_MODEL=""
 F_GATEWAY_DISABLE_BETAS=""; F_GATEWAY_MAX_CONTEXT=""; F_GATEWAY_COMPACT_WINDOW=""
+F_SUBSCRIPTION_TOKEN=""; F_SUBSCRIPTION_MODEL=""
 
 TARGET="${1-}"; [ $# -gt 0 ] && shift || true
 while [ $# -gt 0 ]; do
@@ -45,6 +48,8 @@ while [ $# -gt 0 ]; do
     --aws-secret-access-key) F_AWS_SECRET="$2"; shift ;;
     --aws-session-token) F_AWS_TOKEN="$2"; shift ;;
     --aws-region) F_AWS_REGION="$2"; shift ;;
+    --subscription-token) F_SUBSCRIPTION_TOKEN="$2"; shift ;;
+    --subscription-model) F_SUBSCRIPTION_MODEL="$2"; shift ;;
     --gateway-url) F_GATEWAY_URL="$2"; shift ;;
     --gateway-token) F_GATEWAY_TOKEN="$2"; shift ;;
     --gateway-model) F_GATEWAY_MODEL="$2"; shift ;;
@@ -84,11 +89,13 @@ resolve() { # flagval envkey prompt secret?
 
 # Every key each provider owns. Kept in one place so stash/restore and the outgoing-blank step can't
 # drift from what each arm below actually writes.
-KEYS_anthropic=(ANTHROPIC_API_KEY)
-KEYS_bedrock=(AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_REGION CLAUDE_MODEL)
-KEYS_bedrock_instance_role=(AWS_REGION CLAUDE_MODEL)
+KEYS_anthropic=(ANTHROPIC_API_KEY CLAUDE_MODEL CLAUDE_EXTRA_MODELS)
+KEYS_bedrock=(AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_REGION CLAUDE_MODEL CLAUDE_EXTRA_MODELS)
+KEYS_bedrock_instance_role=(AWS_REGION CLAUDE_MODEL CLAUDE_EXTRA_MODELS)
 # GATEWAY_KEYS comes from _provider_gateway.sh; CLAUDE_MODEL is set by provider_gateway_configure too.
-KEYS_gateway=("${GATEWAY_KEYS[@]}" CLAUDE_MODEL)
+KEYS_gateway=("${GATEWAY_KEYS[@]}" CLAUDE_MODEL CLAUDE_EXTRA_MODELS)
+# SUBSCRIPTION_KEYS comes from _provider_subscription.sh; CLAUDE_MODEL is set by its configure too.
+KEYS_subscription=("${SUBSCRIPTION_KEYS[@]}" CLAUDE_MODEL CLAUDE_EXTRA_MODELS)
 
 keys_for() { # provider -> prints the array name to nameref
   case "$1" in
@@ -96,10 +103,33 @@ keys_for() { # provider -> prints the array name to nameref
     bedrock) printf '%s\n' "${KEYS_bedrock[@]}" ;;
     bedrock-instance-role) printf '%s\n' "${KEYS_bedrock_instance_role[@]}" ;;
     gateway) printf '%s\n' "${KEYS_gateway[@]}" ;;
+    subscription) printf '%s\n' "${KEYS_subscription[@]}" ;;
   esac
 }
 
-known_providers="anthropic bedrock bedrock-instance-role gateway"
+known_providers="anthropic bedrock bedrock-instance-role gateway subscription"
+
+# Stash keys are namespaced by provider because some keys are owned by more than one provider —
+# CLAUDE_MODEL above all, whose id format differs per provider (us.anthropic.… on Bedrock,
+# anthropic/… on a gateway, bare on first-party). A flat _STASH_CLAUDE_MODEL would hand the
+# outgoing provider's model straight to the incoming one.
+stash_key() { # provider key -> _STASH__
+  printf '_STASH_%s_%s' "$(printf '%s' "$1" | tr '[:lower:]-' '[:upper:]_')" "$2"
+}
+# Read a provider's stashed value. Falls back to the pre-namespacing flat _STASH_ only for keys
+# owned by a single provider, where the flat stash is unambiguous; a shared key's flat stash could
+# belong to anyone, so it is ignored.
+stash_get() { # provider key
+  local v owners=0 p
+  v="$(getenv "$(stash_key "$1" "$2")")"
+  if [ -z "$v" ]; then
+    for p in $known_providers; do
+      case " $(keys_for "$p" | tr '\n' ' ') " in *" $2 "*) owners=$((owners+1)) ;; esac
+    done
+    [ "$owners" = 1 ] && v="$(getenv "_STASH_$2")"
+  fi
+  printf '%s' "$v"
+}
 
 status() {
   local cur; cur="$(getenv DEVKIT_MODEL)"
@@ -108,7 +138,7 @@ status() {
     [ "$p" = "$cur" ] && continue
     local stashed=0
     while IFS= read -r k; do
-      [ -n "$(getenv "_STASH_$k")" ] && stashed=1
+      [ -n "$(stash_get "$p" "$k")" ] && stashed=1
     done < <(keys_for "$p")
     if [ "$stashed" = 1 ]; then
       echo "  $p: stashed credentials available — switching back needs no re-entry"
@@ -118,10 +148,10 @@ status() {
   done
 }
 
-[ -n "$TARGET" ] || { echo "Usage: ./scripts/switch-llm.sh  [flags]" >&2; exit 1; }
+[ -n "$TARGET" ] || { echo "Usage: ./scripts/switch-llm.sh  [flags]" >&2; exit 1; }
 TARGET="$(printf '%s' "$TARGET" | tr '[:upper:]' '[:lower:]')"
 [ "$TARGET" = status ] && { status; exit 0; }
-case " $known_providers " in *" $TARGET "*) ;; *) echo "Unknown provider '$TARGET' (use anthropic, bedrock, bedrock-instance-role, gateway, or status)." >&2; exit 1 ;; esac
+case " $known_providers " in *" $TARGET "*) ;; *) echo "Unknown provider '$TARGET' (use anthropic, bedrock, bedrock-instance-role, gateway, subscription, or status)." >&2; exit 1 ;; esac
 
 CURRENT="$(getenv DEVKIT_MODEL)"
 if [ "$CURRENT" = "$TARGET" ]; then
@@ -132,26 +162,29 @@ else
     while IFS= read -r k; do
       [ -z "$k" ] && continue
       v="$(getenv "$k")"
-      if [ -n "$v" ]; then setenv "_STASH_$k" "$v"; fi
+      if [ -n "$v" ]; then setenv "$(stash_key "$CURRENT" "$k")" "$v"; fi
       setenv "$k" ""
     done < <(keys_for "$CURRENT")
   fi
   echo "==> Restoring any stashed $TARGET credentials…"
   while IFS= read -r k; do
     [ -z "$k" ] && continue
-    sv="$(getenv "_STASH_$k")"
+    sv="$(stash_get "$TARGET" "$k")"
     [ -n "$sv" ] && setenv "$k" "$sv"
   done < <(keys_for "$TARGET")
 fi
 
 # Same model ids and defaults run.sh's own arms use, so a model already registered under one provider
 # is recognized (or replaced) the same way a fresh ./run.sh setup would.
-BEDROCK_MODEL="us.anthropic.claude-sonnet-4-6"
+BEDROCK_MODEL="us.anthropic.claude-sonnet-5"
+BEDROCK_EXTRA_MODELS="us.anthropic.claude-opus-5"
 
 case "$TARGET" in
   anthropic)
     KEY="$(resolve "$F_ANTHROPIC" ANTHROPIC_API_KEY 'Anthropic API key' secret)"
     setenv ANTHROPIC_API_KEY "$KEY"
+    setenv CLAUDE_MODEL "claude-sonnet-5"
+    setenv CLAUDE_EXTRA_MODELS "claude-opus-5"
     [ -z "$(getenv ANTHROPIC_BASE_URL)" ] || echo "    note: ANTHROPIC_BASE_URL is still set in .env — the agent will send your Anthropic key THERE, not to api.anthropic.com. Clear it (./scripts/switch-llm.sh gateway then back, or edit .env) unless that's intended."
     LLM_DESC="direct Anthropic"
     ;;
@@ -162,6 +195,7 @@ case "$TARGET" in
     RG="$F_AWS_REGION"; [ -z "$RG" ] && RG="$(getenv AWS_REGION)"; [ -z "$RG" ] && RG="us-west-2"
     setenv AWS_ACCESS_KEY_ID "$AK"; setenv AWS_SECRET_ACCESS_KEY "$SK"; setenv AWS_SESSION_TOKEN "$ST"; setenv AWS_REGION "$RG"
     setenv CLAUDE_MODEL "$BEDROCK_MODEL"
+    setenv CLAUDE_EXTRA_MODELS "$BEDROCK_EXTRA_MODELS"
     [ -z "$(getenv ANTHROPIC_API_KEY)" ] || echo "    note: ANTHROPIC_API_KEY is still set in .env — the agent prefers it over Bedrock."
     [ -z "$(getenv ANTHROPIC_BASE_URL)" ] || echo "    note: ANTHROPIC_BASE_URL is still set in .env — the agent prefers a gateway over Bedrock."
     LLM_DESC="AWS Bedrock"
@@ -170,6 +204,7 @@ case "$TARGET" in
     RG="$F_AWS_REGION"; [ -z "$RG" ] && RG="$(getenv AWS_REGION)"; [ -z "$RG" ] && RG="us-east-1"
     setenv AWS_REGION "$RG"
     setenv CLAUDE_MODEL "$BEDROCK_MODEL"
+    setenv CLAUDE_EXTRA_MODELS "$BEDROCK_EXTRA_MODELS"
     for k in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN; do setenv "$k" ""; done
     echo "    using EC2 instance role for Bedrock in $RG — no keys stored in .env."
     LLM_DESC="AWS Bedrock via EC2 instance role"
@@ -178,6 +213,10 @@ case "$TARGET" in
     provider_gateway_configure || exit 1
     LLM_DESC="LLM Gateway"
     ;;
+  subscription)
+    provider_subscription_configure || exit 1
+    LLM_DESC="Claude Code subscription"
+    ;;
 esac
 
 setenv DEVKIT_MODEL "$TARGET"
@@ -195,6 +234,7 @@ if LLM_PROVIDER_LABEL="$LLM_DESC" ./scripts/register-llm.sh; then
   case "$TARGET" in
     gateway) echo "✔ agent now on LLM gateway @ $(getenv ANTHROPIC_BASE_URL) (model: $(getenv CLAUDE_MODEL))" ;;
     bedrock-instance-role) echo "✔ agent now on $LLM_DESC @ $(getenv AWS_REGION)" ;;
+    subscription) echo "✔ agent now on your Claude Code subscription (model: $(getenv CLAUDE_MODEL)) — local dev only" ;;
     *) echo "✔ agent now on $LLM_DESC (model: $(getenv CLAUDE_MODEL))" ;;
   esac
 else
diff --git a/scripts/upgrade_dev_kit.sh b/scripts/upgrade_dev_kit.sh
index 6d2f4ae..804b667 100755
--- a/scripts/upgrade_dev_kit.sh
+++ b/scripts/upgrade_dev_kit.sh
@@ -19,7 +19,7 @@ cd "$(dirname "$0")/.."   # repo root
 #                    are overwritten/added but NO --delete, so user files in them are preserved.
 #   FRAMEWORK_FILES  — individual files, overwritten in place (never deleted).
 # Keep these lists in sync when adding framework paths.
-FRAMEWORK_DIRS=( .claude .github nginx packages samples scripts )
+FRAMEWORK_DIRS=( .agents .claude .github nginx packages samples scripts )
 # build/ holds only the extension build toolchain's Dockerfile, but `build` is a near-universal name for
 # a project's own output/scripts — so it is merged, NOT mirrored. An adopted repo that already has a
 # build/ keeps everything in it; a --delete here would reap a user directory on an upgrade they asked
diff --git a/tests/test-provider-gateway.sh b/tests/test-provider-gateway.sh
index b9ad738..7184f72 100755
--- a/tests/test-provider-gateway.sh
+++ b/tests/test-provider-gateway.sh
@@ -36,11 +36,11 @@ reset_env() { : > "$ENV"; F_GATEWAY_URL=""; F_GATEWAY_TOKEN=""; F_GATEWAY_MODEL=
 echo "gateway provider:"
 
 t "writes URL, token and model from flags"
-reset_env; F_GATEWAY_URL="https://openrouter.ai/api"; F_GATEWAY_TOKEN="sk-or-abc"; F_GATEWAY_MODEL="anthropic/claude-sonnet-4.6"
+reset_env; F_GATEWAY_URL="https://openrouter.ai/api"; F_GATEWAY_TOKEN="sk-or-abc"; F_GATEWAY_MODEL="anthropic/claude-sonnet-5"
 provider_gateway_configure >/dev/null 2>&1
 if [ "$(getenv ANTHROPIC_BASE_URL)" = "https://openrouter.ai/api" ] \
    && [ "$(getenv ANTHROPIC_AUTH_TOKEN)" = "sk-or-abc" ] \
-   && [ "$(getenv CLAUDE_MODEL)" = "anthropic/claude-sonnet-4.6" ]; then ok; else bad "$(cat "$ENV")"; fi
+   && [ "$(getenv CLAUDE_MODEL)" = "anthropic/claude-sonnet-5" ]; then ok; else bad "$(cat "$ENV")"; fi
 
 t "blanks ANTHROPIC_API_KEY so the agent takes the gateway path, not the proxy path"
 reset_env; setenv ANTHROPIC_API_KEY "sk-ant-leftover"
@@ -72,10 +72,10 @@ reset_env; F_GATEWAY_URL="https://bifrost.local"; F_GATEWAY_TOKEN="none"; F_GATE
 provider_gateway_configure >/dev/null 2>&1
 if grep -qx 'ANTHROPIC_AUTH_TOKEN=' "$ENV"; then ok; else bad "token=$(getenv ANTHROPIC_AUTH_TOKEN)"; fi
 
-t "model defaults to claude-sonnet-4-6 when not given"
+t "model defaults to claude-sonnet-5 when not given"
 reset_env; F_GATEWAY_URL="https://bifrost.local"; F_GATEWAY_TOKEN="t"
 provider_gateway_configure >/dev/null 2>&1
-if [ "$(getenv CLAUDE_MODEL)" = "claude-sonnet-4-6" ]; then ok; else bad "$(getenv CLAUDE_MODEL)"; fi
+if [ "$(getenv CLAUDE_MODEL)" = "claude-sonnet-5" ]; then ok; else bad "$(getenv CLAUDE_MODEL)"; fi
 
 t "reuses values already in .env on a re-run (no flags)"
 reset_env; setenv ANTHROPIC_BASE_URL "https://saved.example"; setenv ANTHROPIC_AUTH_TOKEN "saved-tok"; setenv CLAUDE_MODEL "saved-model"
diff --git a/tests/test-provider-subscription.sh b/tests/test-provider-subscription.sh
new file mode 100755
index 0000000..c1cf46d
--- /dev/null
+++ b/tests/test-provider-subscription.sh
@@ -0,0 +1,163 @@
+#!/usr/bin/env bash
+# Tests for scripts/_provider_subscription.sh — the `subscription` LLM provider arm of run.sh.
+# Runs against a throwaway .env; never touches the real one. Usage: ./tests/test-provider-subscription.sh
+set -euo pipefail
+cd "$(dirname "$0")/.." || exit 1
+
+PASS=0; FAIL=0
+t()   { printf '  %s … ' "$1"; }
+ok()  { echo "ok"; PASS=$((PASS+1)); }
+bad() { echo "FAIL: $1"; FAIL=$((FAIL+1)); }
+
+# Minimal stand-ins for run.sh's .env helpers, pointed at a temp file.
+ENV="$(mktemp)"; trap 'rm -f "$ENV"' EXIT
+getenv() { grep -E "^$1=" "$ENV" 2>/dev/null | head -1 | cut -d= -f2- || true; }
+setenv() {
+  python3 - "$ENV" "$1" "${2-}" <<'PY'
+import sys
+p,k,v=sys.argv[1],sys.argv[2],sys.argv[3]
+lines=open(p).read().splitlines(); out=[]; found=False
+for ln in lines:
+    if ln.startswith(k+"="): out.append(f"{k}={v}"); found=True
+    else: out.append(ln)
+if not found: out.append(f"{k}={v}")
+open(p,"w").write("\n".join(out)+"\n")
+PY
+}
+NONINTERACTIVE=1
+
+. ./scripts/_provider_subscription.sh || { echo "scripts/_provider_subscription.sh not found"; exit 1; }
+
+reset_env() { : > "$ENV"; F_SUBSCRIPTION_TOKEN=""; F_SUBSCRIPTION_MODEL=""; }
+
+echo "subscription provider:"
+
+t "writes the token and a default model from flags"
+reset_env; F_SUBSCRIPTION_TOKEN="sk-ant-oat01-abc"
+provider_subscription_configure >/dev/null 2>&1
+if [ "$(getenv CLAUDE_CODE_OAUTH_TOKEN)" = "sk-ant-oat01-abc" ] \
+   && [ "$(getenv CLAUDE_MODEL)" = "claude-sonnet-5" ]; then ok; else bad "$(cat "$ENV")"; fi
+
+t "an explicit model flag wins over the default"
+reset_env; F_SUBSCRIPTION_TOKEN="sk-ant-oat01-abc"; F_SUBSCRIPTION_MODEL="claude-sonnet-5"
+provider_subscription_configure >/dev/null 2>&1
+if [ "$(getenv CLAUDE_MODEL)" = "claude-sonnet-5" ]; then ok; else bad "$(getenv CLAUDE_MODEL)"; fi
+
+# The agent puts subscription auth next-to-last in its precedence chain, so either of these
+# left over from a previous provider would win and the token would never be read. Blanking
+# them is load-bearing, not hygiene — same as bedrock-instance-role's AWS keys.
+t "blanks ANTHROPIC_API_KEY so the token is actually reached"
+reset_env; setenv ANTHROPIC_API_KEY "sk-ant-api-leftover"; F_SUBSCRIPTION_TOKEN="sk-ant-oat01-abc"
+provider_subscription_configure >/dev/null 2>&1
+if [ -z "$(getenv ANTHROPIC_API_KEY)" ]; then ok; else bad "key still $(getenv ANTHROPIC_API_KEY)"; fi
+
+t "blanks ANTHROPIC_BASE_URL so a stale gateway doesn't win"
+reset_env; setenv ANTHROPIC_BASE_URL "https://openrouter.ai/api"; F_SUBSCRIPTION_TOKEN="sk-ant-oat01-abc"
+provider_subscription_configure >/dev/null 2>&1
+if [ -z "$(getenv ANTHROPIC_BASE_URL)" ]; then ok; else bad "url still $(getenv ANTHROPIC_BASE_URL)"; fi
+
+t "reuses a token already in .env when no flag is passed"
+reset_env; setenv CLAUDE_CODE_OAUTH_TOKEN "sk-ant-oat01-saved"
+provider_subscription_configure >/dev/null 2>&1
+if [ "$(getenv CLAUDE_CODE_OAUTH_TOKEN)" = "sk-ant-oat01-saved" ]; then ok; else bad "$(getenv CLAUDE_CODE_OAUTH_TOKEN)"; fi
+
+t "fails when non-interactive with no token anywhere"
+reset_env
+if provider_subscription_configure >/dev/null 2>&1; then bad "returned 0 with no token"; else ok; fi
+
+# A Bedrock inference-profile id is rejected outright by the first-party API, so a model left
+# over from a Bedrock run must not be carried onto this path.
+t "replaces a leftover us.anthropic.* model id with the bare default"
+reset_env; setenv CLAUDE_MODEL "us.anthropic.claude-sonnet-5"; F_SUBSCRIPTION_TOKEN="sk-ant-oat01-abc"
+provider_subscription_configure >/dev/null 2>&1
+if [ "$(getenv CLAUDE_MODEL)" = "claude-sonnet-5" ]; then ok; else bad "$(getenv CLAUDE_MODEL)"; fi
+
+t "replaces a leftover gateway-namespaced model id with the bare default"
+reset_env; setenv CLAUDE_MODEL "anthropic/claude-sonnet-5"; F_SUBSCRIPTION_TOKEN="sk-ant-oat01-abc"
+provider_subscription_configure >/dev/null 2>&1
+if [ "$(getenv CLAUDE_MODEL)" = "claude-sonnet-5" ]; then ok; else bad "$(getenv CLAUDE_MODEL)"; fi
+
+t "keeps a bare model id already in .env"
+reset_env; setenv CLAUDE_MODEL "claude-opus-4-8"; F_SUBSCRIPTION_TOKEN="sk-ant-oat01-abc"
+provider_subscription_configure >/dev/null 2>&1
+if [ "$(getenv CLAUDE_MODEL)" = "claude-opus-4-8" ]; then ok; else bad "$(getenv CLAUDE_MODEL)"; fi
+
+t "notes an API key pasted where a subscription token belongs"
+reset_env; F_SUBSCRIPTION_TOKEN="sk-ant-api03-nope"
+# Captured rather than piped: `grep -q` exits on first match, and the SIGPIPE that
+# follows would become the pipeline's status under `set -o pipefail`.
+note_out="$(provider_subscription_configure 2>&1)"
+case "$note_out" in *"looks like an Anthropic API key"*) ok ;; *) bad "no note emitted" ;; esac
+
+t "sets a default AWS_REGION (agent's title LLM needs a valid region even off-Bedrock)"
+reset_env; F_SUBSCRIPTION_TOKEN="sk-ant-oat01-abc"
+provider_subscription_configure >/dev/null 2>&1
+if [ -n "$(getenv AWS_REGION)" ]; then ok; else bad "AWS_REGION empty"; fi
+
+# compose resolves ${VAR:-} from the shell before .env, so the .env blanks can't defeat an exported
+# key. The unset is silent by choice — it fixes the scripts' own compose calls, and the warning that
+# used to accompany it was noise on a path the user had just deliberately chosen.
+t "stays silent about exported ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL"
+reset_env; F_SUBSCRIPTION_TOKEN="sk-ant-oat01-abc"
+warn_out="$(ANTHROPIC_API_KEY=sk-ant-api03-shell ANTHROPIC_BASE_URL=https://x.example provider_subscription_configure 2>&1)"
+case "$warn_out" in *WARNING*|*"exported in your shell"*) bad "$warn_out" ;; *) ok ;; esac
+
+# The unset itself is load-bearing and must survive the warning's removal: run.sh / switch-llm.sh
+# start the agent right after, so the exported key has to be dropped from the calling script's env
+# for their own compose calls to be right.
+t "unsets the exported vars in-process so the script's compose calls don't see them"
+reset_env; F_SUBSCRIPTION_TOKEN="sk-ant-oat01-abc"
+left="$(export ANTHROPIC_API_KEY=sk-ant-api03-shell ANTHROPIC_BASE_URL=https://x.example
+        provider_subscription_configure >/dev/null 2>&1; env | grep -cE '^ANTHROPIC_(API_KEY|BASE_URL)=' || true)"
+if [ "$left" = 0 ]; then ok; else bad "$left still in env"; fi
+
+t "no shell-export warning when neither is set"
+reset_env; F_SUBSCRIPTION_TOKEN="sk-ant-oat01-abc"
+warn_out="$(unset ANTHROPIC_API_KEY ANTHROPIC_BASE_URL; provider_subscription_configure 2>&1)"
+case "$warn_out" in *WARNING*) bad "$warn_out" ;; *) ok ;; esac
+
+t "SUBSCRIPTION_KEYS lists every key the arm writes (for --reset)"
+if printf '%s\n' "${SUBSCRIPTION_KEYS[@]}" | grep -qx CLAUDE_CODE_OAUTH_TOKEN; then ok; else bad "${SUBSCRIPTION_KEYS[*]:-unset}"; fi
+
+# This path talks to the first-party API with bare model ids, exactly like the `anthropic` arm —
+# so the picker should offer the same extras. (The gateway arm blanks them for a different reason:
+# a gateway names models its own way, and we only know the one id the user gave us.)
+t "registers claude-opus-5 as an extra so the picker offers it"
+reset_env; F_SUBSCRIPTION_TOKEN="sk-ant-oat01-abc"
+provider_subscription_configure >/dev/null 2>&1
+if [ "$(getenv CLAUDE_EXTRA_MODELS)" = "claude-opus-5" ]; then ok; else bad "$(getenv CLAUDE_EXTRA_MODELS)"; fi
+
+# A Bedrock/gateway id left in the extras is rejected by the first-party API just as CLAUDE_MODEL
+# would be, so the list is always rewritten rather than carried over.
+t "overwrites leftover provider-specific extras from a previous provider"
+reset_env; setenv CLAUDE_EXTRA_MODELS "us.anthropic.claude-opus-5"; F_SUBSCRIPTION_TOKEN="sk-ant-oat01-abc"
+provider_subscription_configure >/dev/null 2>&1
+if [ "$(getenv CLAUDE_EXTRA_MODELS)" = "claude-opus-5" ]; then ok; else bad "$(getenv CLAUDE_EXTRA_MODELS)"; fi
+
+# The title LLM being Bedrock-only is a detail of an unrelated subsystem; it told the user nothing
+# actionable on a path they'd just chosen deliberately.
+t "does not print the Bedrock-only ticket-titles note"
+reset_env; F_SUBSCRIPTION_TOKEN="sk-ant-oat01-abc"
+title_out="$(provider_subscription_configure 2>&1)"
+case "$title_out" in *"ticket titles are not generated"*) bad "note still printed" ;; *) ok ;; esac
+
+# Normalization, not validation: no valid opaque bearer token contains whitespace, so trimming can
+# never turn a good token away regardless of future token shape. Deliberately NOT a length or
+# stricter format check — the prefix is not a documented contract (see the file header).
+t "trims surrounding whitespace from a pasted token"
+reset_env; F_SUBSCRIPTION_TOKEN="  sk-ant-oat01-abc
+"
+provider_subscription_configure >/dev/null 2>&1
+if [ "$(getenv CLAUDE_CODE_OAUTH_TOKEN)" = "sk-ant-oat01-abc" ]; then ok; else bad "[$(getenv CLAUDE_CODE_OAUTH_TOKEN)]"; fi
+
+t "trims a token that came from .env with trailing whitespace"
+reset_env; setenv CLAUDE_CODE_OAUTH_TOKEN "sk-ant-oat01-saved   "
+provider_subscription_configure >/dev/null 2>&1
+if [ "$(getenv CLAUDE_CODE_OAUTH_TOKEN)" = "sk-ant-oat01-saved" ]; then ok; else bad "[$(getenv CLAUDE_CODE_OAUTH_TOKEN)]"; fi
+
+t "a whitespace-only token is treated as no token at all"
+reset_env; F_SUBSCRIPTION_TOKEN="   "
+if provider_subscription_configure >/dev/null 2>&1; then bad "returned 0 for a blank token"; else ok; fi
+
+echo; echo "$PASS passed, $FAIL failed"
+[ "$FAIL" = 0 ]
diff --git a/tests/test-run-subscription-wiring.sh b/tests/test-run-subscription-wiring.sh
new file mode 100755
index 0000000..845b723
--- /dev/null
+++ b/tests/test-run-subscription-wiring.sh
@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+# Wiring tests for the `subscription` LLM provider: the grep-level checks that run.sh,
+# switch-llm.sh, docker-compose.yml and .env.example all actually know about it. Static only —
+# nothing is executed and no .env is touched. Usage: ./tests/test-run-subscription-wiring.sh
+set -euo pipefail
+cd "$(dirname "$0")/.." || exit 1
+
+PASS=0; FAIL=0
+t()   { printf '  %s … ' "$1"; }
+ok()  { echo "ok"; PASS=$((PASS+1)); }
+bad() { echo "FAIL: $1"; FAIL=$((FAIL+1)); }
+
+echo "subscription wiring:"
+
+t "--help documents --model subscription and the --subscription-* flags"
+if grep -q -- "--model subscription" run.sh && grep -q -- "--subscription-token" run.sh; then ok; else bad "run.sh usage header"; fi
+
+t "every --subscription-* flag is parsed (not 'Unknown flag')"
+miss=""
+for f in --subscription-token --subscription-model; do
+  grep -qE "^[[:space:]]*$f\)" run.sh || miss="$miss $f"
+done
+if [ -z "$miss" ]; then ok; else bad "unparsed:$miss"; fi
+
+t "menu maps 5 → subscription"
+if grep -q "5) MODEL=subscription" run.sh; then ok; else bad "numeric mapping"; fi
+
+t "both provider menus offer option 5"
+if [ "$(grep -c '5) Claude Code subscription' run.sh)" = 2 ]; then ok; else bad "expected in both the IMDS and non-IMDS menu"; fi
+
+t "run.sh sources the helper and calls provider_subscription_configure"
+if grep -q "scripts/_provider_subscription.sh" run.sh && grep -q "provider_subscription_configure" run.sh; then ok; else bad "not wired into run.sh"; fi
+
+t "switch-llm.sh supports subscription"
+if grep -q "provider_subscription_configure" scripts/switch-llm.sh \
+   && grep -q "subscription" <(grep known_providers scripts/switch-llm.sh); then ok; else bad "not wired into switch-llm.sh"; fi
+
+t "--reset clears the subscription keys"
+if grep -q 'SUBSCRIPTION_KEYS\[@\]' run.sh; then ok; else bad "SUBSCRIPTION_KEYS not in the --reset list"; fi
+
+t "docker-compose passes CLAUDE_CODE_OAUTH_TOKEN to the agent"
+if grep -q 'CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-}' docker-compose.yml; then ok; else bad "compose passthrough missing"; fi
+
+t ".env.example documents subscription and has a blank key"
+if grep -q "^CLAUDE_CODE_OAUTH_TOKEN=$" .env.example && grep -q "subscription  " .env.example; then ok; else bad ".env.example"; fi
+
+t "docs mention the provider"
+if grep -q "CLAUDE_CODE_OAUTH_TOKEN" docs/configuration.md && grep -q -- "--subscription-token" docs/cli-reference.md; then ok; else bad "docs not updated"; fi
+
+t "bash -n on run.sh, switch-llm.sh and the helper"
+if bash -n run.sh && bash -n scripts/switch-llm.sh && bash -n scripts/_provider_subscription.sh; then ok; else bad "syntax error"; fi
+
+echo; echo "$PASS passed, $FAIL failed"
+[ "$FAIL" = 0 ]
diff --git a/tests/test-switch-llm-subscription.sh b/tests/test-switch-llm-subscription.sh
new file mode 100755
index 0000000..bd18ce6
--- /dev/null
+++ b/tests/test-switch-llm-subscription.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+# End-to-end tests for switching to and from the `subscription` provider with scripts/switch-llm.sh.
+# Runs the real script (under its own set -euo pipefail, with no F_* vars pre-set) against a copy of
+# scripts/ in a throwaway root with a stub `docker`, so the real .env is never touched and the stack
+# is never started. Usage: ./tests/test-switch-llm-subscription.sh
+set -euo pipefail
+cd "$(dirname "$0")/.." || exit 1
+
+PASS=0; FAIL=0
+t()   { printf '  %s … ' "$1"; }
+ok()  { echo "ok"; PASS=$((PASS+1)); }
+bad() { echo "FAIL: $1"; FAIL=$((FAIL+1)); }
+
+ROOT="$(mktemp -d)"; trap 'rm -rf "$ROOT"' EXIT
+cp -R scripts "$ROOT/scripts"
+mkdir -p "$ROOT/bin"
+# No agent container running → switch-llm.sh rewrites .env and exits 0 before any compose/registration.
+printf '#!/bin/sh\nexit 0\n' > "$ROOT/bin/docker"; chmod +x "$ROOT/bin/docker"
+ENV="$ROOT/.env"
+getenv() { grep -E "^$1=" "$ENV" 2>/dev/null | head -1 | cut -d= -f2- || true; }
+switch() { PATH="$ROOT/bin:$PATH" "$ROOT/scripts/switch-llm.sh" "$@" -y >"$ROOT/out" 2>&1; }
+
+echo "switch-llm.sh subscription:"
+
+t "switching with only --subscription-token doesn't die on an unset flag var"
+printf 'DEVKIT_MODEL=anthropic\nANTHROPIC_API_KEY=sk-ant-api03-x\n' > "$ENV"
+if switch subscription --subscription-token sk-ant-oat01-x \
+   && [ "$(getenv CLAUDE_CODE_OAUTH_TOKEN)" = "sk-ant-oat01-x" ] \
+   && [ "$(getenv DEVKIT_MODEL)" = subscription ]; then ok; else bad "$(cat "$ROOT/out")"; fi
+
+t "gateway → subscription does not inherit the gateway's model id"
+cat > "$ENV" <<'ENVF'
+DEVKIT_MODEL=gateway
+ANTHROPIC_BASE_URL=https://openrouter.ai/api
+ANTHROPIC_AUTH_TOKEN=gw-token
+CLAUDE_MODEL=anthropic/claude-sonnet-5
+ENVF
+if switch subscription --subscription-token sk-ant-oat01-x \
+   && [ "$(getenv CLAUDE_MODEL)" = "claude-sonnet-5" ] \
+   && [ -z "$(getenv ANTHROPIC_BASE_URL)" ]; then ok; else bad "CLAUDE_MODEL=$(getenv CLAUDE_MODEL); $(cat "$ROOT/out")"; fi
+
+t "the gateway's model is stashed under its own provider"
+if [ "$(getenv _STASH_GATEWAY_CLAUDE_MODEL)" = "anthropic/claude-sonnet-5" ] \
+   && [ -z "$(getenv _STASH_CLAUDE_MODEL)" ]; then ok; else bad "$(grep _STASH "$ENV")"; fi
+
+t "subscription → gateway restores the gateway's own model, not the subscription's"
+if switch gateway && [ "$(getenv CLAUDE_MODEL)" = "anthropic/claude-sonnet-5" ] \
+   && [ "$(getenv _STASH_SUBSCRIPTION_CLAUDE_MODEL)" = "claude-sonnet-5" ]; then ok; else bad "CLAUDE_MODEL=$(getenv CLAUDE_MODEL); $(cat "$ROOT/out")"; fi
+
+t "gateway → subscription again restores the stashed token without re-entry"
+if switch subscription && [ "$(getenv CLAUDE_CODE_OAUTH_TOKEN)" = "sk-ant-oat01-x" ] \
+   && [ "$(getenv CLAUDE_MODEL)" = "claude-sonnet-5" ]; then ok; else bad "$(cat "$ROOT/out")"; fi
+
+t "a pre-namespacing flat stash is still honoured for an unshared key"
+printf 'DEVKIT_MODEL=anthropic\nANTHROPIC_API_KEY=sk-ant-api03-x\n_STASH_CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-legacy\n_STASH_CLAUDE_MODEL=us.anthropic.claude-sonnet-5\n' > "$ENV"
+if switch subscription && [ "$(getenv CLAUDE_CODE_OAUTH_TOKEN)" = "sk-ant-oat01-legacy" ]; then ok; else bad "$(cat "$ROOT/out")"; fi
+
+t "…but a flat stash of a shared key (CLAUDE_MODEL) is ignored"
+if [ "$(getenv CLAUDE_MODEL)" = "claude-sonnet-5" ]; then ok; else bad "CLAUDE_MODEL=$(getenv CLAUDE_MODEL)"; fi
+
+echo; echo "$PASS passed, $FAIL failed"
+[ "$FAIL" = 0 ]