Skip to content

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:

FlagGovernsRead today by
ai_agentThe Aero rail, the entry describe box, the publish reviewPortalStudio.vue, PublishPopover.vue
portal_agentThe customer-facing portal assistantPortalAgentChatHost.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

js
export function useAeroAvailable()          // ai_agent
export function usePortalAgentAvailable()   // portal_agent

Both 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.vueconst aeroAvailable = useAeroAvailable(), then:

  • The whole describe hero (sparkles icon, <h1>, lede, Textarea, chips, "Build with Aero") goes behind v-if="aeroAvailable".
  • The .entry__divider ("Or start from a template") also goes behind v-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." No pi-sparkles.
  • Skip loadSuggestions() and store.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, useExample and 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:

js
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, loadEnvironment not called. Aero on: today's page, unchanged.
  • New case in a ComposeStep spec — aeroBusy: true + aeroAvailable: false + empty portal renders the canvas and the left rail, not GeneratingScreen.

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:

  1. The Layout panel's "AI assistant" toggle is gated only on !accountScope (LayoutPanel.vue:48), and its default is on (isAgentEnabled returns true when unset). An org without portal_agent gets an on-by-default switch whose "on" state is fiction — the customer app requires PORTAL_AGENT_FEATURE_CODE andproject.portal_agent_enabled before it mounts anything (PortalAgentChatHost.vue:213).
  2. 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.vueconst portalAgentAvailable = usePortalAgentAvailable(), passed to StudioLeftRail as :portal-agent-available and to StudioCanvas as :agent-available.
  • StudioLeftRail.vue — new portalAgentAvailable prop (default false), forwarded as :agent-available="!accountScope && portalAgentAvailable" (replacing the current :agent-available="!accountScope").
  • StudioCanvas.vue — new agentAvailable prop (default false, 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 ingestBrand entirely; run only portalStudioApi.ingestBrandAssets, land it through applyBrandAssets(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) on ingestBrand too, and treat a null result exactly like matched: false. A dead model call must not discard a successful scrape.
    • describeIngest grows 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.
  • BrandIngestField.vue — takes aeroAvailable and 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 the description prop overriding at StudioLeftRail.vue:34 in favor of the component's own two strings.
  • AeroTintPanel — the panel wears the AI tint (primary-tinted fill + border) and a pi-sparkles icon. 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.vue and ComposeStep.vue — pass the flag.

Tests:

  • New composables/__tests__/useBrandIngest.test.js — Aero off: ingestBrand never called, scraped assets applied, note has no "Aero"; Aero on with ingestBrand rejecting: 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

RiskHandling
The portal_agent flag is rarer than assumed, and Phase 3 hides the toggle from orgs that should see itConfirm 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 StudioIntentional — 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 anyoneVerify 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

  1. Flag distribution — how many orgs hold portal-studio without ai_agent, and without portal_agent? Decides how loudly Phases 3 and 5 need to be announced.
  2. Non-Aero entry heading — "Pick a starting point" is a placeholder good enough to build against, not a decided string.

Internal documentation — gated behind Cloudflare Access.