Portal Studio Without Aero
Make Portal Studio a complete manual builder for an org that has portal-studio but not ai_agent — today that org hits a hard lockout from the entry page's primary CTA, plus a set of controls that silently do nothing.
Status: Draft · Owner: Jeff Stolz
The axis
Studio access is already OWNER + ONRAMP_ADMIN only (pages/portalStudio/router.js), and both roles sit inside ASK_AI_ALLOWED_ROLES. So role is not a second axis — the only variable is the vendor feature flag. Two flags matter, and they are independent:
| Flag | Governs | Read today by |
|---|---|---|
ai_agent | The Aero rail, the entry describe box, the publish review | PortalStudio.vue, PublishPopover.vue |
portal_agent | The customer-facing portal assistant | PortalAgentChatHost.vue (customer app only) |
A third gate exists server-side and disagrees with the first: the studio agent endpoint gates on portal-studio, not ai_agent (app/api/agent/controllers/portal_studio_agent_controller.py). Phase 5 resolves that.
Deliberately out of scope
First-run guidance stays Aero-only. AeroArrivalRecap / AeroRecapCleared render inside the rail and will keep doing so. This is a product decision — the arrival recap is part of what Aero is worth — not an oversight. See Portal Studio First-Run for what that card is. Do not "fix" this later without reopening the decision.
A non-Aero org has no in-product route to the "Portal Studio guide" article. The header's Help menu was the one entry point and has been removed, so the recap's link is the only one left and it renders inside the rail. Reopening this means choosing a surface that does not depend on Aero being mounted.
Also out: any new upsell surface where the Aero rail would have been. Nothing fills that space; the canvas simply gets the width.
Phase 0 — one gate per flag
Three files currently re-derive "does this org have Aero" from AI_AGENT_FEATURE_CODE by hand, and Phase 3 is about to add a second flag. Give each flag exactly one home first.
New: app/ui-organization/pages/portalStudio/composables/useStudioFeatures.js
export function useAeroAvailable() // ai_agent
export function usePortalAgentAvailable() // portal_agentBoth return a ComputedRef<boolean> off useFeaturesStore().
Why not useAskAIAvailable (the org-wide Ask Aero gate): it also consults the route name and the role allowlist. The route half is inert here, the role half is already closed by the router guard, and depending on useRoute() would force a router stub into every component test that touches the studio. The new composable carries a one-line comment pointing at useAskAIAvailable as the org-wide equivalent so the two are discoverable from each other.
Edits: replace the local aeroEnabled computed in PortalStudio.vue:105 and PublishPopover.vue:192 with useAeroAvailable(). No behavior change — PublishPopover's existing tests should pass untouched, which is the check that this phase is inert.
Tests: new composables/__tests__/useStudioFeatures.test.js — each flag on and off, driven by features.enabledFeatures (the pattern publish/__tests__/PublishPopover.test.js already uses).
Phase 1 — the lockout (P0)
buildWithAero() sets store.setAeroBusy(true) then navigates (PortalStudioEntry.vue:257). In the editor generatingFirstPortal = aeroBusy && isEmptyPortal feeds buildingSurface (ComposeStep.vue:244), which hides the left rail, the inspector and the canvas and collapses the top bar to title + Exit. The only writer that clears aeroBusy is setAeroRailMounted(false) (portalStudio.store.js:741), whose sole caller is AeroRail's onUnmounted — which never fires, because the rail never mounts. Result: a permanent fake progress screen, escapable only by Exit or reload.
Fixed at the source and at the one place it renders:
PortalStudioEntry.vue — const aeroAvailable = useAeroAvailable(), then:
- The whole describe hero (sparkles icon,
<h1>, lede,Textarea, chips, "Build with Aero") goes behindv-if="aeroAvailable". - The
.entry__divider("Or start from a template") also goes behindv-if="aeroAvailable"— without Aero the template grid is not an alternative, it is the page. - A non-Aero heading block renders in its place, above the grid, in the same
max-w-[720px]centered slot so the vertical rhythm is unchanged: "Pick a starting point" / "Start from a template built for a common onboarding shape, or from a blank canvas." Nopi-sparkles. - Skip
loadSuggestions()andstore.loadEnvironment()when Aero is off — both exist only to feed the describe box's playbook-derived chips and the build turn. (This is finding #8 for this route; the editor route is Phase 5.) buildWithAero,describedSeriesId,useExampleand the chip constants stay as-is — they are still the Aero path, not dead code.
ComposeStep.vue — the safety net, so no future writer of aeroBusy can strand the editor again:
const generatingFirstPortal = computed(() => props.aeroAvailable && store.aeroBusy && isEmptyPortal.value);buildingSurface's other input, store.isGenerating, is written only by the rail's stream and needs no guard.
Manual path sanity — verified, no work needed: "Blank canvas" lands on an empty portal whose canvas carries its own "Add widget" button (StudioCanvas.vue:77), and the left rail's page rows carry theirs. A non-Aero user can build from zero.
Tests:
- New
__tests__/PortalStudioEntry.test.js— Aero off: no describe textarea, no "Build with Aero" button, no divider, heading reads "Pick a starting point", template cards present,loadEnvironmentnot called. Aero on: today's page, unchanged. - New case in a
ComposeStepspec —aeroBusy: true+aeroAvailable: false+ empty portal renders the canvas and the left rail, notGeneratingScreen.
Phase 2 — dead inspector buttons (P1)
AskAeroFieldButton calls store.askAero(), which sets the one-shot aeroRequest only the rail consumes — so with no rail the three inspector buttons are silent no-ops (StudioInspector.vue:62,90,108).
AskAeroFieldButton.vue self-gates: v-if="aeroAvailable" on its own root, from useAeroAvailable().
This deviates from the top bar's aeroAvailable prop pattern on purpose. The top bar composes its menu in JS, so a prop is natural there; here a prop would mean three v-if mirrors in StudioInspector plus a fourth wherever the next field type lands. A component whose name is the feature is the stronger single source.
Empty-slot safety — verified, no layout residue. All three call sites sit in #actions slots whose containers already document and support being empty: ItemRowsField.vue:52-60 (the row still holds its Add button) and InspectorField.vue:54-59 (the row still holds its item count). Nothing renders a bare padded row.
Tests: additions to components/__tests__/StudioInspector.test.js — Aero off: no "Write with Aero" / "Extend with Aero" button on a doc field, a list field or a FAQ field; the field editors themselves still render.
Phase 3 — portal-assistant parity (P1)
Two independent over-promises, both keyed on portal_agent:
- The Layout panel's "AI assistant" toggle is gated only on
!accountScope(LayoutPanel.vue:48), and its default is on (isAgentEnabledreturns true when unset). An org withoutportal_agentgets an on-by-default switch whose "on" state is fiction — the customer app requiresPORTAL_AGENT_FEATURE_CODEandproject.portal_agent_enabledbefore it mounts anything (PortalAgentChatHost.vue:213). - The builder preview renders the real launcher regardless (
StudioCanvas.vue:334).
Both stay prop-driven — StudioLeftRail, LayoutPanel and StudioCanvas are each mounted only by ComposeStep, and LayoutPanel already takes agentAvailable.
ComposeStep.vue—const portalAgentAvailable = usePortalAgentAvailable(), passed toStudioLeftRailas:portal-agent-availableand toStudioCanvasas:agent-available.StudioLeftRail.vue— newportalAgentAvailableprop (defaultfalse), forwarded as:agent-available="!accountScope && portalAgentAvailable"(replacing the current:agent-available="!accountScope").StudioCanvas.vue— newagentAvailableprop (defaultfalse, so a mount that forgets it under-promises rather than over-promises):showAgentLauncher = props.agentAvailable && props.previewing && !showingAccountHome.value && isAgentEnabled(renderedConfig.value?.layout).
Breaks a source-string assertion.StudioCanvasAdapterParity.test.js:171 asserts the literal text props.previewing && !showingAccountHome.value && isAgentEnabled. Update it to the new expression — the property it guards (reads renderedConfig, never props.config) must stay asserted.
Decision: do not write layout.agent = false for these orgs. The render side already refuses, so persisting it would buy nothing and cost a sweep over stored documents plus a rule about what happens on flag flip. Leaving it unset means the day an org buys portal_agent, their published portals light it up — which is the upsell behaving correctly. The alternative (write it explicitly, treat unset as legacy) is recorded here only so the choice is visible.
Tests: components/__tests__/LayoutPanel.test.js — no "AI assistant" row when agentAvailable is false, row present when true. New StudioCanvas case — previewing with agentAvailable: false renders no launcher.
Phase 4 — brand from your website (P2)
ingest() awaits Promise.all([ingestBrand(url), ingestBrandAssets(url).catch(() => null)]) (useBrandIngest.js:79). The deterministic scrape is individually caught; the Aero call is not — so one Bedrock failure reports "Couldn't read that site" even when the real logo, fonts and favicon came back fine. Separately, the copy says "Aero" in three places, in two surfaces: Studio → Brand mode and Settings → Brand (via BrandDefaultsEditor).
And because the endpoint gates on portal-studio rather than ai_agent, a non-Aero org's ingest currently does reach Bedrock. Decision: it shouldn't. The deterministic scrape is the genuinely useful half; keep it for everyone, drop the model call for orgs that haven't bought it, and stop calling it Aero when it isn't.
useBrandIngest(store, { aeroAvailable })— a second argument, since both call sites (BrandSettings.vue:60,ComposeStep.vue:213) own their own flag read.- Aero off: skip
ingestBrandentirely; run onlyportalStudioApi.ingestBrandAssets, land it throughapplyBrandAssets(store, source, assets, { clearMissing: true })— the path the unmatched-Aero branch already takes — and compose the note from the scrape alone. - Aero on:
.catch(() => null)oningestBrandtoo, and treat a null result exactly likematched: false. A dead model call must not discard a successful scrape. describeIngestgrows an Aero-free branch. Its two Aero sentences (useBrandIngest.js:50-51) are claims about a model's recall — with no model in the loop there is nothing to disclaim, so the non-Aero note reads only what was actually read: "Read colors, logo and fonts from mycompany.com." When nothing is read: "Couldn't read mycompany.com — nothing was changed." The existing rule holds either way: the note is composed from what came back, never from what was asked for.
- Aero off: skip
BrandIngestField.vue— takesaeroAvailableand picks its own copy, so both surfaces stay identical without either caller restating it. Title "Brand from your website" is already neutral; the description becomes "Paste your site — we'll read your colors, type, and logo." when Aero is off. Drop thedescriptionprop overriding atStudioLeftRail.vue:34in favor of the component's own two strings.AeroTintPanel— the panel wears the AI tint (primary-tinted fill + border) and api-sparklesicon. With Aero off, pass a neutral icon (pi pi-globe, matching "from your website"). Keeping the tint is fine — it reads as an accent panel, not an AI badge, once the sparkle is gone.BrandSettings.vueandComposeStep.vue— pass the flag.
Tests:
- New
composables/__tests__/useBrandIngest.test.js— Aero off:ingestBrandnever called, scraped assets applied, note has no "Aero"; Aero on withingestBrandrejecting: scrape still applied, no failure note; nothing read at all: failure note, theme untouched. components/__tests__/BrandIngestField.test.js— the two descriptions and the two icons.
Phase 5 — waste, then fail closed
Client. PortalStudio.vue:103 calls store.loadEnvironment() on every editor open — a fetch of playbooks, resources and team. store.environment is read only by the Aero context object, useAeroStarterChips and the publish review, all three already Aero-gated. Skip it when Aero is off.
Server, optional hardening. After Phase 4, nothing in a non-Aero org calls /agent/portal-studio, so the endpoint can require ai_agent alongside portal-studio and fail closed the way CLAUDE.md asks. Gate this on a check of which vendors actually hold portal-studio without ai_agent today — if brand ingest is the only caller, this is safe the moment Phase 4 ships; if anything else turns up, it needs its own note. Ship the client phases first either way: an FE gate stricter than the BE gate breaks nothing, the reverse does.
Risks
| Risk | Handling |
|---|---|
The portal_agent flag is rarer than assumed, and Phase 3 hides the toggle from orgs that should see it | Confirm flag distribution before merging Phase 3; the change is one boolean if it needs inverting |
| Phase 4 changes copy on Settings → Brand, a surface outside Portal Studio | Intentional — it is the same component and the same claim. Called out in the PR body so it isn't read as scope creep |
| A non-Aero org's entry page has never been seen by anyone | Verify live by clearing ai_agent for the local vendor (see below) before calling any phase done |
Verification
Static tracing found all of this; none of it has been observed running, because no non-Aero org is seeded locally. Before each phase is called done: clear ai_agent for the local vendor's enabled features, then walk Entry → template start → blank-canvas start → widget inspector → Brand mode → Layout panel → preview → publish, in light and dark. The Phase 1 lockout should be reproducible first, on main, so the fix is provably a fix.
Sequencing
Phase 0 → 1 → 2 → 3 → 4 → 5. Phase 1 is the only true break and depends only on Phase 0. Phases 2–4 are independent of each other and can land in any order or together. Phase 5's server half waits on Phase 4.
Open questions
- Flag distribution — how many orgs hold
portal-studiowithoutai_agent, and withoutportal_agent? Decides how loudly Phases 3 and 5 need to be announced. - Non-Aero entry heading — "Pick a starting point" is a placeholder good enough to build against, not a decided string.