How to develop an unpublished, local build of @beamable/sdk or @beamable/portal-toolkit and see it
running in the Portal and in portal extensions.
The main flow publishes both packages to a local Verdaccio registry as version 0.0.123 and serves them
through a local unpkg-style CDN — a real IIFE fetch, so it exercises the production path. There is a
secondary, Docker-free alternative for SDK-only work in
Appendix: the inject-host alternative.
- TL;DR
- How it works: version
0.0.123 - One-time setup
BEAM_WORKSPACE— read this before your first run- Driving it from
beam local up - Building the packages
- The iteration loops
- How the Portal picks up a local build
- Cleaning up — IMPORTANT
- Command reference
- The shell scripts
- Troubleshooting
- How it works under the hood
- Appendix: the inject-host alternative
# 1. Start the local registry. NOT idempotent — this WIPES every package already published.
cd /path/to/BeamableProduct
./setup-web.sh
# ...it does not wait for the containers, so give them a moment (see the race below)
# 2. Every iteration: build, publish as 0.0.123, refresh the projects that use it.
# BEAM_WORKSPACE is effectively REQUIRED — the default rewrites tracked files in THIS repo.
BEAM_WORKSPACE=/path/to/agentic-portal ./dev-web.sh
# ...add --build if the packages' own dependencies changed
# ...add --only sdk / --only toolkit to rebuild just one (both are still published)
# 3. Run the Portal and the extension's microservice as usual. NO portal configuration needed.
npm run dev # in agentic-portal
beam project run --ids <ext-id>
# 4. When you're done — the pin lives in TRACKED files, so revert it in BOTH repos
./teardown-web.sh
cd /path/to/agentic-portal && git restore '**/package.json' '**/package-lock.json'
cd /path/to/BeamableProduct && git status # nothing web-related should be dirty
⚠️ If the Portal's.env.localsetsVITE_INJECT_HOST_SDK=true, comment it out. That flag silently wins over everything on this page: the host SDK is handed to extensions before any fetch happens, so the local CDN is never consulted and the build you just published is ignored without a word of warning. See the appendix.
Or let the local stack do steps 1–3: see
Driving it from beam local up.
Check state at any time with beam web status.
Both packages are always published as the single version 0.0.123:
Any package published as version
0.0.123is treated as a local-dev build.
That's the same sentinel dev.sh uses for the .NET packages (0.0.123.<N>), and a dozen places in the C#
codebase string-match it. The Portal does the same for web packages — it sees a 0.0.123 version and loads
it from the local CDN instead of unpkg.com, with no environment variable or config.
Both packages share that version, and the toolkit's @beamable/sdk peer dependency points at it — the
Portal reads that peer dep to decide which SDK an extension gets, so they have to agree.
Why the version never moves: consumers pin it. A version that changed per build would mean rewriting
every extension's package.json on every publish. Holding it still means the pin is written once, when
you opt in, and reverted when you're done.
What that costs: content now changes under a version that already exists, so "is this the latest build?" becomes a caching question. Every layer that could serve you a stale copy is handled:
| Layer | Keyed by | How it's busted |
|---|---|---|
| npm's view of your tree | version | beam web use deletes the installed copy, then installs the explicit spec — npm would otherwise see the version satisfied and do nothing |
| npm cache | version + integrity | the new tarball has a new integrity, so it's a cache miss automatically |
| pnpm store / cache | integrity | not self-healing — a stale entry fails the next install with ERR_PNPM_TARBALL_INTEGRITY. beam web reset / beam web stop --wipe evict the two @beamable entries (pnpm store delete + pnpm cache delete) |
| lock file integrity | — | the install rewrites it, keeping the lock consistent with what's on disk |
| local-unpkg's file cache | pkg@version |
beam web publish calls POST :4874/__flush — best-effort, see below |
| browser HTTP cache | URL | local-unpkg answers Cache-Control: no-store, and the Portal fetches local URLs with cache: 'no-store' |
| Portal's in-memory module cache | version | cleared only by a full page reload |
| extension bundle cache | localStorage | clear the portal-extension-* keys |
The first row is the one that bites, and the reason beam web use exists rather than a plain npm install.
Verified: a plain npm install leaves the old build in place; beam web use replaces it.
⚠️ The CDN flush is best-effort and never fatal.WebLocalRegistryService.FlushCdnCachelogs a warning and returns on any failure — a rejected flush, an unreachable container, a timeout. So a publish can report success while the CDN keeps serving the build you just replaced. Watch the publish output forCould not flush the local CDN cache/rejected the cache flush. A stalelocal-unpkgimage is the common cause of the latter (the/__flushendpoint postdates it); rebuild withdocker compose up -d --buildinportal-localdev/.
Version pins tolerate range operators: IsLocalDevVersion trims a leading ^ ~ > < = v before matching, so
^0.0.123 still routes at the local registry. That matters because npm's default save-prefix would
otherwise turn the pin into a caret range and silently stop it being recognised. (beam web use installs
with --save-exact, so it writes a bare 0.0.123.)
0.0.123 is safe to reuse because it exists nowhere on npmjs (404 for both packages), so it can never
collide with the registry's uplink, and removing it can't affect a published version.
- Docker Desktop running
- Node ≥ 22.14.0,
pnpm10.8.0 (npm i -g pnpm@10.8.0) - .NET SDK (the
beam webcommands are part of the CLI in this repo)
cd /path/to/BeamableProduct
./setup-web.shThis brings up two containers from portal-localdev/:
| Service | Port | Role |
|---|---|---|
| Verdaccio | 4873 | npm registry holding your local @beamable/* builds; proxies everything else to npmjs |
| local-unpkg | 4874 | unpkg-style file server, so the browser can fetch IIFEs by path |
⚠️ setup-web.shis a clean slate, not "make sure the registry is up". It runsbeam web reset, which executesdocker compose down -vfirst — that drops theverdaccio-storagevolume, so every package you had published is deleted. It then evicts the cached@beamabletarballs from the pnpm store as well (pass--keep-cachesto skip that part).Run it once at the start of a session, or deliberately when you want an empty registry. To simply bring the containers back up without losing anything, use
docker compose up -dinportal-localdev/— orbeam local up, whose registry step is non-destructive.
⚠️ There is a start-up race.beam web resetrunsdocker compose up -dwithout--wait, so it returns as soon as Docker has accepted the containers — before Verdaccio is listening. Adev-web.shfired immediately afterwards can therefore die with "The local registry at [http://localhost:4873] is not reachable". It is not a real failure; just re-run it. To avoid it entirely, gate on the registry yourself:./setup-web.sh until curl -sf http://localhost:4873 >/dev/null; do sleep 1; done BEAM_WORKSPACE=/path/to/agentic-portal ./dev-web.sh
beam local updoes not have this problem — itsdocker: web registrystep runsdocker compose up -d --waitand additionally gates on Verdaccio answering.
None. The 0.0.123 prefix is self-identifying, so the Portal routes those versions to the local CDN
automatically (LOCAL_DEV_PREFIX in agentic-portal/src/lib/utils/extensionSdkRegistry.ts). See
How the Portal picks up a local build.
The one exception is VITE_INJECT_HOST_SDK=true in the Portal's .env.local, which overrides all of this
silently — comment it out. See the appendix.
dev-web.sh takes the repo holding your extensions from BEAM_WORKSPACE, and defaults it to this
repository:
WORKSPACE="${BEAM_WORKSPACE:-$SCRIPT_DIR}" # dev-web.shThat default is almost never what you want. beam web use scans the tree for every package.json carrying
a beamable.portalExtension / beamable.portalExtensionLib marker and rewrites its
@beamable/portal-toolkit pin to 0.0.123. Run without BEAM_WORKSPACE, and it rewrites ~7 tracked
package.json files inside BeamableProduct:
client/extensions/Calculator/package.json
client/extensions/SampleOneHealthCheck/package.json
client/extensions/Test123/package.json
client/extensions/weather/package.json
client/extensions/world-clock/package.json
cli/beamable.templates/templates/PortalExtensionReactApp/package.json ← CLI scaffolding template
cli/beamable.templates/templates/PortalExtensionCommonLib/package.json ← CLI scaffolding template
The last two are the worst: they are the templates beam project new scaffolds from, so committing them
would ship a 0.0.123 pin to every extension anyone creates from that point on.
So: always set BEAM_WORKSPACE, and always git status this repo at the end of a session, not just
the extensions repo. See Cleaning up.
To publish without repointing anything at all, set BEAM_SKIP_UPDATE=1.
beam local up can own the whole loop: start the registry, publish your local packages, refresh the
extensions, and then run them against what it just built.
beam local init --with-web-registryThat adds three steps to .beamable/local-stack.json. The portal-localdev path is prompted for with an
auto-detected default (or pass --web-registry-dir, which implies the flag); the product dir is derived
from it, and the extensions repo comes from the --portal-dir you already give init:
| Step | What it does | Runs |
|---|---|---|
docker: web registry |
docker compose up -d --wait in portal-localdev/, gated on Verdaccio answering |
always, first |
build: web packages |
beam web publish — build both, publish 0.0.123, flush the CDN |
only with --build |
build: web extension pins |
beam web use — pin 0.0.123 and force-refresh the installs |
only with --build |
beam local up # registry + CDN come up; your packages are left alone
beam local up --build # ...and rebuild + republish + refresh before running the extensions--build is the existing convention for "also build the things local up doesn't otherwise build" — the
same flag that rebuilds the C# gateway, the Scala services and the portal's node deps. Plain beam local up
stays fast.
Skip or isolate individual steps by exact name (comma-separated):
beam local up --build --skip "build: web packages" # refresh only, don't rebuild
beam local up --only "docker: web registry" # just the registry
beam local up --build --only "build: web packages","build: web extension pins"Stopping is non-destructive by default. The registry step's stop is docker compose stop, so published
packages survive a stop/up cycle — unlike setup-web.sh. beam local stop --purge runs
docker compose down -v and wipes them.
- The registry goes first. It's independent of everything else and fast to come up, and later steps
(
build: portal deps, the extension steps) run npm installs that may have to resolve locally published@beamablepackages. - Publish/refresh go after the Scala services.
beam local upperforms a realm/login before its firstbeamstep, and that authenticates through the Scalaauthservice. Putting the web steps earlier would fire that login against a backend that isn't up yet. - ...and before the extension steps.
beam project run --ids <ext>builds the extension, and the toolkit is compiled into its bundle — so the refresh has to land first. - They abort the stack on failure. Both are run-to-completion steps, and a non-zero exit tears the stack down. Deliberate: quietly running extensions against a stale toolkit after a failed publish is worse than stopping.
Already have a manifest? Re-run
beam local init --with-web-registryto regenerate it. A manifest created without the flag is byte-identical to one from before this feature existed.
You normally don't have to do this by hand. beam web publish (and therefore ./dev-web.sh) builds
both packages before publishing, running exactly the recipes below. This section is for understanding what
runs, building without publishing, or debugging a build failure.
| Package | Source | Bundler | Output | |
|---|---|---|---|---|
| SDK | @beamable/sdk |
web/ |
tsdown | dist/ — node / browser / react-native, ESM+CJS+IIFE, types |
| Toolkit | @beamable/portal-toolkit |
beam-portal-toolkit/ |
tsdown | dist/ — CJS+ESM bundles, dist/types/*.d.ts |
Both are independent pnpm projects with their own lock files — there is no workspace linking them.
cd /path/to/BeamableProduct
# Build + publish (the normal path)
BEAM_WORKSPACE=/path/to/agentic-portal ./dev-web.sh
# Reinstall dependencies first (after the packages' own deps changed)
BEAM_WORKSPACE=/path/to/agentic-portal ./dev-web.sh --build
# Publish without rebuilding (dist/ is already current)
BEAM_WORKSPACE=/path/to/agentic-portal ./dev-web.sh --skip-buildbeam web publish installs dependencies only when node_modules is missing, so a normal iteration doesn't
pay for a pnpm install. Pass --build (script) / --force-install (CLI) when the packages' own
dependencies changed, or the build compiles against stale ones.
⚠️ Every script invocation rebuilds the CLI from source.scripts/beam-cli.shshells out todotnet run -f net10.0 --project cli/cli, which compiles the CLI before running it — on everysetup-web.sh/dev-web.sh/teardown-web.sh. That is usually the slowest part of an iteration and it has nothing to do with the web packages. Skip it withBEAM_CLI_NO_BUILD=1(which adds--no-build), and remember to drop the variable after touching CLI source:BEAM_CLI_NO_BUILD=1 BEAM_WORKSPACE=/path/to/agentic-portal ./dev-web.sh --only sdk
cd web
pnpm install # first time, or after dependencies changed
pnpm build # tsdown -> dist/That's the whole recipe — it mirrors the package's own prepublishOnly script.
pnpm dev # tsdown --watch, for a fast inner loopThe Portal fetches these two files by path at runtime:
web/dist/browser/index.iife.js # the SDK IIFE (package.json "unpkg" entry)
web/dist/api.iife.js # the generated API surface IIFE
Only needed when your local backend's OpenAPI differs from the checked-in generated code — i.e. you
changed a backend endpoint and want the SDK to know about it. From the repository root, with the backend
running on :8080:
dotnet run -f net10.0 --project ./cli/cli -- \
--host http://localhost:8080 \
oapi generate --engine web \
--conflict-strategy RenameUncommonConflicts \
--output ./web/src/__generated__Then rebuild (pnpm build in web/). This writes tracked files under web/src/__generated__ — review
the diff before committing, and git restore it if you only wanted a local experiment.
The toolkit has an extra step: it generates typed bindings for the Portal's beam-* web components before
bundling.
cd beam-portal-toolkit
pnpm install # first time, or after dependencies changed
pnpm sync-components --no-copy # regenerate src/generated/* from the component manifest
pnpm build # tsdown -> dist/That is the package's prepublishOnly recipe, which is why beam web publish runs both steps.
pnpm sync-components writes tracked files. It rewrites src/generated/* and stamps the current
package version into src/generated/web-types.json. beam web publish snapshots and restores that
directory so a publish never dirties the repo — but running the command by hand does not, so check
git status -- beam-portal-toolkit/src/generated afterwards. (A 0.0.123-local19 stamp is currently
committed there from an older flow — that's exactly this failure mode.)
A harmless UNRESOLVED_IMPORT warning for @vitejs/plugin-react may appear; it's an optional dynamic
import in src/vite.ts.
The single most useful thing to internalise:
- The SDK is external to an extension's bundle — the browser fetches its IIFE at runtime. Republish it and a full page reload picks it up. No extension rebuild.
- The toolkit is compiled into each extension's bundle. Republish it and every extension must be
rebuilt (
beam project run --ids <ext>) before the change appears.
cd web && pnpm build
cd ../beam-portal-toolkit && pnpm sync-components --no-copy && pnpm buildcd /path/to/BeamableProduct
BEAM_WORKSPACE=/path/to/agentic-portal ./dev-web.sh --only sdkThen fully reload the Portal. No extension rebuild — the SDK is fetched at runtime.
--only sdk skips the toolkit's build, but still publishes it and still repoints your extensions: the
version has to move on both, because the toolkit's peer dep is how the Portal finds the SDK.
--onlyis a build skip, not a build ban. If the other package has nodist/directory yet, it is built anyway — so the first--only sdkin a fresh checkout still pays for the (slower) toolkit build. That is deliberate: publishing adist/-less package would produce an empty tarball and 404s at runtime.
cd /path/to/BeamableProduct
BEAM_WORKSPACE=/path/to/agentic-portal ./dev-web.sh --only toolkit
beam project run --ids <extension-id> # rebuilds the extensionbeam web statusregistry [http://localhost:4873] running
cdn [http://localhost:4874] running
@beamable/sdk: 0.0.123 published 2026-07-27T17:44:31.122Z
@beamable/portal-toolkit: 0.0.123 published 2026-07-27T17:44:32.735Z
Since the version never changes, the publish time is what tells builds apart — check it matches the publish you just ran.
In the browser's Network tab, toolkit/SDK requests should go to /local-unpkg with the 0.0.123 version and
return 200.
All of this lives in agentic-portal. There is no portal configuration to switch on;
src/lib/utils/extensionSdkRegistry.ts treats any version starting with 0.0.123 as a local developer
build and fetches it from the local CDN instead of unpkg.com:
const LOCAL_DEV_PREFIX = '0.0.123';
function isLocalDev(version: string): boolean {
return version.startsWith(LOCAL_DEV_PREFIX);
}Extension bundle metadata
└─ ToolkitVersion: "0.0.123"
│
▼
isLocalDev() → true → LOCAL_CDN_BASE ("/local-unpkg", proxied to :4874 in vite.config.ts)
└─ GET /local-unpkg/@beamable/portal-toolkit@0.0.123/package.json
▼
peerDependencies['@beamable/sdk'] → "0.0.123"
▼
GET /local-unpkg/@beamable/sdk@0.0.123/dist/browser/index.iife.js (+ dist/api.iife.js)
▼
window['@beamable/sdk-0.0.123']
Four supporting details, all of which you will eventually trip over:
-
The
/local-unpkgVite proxy exists to keep the fetches same-origin.LOCAL_CDN_BASEdefaults to the path/local-unpkg, whichvite.config.tsproxies tohttp://localhost:4874(stripping the prefix). Going straight at the container instead is cross-origin: it does setAccess-Control-Allow-Origin: *on successful file responses, but its error responses carry no CORS headers at all — so a 404 for an unpublished path arrives in the console as an opaque CORS failure rather than the 404 it is, which is exactly the case you need to debug. Override withVITE_LOCAL_CDN_BASEif you moved the port (e.g. a directhttp://localhost:PORT, or a different proxy path). -
A full page reload is mandatory after a republish.
normalizeCacheKeykeeps the full version as the cache key for local-dev versions (real versions are collapsed tox.y.z). Since the version is fixed, that key is identical for every build — so_moduleCache/_apiModuleCache/_instanceCachekeep handing out whichever build loaded first. Those are module-levelMaps, so only a reload clears them; no amount of in-app navigation will.fetchWithCachealready bypasses the browser Cache API for local URLs (and passescache: 'no-store'), and local-unpkg answersCache-Control: no-store, so the reload is sufficient. -
src/lib/utils/extensionStorage.tsmaps the prefix forward.includePrefixes: ['0.0.123']on the newestSTORAGE_BUILDERSentry means a local build always gets the currentcontext.storageshape, regardless of how0.0.123sorts against real releases. When a new storage shape lands,includePrefixeshas to move to the new newest entry — otherwise local builds silently get the old shape. -
Extension bundles are cached in
localStorage. Clear theportal-extension-*keys and reload if a bundle itself looks stale.
The local pin lives in tracked files, in both repos. That is how the model works, but it must never be committed.
# 1. Stop the registry (and delete what was published)
cd /path/to/BeamableProduct && ./teardown-web.sh
# ...or ./teardown-web.sh --keep-packages to stop the containers but keep the packages
# 2. Revert the pins in the repo holding your extensions
cd /path/to/agentic-portal
git restore '**/package.json' '**/package-lock.json'
npm install # in any extension you actually ran, to restore the published toolkit
# 3. Revert BeamableProduct too — see below for why
cd /path/to/BeamableProduct
git status --porcelain -- '*/package.json' beam-portal-toolkit/src/generated
git restore client/extensions cli/beamable.templates beam-portal-toolkit # if anything showed up
# 4. Pre-commit sanity check, run in BOTH repos
git status --porcelain | grep -E 'package(-lock)?\.json|pnpm-lock\.yaml|web-types\.json' \
&& echo "LOCAL DEV WIRING STILL PRESENT — do not commit" || echo cleanFour things to know:
-
Don't forget this repo. If you ever ran
dev-web.shwithoutBEAM_WORKSPACE,beam web usepinned0.0.123intoclient/extensions/*and the twocli/beamable.templates/templates/PortalExtension*scaffolding templates. SeeBEAM_WORKSPACE. -
An aborted publish leaves stamped files behind.
beam web publishstampsbeam-portal-toolkit/package.json(version +@beamable/sdkpeer dep) and letspnpm sync-componentsrewritesrc/generated/*, then restores both in afinallyblock. Ctrl-C mid-run kills the process before that block executes, so the stamps survive. After any interrupted or crashed run:git statusin BeamableProduct, thengit restore beam-portal-toolkit/package.json beam-portal-toolkit/src/generated. -
Lock files matter as much as manifests. They pick up
resolvedURLs pointing athttp://localhost:4873, so revertingpackage-lock.jsonis not optional. -
There is no global npm config to restore. This flow never writes an
@beamableregistry override into your npmrc; installs are routed per-invocation with--registry. (An older version of these scripts did mutate the global npmrc, which broke installs machine-wide when the registry went away.)
All beam web commands are standalone — no Beamable workspace, service manifest or backend connection
required.
Builds both packages and publishes them to the local registry as 0.0.123, under the local dist-tag.
| Option | Meaning |
|---|---|
--product-dir <path> |
The BeamableProduct checkout. Defaults to searching upwards (and one level down into siblings) from the working directory. |
--only sdk|toolkit |
Rebuild just one package. Both are still published, at the same version — see below. |
--version <v> |
Publish as this version instead of 0.0.123. Read the caveat below before using it. |
--skip-build |
Publish what's already built. |
--force-install |
pnpm install before building even when node_modules exists. ./dev-web.sh --build maps to this. |
--registry <url> / --cdn <url> |
Non-default ports. |
The two versions have to match. The Portal resolves an extension's SDK through the toolkit's
@beamable/sdk peer dependency, so a toolkit published at version N names an SDK at version N — publishing
only one would leave that peer dep pointing at a version nothing published, and the SDK fetch 404s at
runtime with the extension failing to mount.
So --only sdk rebuilds the SDK, republishes the toolkit's existing dist/ unchanged, and publishes both
at the same version. You still save the (slower) toolkit build — unless the un-built package has no dist/
yet, in which case it is built anyway.
--version changes what gets published, but nothing else knows about it. The Portal's isLocalDev()
is a literal startsWith('0.0.123') test, so any other value fails it and the Portal falls back to
https://unpkg.com — where your local build does not exist. The failure is late and confusing:
- the publish succeeds;
beam web usehappily pins it (it resolves thelocaldist-tag, which the publish moved);beam web statusdoesn't show it — it filters versions throughIsLocalDevVersion, so your build is invisible;- the extension 404s against unpkg at runtime and fails to mount.
The same applies to extensionStorage.ts's includePrefixes and to the CLI's install routing. Use
--version only for suffixes of the sentinel (e.g. 0.0.123-experiment), or not at all.
Points the extensions under a directory at a locally published build, and installs it.
| Option | Meaning |
|---|---|
--workspace <path> |
Directory tree to scan. Defaults to the working directory. dev-web.sh passes BEAM_WORKSPACE. |
--registry <url> |
The registry to install from. Defaults to http://localhost:4873. |
--version <v> |
The version to pin. Defaults to the registry's local dist-tag — i.e. the newest build. |
--skip-install |
Rewrite the pins without running npm install. |
Discovery is a plain filesystem scan for beamable.portalExtension / beamable.portalExtensionLib markers,
skipping node_modules and .git. beam portal extension update-toolkit --local does the same rewrite but
discovers extensions via the Beamo service manifest, which makes it authenticate against the configured host
— so it fails when the backend isn't running. beam web use works offline; that's why dev-web.sh calls
it.
Installs are best-effort: a failed install in one project logs a warning and the command still succeeds,
because package.json is the source of truth and the run flow installs again before building.
Reachability of the registry and CDN, which local builds exist, and where the local dist-tag points —
the pointer beam web use follows. First thing to run when an extension loads the wrong thing.
Only 0.0.123-prefixed versions are listed; the registry proxies npmjs, so its packument otherwise lists
every published release as noise.
docker compose down -v then docker compose up -d in portal-localdev/ — wipes everything published
and comes back empty. Also evicts the cached @beamable tarballs from the pnpm store and cache, since a
wipe means the integrity hashes they hold no longer match anything. --keep-caches skips the eviction.
Note the missing --wait: see the race.
docker compose down. --wipe uses down -v instead, deleting the published packages, and also evicts the
pnpm cache entries.
Thin wrappers that run the CLI from source via dotnet run, so there is one implementation and it behaves
the same on Windows, macOS and Linux.
| Script | Runs | Destructive? |
|---|---|---|
./setup-web.sh |
beam web reset --product-dir <repo> |
Yes — deletes every published package, and evicts pnpm cache entries |
./dev-web.sh |
beam web publish --product-dir <repo> then beam web use --workspace $BEAM_WORKSPACE |
Edits tracked files in the workspace |
./teardown-web.sh |
beam web stop --product-dir <repo> --wipe |
Yes, unless --keep-packages |
| Variable | Effect |
|---|---|
BEAM_WORKSPACE |
The repo holding your extensions, where beam web use runs. Defaults to this repo — see the warning above. |
BEAM_SKIP_UPDATE |
Publish without repointing any extension. |
BEAM_FULL_BUILD |
Same as passing --build. |
BEAM_CLI_NO_BUILD |
Skip rebuilding the CLI on every invocation — much faster, but stale if you changed CLI source. |
BEAM_CLI_FRAMEWORK |
Target framework for the CLI, default net10.0. |
dev-web.sh build flags:
| Flag | Effect |
|---|---|
| (none) | Builds both packages, skipping pnpm install when node_modules exists. |
--build |
Full build — reinstalls dependencies first (translated to the CLI's --force-install). |
--skip-build |
Publishes dist/ as-is. |
dev-web.sh collects unrecognised arguments into PASSTHROUGH and appends them to the publish
invocation. The beam web use call that follows is hard-coded to --workspace "$WORKSPACE" and nothing
else. So:
./dev-web.sh --registry http://localhost:5873 # publishes to :5873, then installs from :4873...publishes to your custom registry and then force-reinstalls from the default one — which either has an
older build or nothing at all. Same story for --version. When you need a non-default registry or version,
skip the script and run the two commands yourself:
beam web publish --registry http://localhost:5873
beam web use --workspace /path/to/agentic-portal --registry http://localhost:5873./setup-web.sh --keep-caches and ./dev-web.sh --only sdk work fine, because those flags belong to the
command the passthrough actually reaches.
beam web publish says the registry isn't reachable, right after ./setup-web.sh.
The start-up race — beam web reset doesn't wait for Verdaccio. Re-run
dev-web.sh, or poll curl -sf http://localhost:4873 first. If it persists, check Docker is running and
docker compose ps in portal-localdev/.
Everything I published is gone.
You re-ran ./setup-web.sh, which is a clean slate (docker compose down -v first). Just publish again.
To restart the containers without wiping, docker compose up -d in portal-localdev/, or beam local up.
An extension still loads the published SDK/toolkit.
- Is
VITE_INJECT_HOST_SDKset in the Portal's.env.local? It wins silently — comment it out. beam web status— is a local build published, is the publish time recent, and where doeslocalpoint? Nothing listed? You may have published under a non-0.0.123--version, whichstatushides.- Does the extension's
package.jsonpin that version? Re-runbeam web useif not. - Clear the
portal-extension-*localStoragekeys and do a full page reload (not just a re-render — the module caches are module-levelMaps).
I published, but my change isn't in the extension.
Almost always the npm layer: a plain npm install sees 0.0.123 already installed and does nothing. Run
beam web use (or beam local up --build), which deletes the installed copy first. Then check the publish
output for a CDN-flush warning — the flush is best-effort, so a "successful" publish can still be shadowed
by a stale CDN cache. docker compose restart local-unpkg (or POST :4874/__flush) clears it; if the flush
was rejected, the container image predates the endpoint — docker compose up -d --build.
npm error notarget No matching version found for @beamable/portal-toolkit@0.0.123
0.0.123 exists only on the local registry, so the install has to be routed there. The CLI does that
automatically wherever it installs an extension; if you're running npm install by hand, add
--registry http://localhost:4873. Also check the registry is actually running.
ERR_PNPM_TARBALL_INTEGRITY / EINTEGRITY.
A cached entry holds the integrity hash of a tarball that has since been replaced. beam web reset and
beam web stop --wipe evict the two @beamable entries; run one, or
pnpm store delete @beamable/portal-toolkit by hand.
409 Conflict - this package is already present when publishing by hand.
Verdaccio won't overwrite a version. Unpublish first — which is exactly what beam web publish does:
npm unpublish <pkg>@<version> --force --registry http://localhost:4873.
The build fails inside beam web publish / dev-web.sh.
The error is the raw pnpm output. Reproduce it directly — cd web && pnpm build, or
cd beam-portal-toolkit && pnpm sync-components --no-copy && pnpm build. If it complains about missing or
mismatched dependencies, run with --build.
Everything 404s from /local-unpkg in the browser.
Either nothing is published at that version (beam web status), or the local-unpkg container is down
(beam web status probes it too). A CORS error rather than a 404 usually means VITE_LOCAL_CDN_BASE
points straight at the container — local-unpkg omits CORS headers on error responses, so the real status is
hidden. Drop back to the /local-unpkg proxy path.
git status shows changes under beam-portal-toolkit/.
pnpm sync-components regenerates src/generated/*, and beam web publish stamps package.json. The
publish restores both — unless it was interrupted, or you ran sync-components by hand.
git restore beam-portal-toolkit/package.json beam-portal-toolkit/src/generated.
git status in BeamableProduct shows 0.0.123 in client/extensions/ or cli/beamable.templates/.
You ran dev-web.sh without BEAM_WORKSPACE. git restore client/extensions cli/beamable.templates, and
set the variable next time.
PUBLISH beam web publish (./dev-web.sh, or `beam local up --build`)
stamp package.json (+ toolkit sdk peer dep), snapshot src/generated/
SDK → pnpm build
toolkit → pnpm sync-components --no-copy && pnpm build
npm unpublish 0.0.123 --force ← it already exists; Verdaccio won't overwrite
npm publish --tag local --ignore-scripts ← scripts already ran, above
finally: restore package.json + src/generated ← skipped on Ctrl-C
POST :4874/__flush ← best-effort; or the CDN serves the old files
ADOPT beam web use
resolves 0.0.123 from the registry's `local` dist-tag
scan for beamable.portalExtension{,Lib} markers, skipping node_modules
ensures the toolkit pin is 0.0.123 ← a no-op after the first run
rm -rf node_modules/@beamable/portal-toolkit ← the part that makes it actually refresh
npm install @beamable/portal-toolkit@0.0.123 --registry http://localhost:4873 --save-exact
RUNTIME browser → /local-unpkg/@beamable/portal-toolkit@0.0.123/package.json
→ peerDependencies['@beamable/sdk'] = 0.0.123
→ /local-unpkg/@beamable/sdk@0.0.123/dist/browser/index.iife.js (+ api.iife.js)
→ window['@beamable/sdk-0.0.123']
Details worth knowing:
Why 0.0.123 and not 0.0.123.N. npm rejects 4-part versions — they aren't semver. The .NET side can
use 0.0.123.<N> because NuGet allows it; on npm the base version is the whole marker.
Why the version doesn't move at all. Consumers pin it, so a moving version means rewriting every extension's manifest per publish. A fixed version turns that into a one-time edit and makes freshness a cache problem instead — see the table in How it works.
Why publish unpublishes first. Verdaccio rejects a publish for a version it already holds. Since only
0.0.123 is ever removed, and the uplink has no such version, this can't disturb a published package. The
unpublish is tolerant of failure: "not found" is the normal first-publish case, and a real problem surfaces
on the publish that follows with a clearer message.
Why the registry proxies npmjs for @beamable/*. It does — portal-localdev/verdaccio/config.yml
gives the scope an npmjs uplink. Extension installs get routed at the local registry, so every other
@beamable spec in that install still has to resolve — extensions pinning a published toolkit, extension
libraries, and so on. Proxying is safe because 0.0.123 exists nowhere upstream: publishing a version the
uplink also serves is what fails with 409 Conflict, and that can't happen here.
Why writes pass a token on the command line. npm refuses to publish without one even against a registry
that allows anonymous writes, and passing --//localhost:4873/:_authToken=local per invocation keeps the
user's global npmrc untouched.
| Path | Role |
|---|---|
portal-localdev/docker-compose.yml |
The two containers (beamable-verdaccio, beamable-local-unpkg) |
portal-localdev/verdaccio/config.yml |
Registry config, including the npmjs uplink for @beamable/* |
portal-localdev/local-unpkg/index.js |
The unpkg-style file server, incl. POST /__flush and Cache-Control: no-store |
portal-localdev/README.md |
The registry stack on its own terms |
scripts/beam-cli.sh |
dotnet run wrapper the three scripts share |
cli/cli/Commands/Web/ |
The beam web commands |
cli/cli/Services/Web/WebLocalRegistryService.cs |
The version constant, force-reinstall, install routing, discovery, cache eviction |
cli/cli/Services/LocalStack/LocalStackTemplate.cs |
The beam local up steps |
agentic-portal/src/lib/utils/extensionSdkRegistry.ts |
LOCAL_DEV_PREFIX routing, the module caches, inject-host |
agentic-portal/src/lib/utils/extensionStorage.ts |
includePrefixes mapping 0.0.123 to the newest storage shape |
agentic-portal/vite.config.ts |
The /local-unpkg → :4874 dev proxy |
Related: docs/developer-help.md (the .NET dev.sh loop this mirrors),
agentic-portal/CreatingExtensions.md (writing extensions in the first place).
The XML doc comments on
WebLocalRegistryServicedescribe an older "shadow the version consumers already pin" design and claim@beamable/*is not proxied. Both are stale; the code andportal-localdev/verdaccio/config.ymlare authoritative.
A second, narrower flow, kept because it needs no Docker at all. Reach for the local registry above unless you specifically want this.
| Local registry (main flow) | inject-host (this appendix) | |
|---|---|---|
| Mechanism | Publish 0.0.123 to Verdaccio, serve via local-unpkg |
The Portal hands extensions its own bundled SDK |
| Covers | SDK and toolkit | SDK only |
| Prod-like? | Yes — a real IIFE fetch from a CDN | No — skips the IIFE/CDN path entirely |
| Needs Docker? | Yes | No |
| Tracked-file edits | Extension manifests + lock files, in both repos | None |
⚠️ inject-host silently takes precedence. WhenVITE_INJECT_HOST_SDK=true,loadSdkModule/loadSdkApiModulereturn the host SDK before any fetch happens, so the local CDN is never consulted and a published0.0.123build is ignored with no error, no warning and no network request to notice. If you are using the main flow, the flag must be commented out of.env.local.
In agentic-portal/src/lib/utils/extensionSdkRegistry.ts:
INJECT_HOST_SDK = import.meta.env.DEV && VITE_INJECT_HOST_SDK === 'true'— dev builds only, never active in a production build.- When enabled, the loaders skip the IIFE fetch and register the Portal's own bundled
@beamable/sdkonto the versioned window globals.registerHostSdkGlobalsFromBundle(called fromextensionMountHandler.ts) reads the extension bundle text and registers the host SDK under whatever@beamable/sdk-<V>/@beamable/sdk/api-<V>keys the bundle baked. - It is therefore version-agnostic. The regex matches any version suffix, so it doesn't matter which
toolkit version an extension was built against — an extension built with the published toolkit still
receives the host's SDK at runtime. No local toolkit and no extension
package.jsonedit is needed. - Because the injected SDK is the Portal's own bundled copy, the Portal has to resolve
@beamable/sdkto your local build. That is the only real requirement, andnpm linksatisfies it. Vite dedupes@beamable/sdkto a single module in the graph, so extensions share the exact instance the host uses.
npm link creates a symlink under node_modules and does not modify package.json or
package-lock.json — so nothing is committable:
cd /path/to/BeamableProduct/web
pnpm install # first time only
pnpm build # produces web/dist
npm link # registers @beamable/sdk globally
cd /path/to/agentic-portal
npm link @beamable/sdk # node_modules/@beamable/sdk -> BeamableProduct/webVerify — the manifest should still read its registry pin, and node_modules should be a symlink:
grep '"@beamable/sdk"' package.json # unchanged registry version — good
readlink node_modules/@beamable/sdk # -> .../BeamableProduct/webThen add to agentic-portal/.env.local (already git-ignored via *.local):
VITE_INJECT_HOST_SDK=true
For any change inside the Web SDK this is the whole thing — zero tracked-file edits in either repo, no toolkit rebuild, no extension changes:
cd /path/to/BeamableProduct/web && pnpm build # rebuild the local SDK
cd /path/to/agentic-portal && npm run dev # restart so Vite re-bundles the symlink- Restart the dev server, not just the browser — a reload alone won't re-bundle the symlinked SDK.
- Extensions stay on their published
@beamable/portal-toolkitspec. Do not edit theirpackage.json. - Run the extension's microservice so
RequestPortalExtensionDataserves its bundle:beam project run --ids <service-id>. - Clear the
portal-extension-*localStoragekeys and reload.
Typechecking is the part this doesn't cover: npx tsc --noEmit inside an extension resolves
@beamable/sdk from that folder's node_modules, i.e. the registry copy. npm link @beamable/sdk in the
extension folder too if you need local types there.
cd /path/to/agentic-portal
npm unlink @beamable/sdk
npm install # restore the registry copy
# then remove VITE_INJECT_HOST_SDK from .env.local- The extension mounts with no "did not register on window" error.
- Add a temporary
console.login the local SDK,pnpm buildinweb/, restart the Portal, reload — the log appears in the extension, with no IIFE/CDN fetch in the Network tab, andgit statusis clean of anypackage.jsonor lock-file change in both repos.