Skip to content

AI-Native Shell

The AI-Native shell ("Agent mode") replaces the 5-phase wizard at /app/agent with a chat canvas. The agent drives the design — phase navigation, component selection, link budget, stackup, thermal, export — via a tool-call loop streamed over SSE. The legacy Guided wizard at /app is unchanged and remains the fallback when the flag is OFF or on mobile.

This guide answers "what is it, how do I switch it on, and where does the code live?"

TL;DR

  • Flag: VITE_FF_AI_NATIVE_SHELL (read once at module load via src/utils/featureFlags.ts → isAiNativeAvailable())
  • Route: /app/agent mounts AgentShell; /app keeps the Guided wizard
  • Wire: POST /api/chat/gemini/stream (fetch + ReadableStream — not EventSource)
  • Mobile: <640 px viewports keep the Guided sidebar regardless of flag state
  • Source of truth for changes: docs/planning/ai_native_transformation/ai-native-transformation.md

Architecture

Browser /app/agent              FastAPI                   Gemini
─────────────────              ─────────                 ──────
AgentShell                                                 │
├─ PhaseNavigation             POST /chat/gemini/stream    │
└─ ChatCanvas ──────► fetch ──►   verify_jwt               │
   ├─ MessageBubble                handle_chat_stream      │
   │   └─ ActionCard               agent_loop.run_loop ───►│ send_message
   │       └─ <registry>             while iter < 16:      │
   ├─ ChatInputBar                     yield thinking      │
   └─ agentStream.ts                   yield tool_call    ◄┘
       (SSE parser)                    dispatch_tool ─────►│ function_response
                                       yield tool_result ◄─┘
                                       if no tool_calls: done

Wire format

  • Transport: Content-Type: text/event-stream over fetch POST (lets us send Authorization: Bearer <Supabase JWT>; EventSource cannot).
  • Framing: event: <name>\ndata: <json>\n\n plus :keepalive comments every 15 s to defeat proxies.
  • Event names: thinking | tool_call | tool_result | text | done.
  • Parser: frontend/src/services/chat/agentStream.ts owns cross-chunk buffering, multi-byte UTF-8, multi-line data:, and keepalive drop.

Tool registry

10 tools dispatched by the loop, each verifies args.project_id == context.project_id (JWT-derived) before touching state. Failures encode as {ok: false, error} tool responses so the model can recover without the loop crashing.

Phase Tool
pre-existing update_rf_specs, generate_block_diagram, update_stackup_config, run_surrogate_predict, run_stackup_solver
AI-native additions goto_phase, select_components_batch, run_link_budget, run_thermal_analysis, run_pdn_analysis, run_glass_weave_analysis, generate_export, export_bom, load_stackup, load_bom, lookup_component_by_mpn, set_active_component

run_solver is kept as a deprecated alias for run_stackup_solver in tool_dispatcher.py so persisted conversations still route.

Registry source: backend/services/agent_loop/loop.py (loop) + backend/services/tool_dispatcher.py (routing) + backend/tools/ (handlers).

Export surface

generate_export exports kicad, odb, ads, gerber, ipc2581, hfss, magicon_internal, pdf, pptx. Each format branch delegates to the same generator the Guided HTTP route uses (routes/exports.py, routes/_export_utils.py, services/stackup_pdf_service, services/pptx_generator) — never a reimplementation. The kicad/odb/ads branches live in tools/generate_export.py; the widened branches live in tools/generate_export_formats.py and lazy-import their generators (so importing the tool never pulls in KiCad/pcbnew). Cross-surface byte parity is locked by tests/test_generate_export_parity.py. Per-format gating mirrors the routes (gerber/pdf ungated; ipc2581/hfss behind EXPORT_IPC2581/EXPORT_HFSS; magicon-internal gated in production only). pptx (full design report) derives its BOM/stackup from prior tool results and returns insufficient_design when too sparse. ADS/ODB pass Design-Intelligence/sidecar through when include_sidecar/include_design_intelligence is set (no more hardcoded None).

export_bom serializes the user's BOM (the latest bom_preview, else the applied phased BOM via load_bom) to csv/excel/json through the single-source services/export/bom_export_service.py, which formats the canonical assembler BOM and never re-assembles. The same service backs POST /api/export/bom, which the BomPreviewCard "Download BOM" button calls (services/export/bom/bomFileDownloadService.ts → shared services/export/fileDownload.ts). ExportReadyCard downloads inline base64 files (text fallback for the legacy kicad/ads payloads).

Both the widened formats and export_bom sit behind FF_AGENT_EXPORT_EXTENDED (backend, default on) — an agent-path kill switch: off restores the {kicad, odb, ads}-only surface and hides export_bom. Guided HTTP export routes never read this flag. Source change: OpenSpec agent-export-parity.

Action card registry (frontend)

Tool results render via frontend/src/components/Chat/actionCards/registry.tsx. Each tool_result.resultType maps to a card component — StackupInlineCard, BomPreviewCard, LinkBudgetResultCard, ExportReadyCard, KeyValueCard (the generic per-phase PhaseViewCard was retired 2026-06-08 in favor of these dedicated cards). Cards are memoized on result.id; without React.memo a 7-tool turn re-renders 30+ times.

Phase components inline rendering

Cards that mount full Phase components (e.g., StackupInlineCard<StackupDesignView viewMode="inline" />) pass viewMode='inline' so the wizard chrome (step labels, "Back/Next") is suppressed and dense tables condense for the 896 px chat column. Default viewMode='full' keeps Guided behavior identical. The escape hatch for full interactivity is "Open full editor" → <ModeSwitcher> to /app.

Standalone calculator → agent handoff

/tools/{impedance,link-budget,stackup} show an "Open in agent" CTA when the flag is ON. The CTA writes a free-text seed to sessionStorage['agentSeed'] and navigates to /app/agent; ChatCanvas reads + clears the key on mount and pre-fills ChatInputBar (does not auto-send).

Public demo (planned — signed-out)

The Agent canvas gates behind sign-in (ChatCanvas.tsx → if (!user)), so landing visitors can't watch the agent run. Scripted demo mode was removed (§3.14). The replacement is a public, unauthenticated /demo/agent route that replays a real captured agent run over the production SSE framing and action-card registry — no Gemini quota, deterministic, and honest (it shows only what the real tools produced). Implemented (OpenSpec change add-public-demo-shell) behind FF_PUBLIC_DEMO (backend) / VITE_FF_PUBLIC_DEMO (frontend), both default off pending a reviewed transcript captured via backend/scripts/capture_demo_transcript.py (a grounded seed fixture ships for tests). Backend: routes/demo.py + services/demo/; frontend: components/Demo/DemoAgentShell.tsx + services/chat/demoStream.ts. Closing this is the top funnel fix — see docs/articles/ai-native-positioning.md.

Feature flag

# repo-root .env  (NOT frontend/.env — see CLAUDE.md note about envDir)
VITE_FF_AI_NATIVE_SHELL=true
  • Read once via isAiNativeAvailable() in frontend/src/utils/featureFlags.ts. All call sites import the helper rather than import.meta.env directly — keeps the flag fully mockable in tests and gives us a single chokepoint for future flag-source migrations (e.g., GrowthBook).
  • agentStream.ts is dormant until the flag is ON: import graph is gated, verified by frontend/src/__tests__/featureFlag/dormancy.test.ts (§7.9). If you wire the SSE consumer to a new mount point, add a row to that suite.
  • After editing root .env, fully restart Vite (HMR does not re-read env). Quick trick: touch frontend/vite.config.ts triggers a full server restart.

Key files

Backend (backend/): - services/agent_loop/loop.py — the loop itself (iteration cap, tool dispatch, function-response loopback); agent_loop is a package - services/agent_loop_sse.py — SSE framing + keepalive emitter - services/tool_dispatcher.py — name → executor registry (raises UnknownToolError for unregistered tools) - services/conversation_store.py — server-side conversation persistence (Supabase) - tools/ — per-tool handlers: solver_tool.py, surrogate_tool.py, run_link_budget.py, run_thermal_analysis.py, run_pdn_analysis.py, select_components_batch.py, block_diagram.py, goto_phase.py, stackup_advisor.py, rf_specs.py, generate_export.py (+ generate_export_formats.py), export_bom.py, load_stackup.py, load_bom.py, lookup_component.py, set_active_component.py - services/export/bom_export_service.py — canonical BOM → csv/excel/json serializer (single source; backs export_bom + POST /api/export/bom) - routes/chat_gemini.pyPOST /api/chat/gemini/stream endpoint (JWT verify, project tenancy assertion)

Frontend (frontend/src/): - components/AgentShell/AgentShell.tsx — shell composition (PhaseNavigation + ChatCanvas) - components/Layout/ChatCanvas.tsx — message rendering + seed consumption + scroll - components/Layout/PhaseNavigation.tsx — thin progress strip (mounted by AgentShell, not the wizard) - components/Chat/actionCards/registry.tsxresultType → card component map - services/chat/agentStream.ts — SSE parser (the wire-format owner) - hooks/chat/useGeminiChat.ts — chat state + send dispatcher (legacy + agent paths) - utils/featureFlags.tsisAiNativeAvailable() reader - App.tsx<PhaseProvider> hoisted above <Routes> so <ModeSwitcher> round-trips don't reset state

Tests: - frontend/src/__tests__/featureFlag/dormancy.test.tsagentStream.ts does not load when flag OFF - frontend/src/__tests__/integration/aiNativeParity.test.tsx — flag OFF vs flag ON reach the same final state - frontend/src/__tests__/App.aiNativeShell.test.tsx — route gating + Guided shell at /app

Switching modes

  • /app (Guided wizard) ⇄ /app/agent (chat canvas): ModeSwitcher button in the header of each shell.
  • Switching preserves PhaseProvider state because the provider is hoisted above <Routes> (see CLAUDE.md note + App.tsx).
  • <640 px viewports: the agent shell falls back to the sidebar even when the flag is ON. The canvas needs real estate; mobile users get the Guided sidebar plus chat-as-sidebar. Not a regression — document if you see it.

Common pitfalls

These are spelled out in the planning doc's "Prerequisites & Non-Obvious Gotchas" section but worth repeating here because each has bitten contributors:

  1. The agent loop is the load-bearing change. Every cosmetic edit assumes the loop iterates. Don't ship UI for tools the backend can't dispatch.
  2. Tool errors must never crash the loop. Wrap dispatch in try/except; encode failures as {ok: false, error}. Crashes kill the conversation.
  3. EventSource is structurally blocked — can't carry the JWT header. Stay on fetch + ReadableStream.
  4. StackupDesignView.tsx is prop-driven. Don't introduce a context dep on it — the inline ActionCard render path breaks.
  5. PhaseProvider MUST be above <Routes> in App.tsx. Per-shell mounting silently resets state on ModeSwitcher round-trips.
  6. Tools write client state via clientUpdate; backend stays stateless re: UI. SSE consumer applies applyAgentUpdate(clientUpdate) and on version skew opens ConflictResolverModal.
  7. RLS is the second line of defense. Tools verify project tenancy from JWT context first; RLS catches anything that slips past.
  8. Memoize cards on result.id or a 7-tool turn re-renders 30+ times.
  9. Synthetic local-* conversation ids are a data-loss trap. When hydration fails (classically: a brand-new project whose row hasn't synced to projects yet → conversation INSERT hits the FK → 404), the hook falls back to a client-minted local-* id. append_message can't persist under it, so the whole session silently evaporates on the next remount. Three layers defend this now: hydrateConversation awaits waitForCloudSync(projectId) + retries once; the stream route resolves local-*/missing ids to the real (project, user) conversation via _resolve_conversation_id (routes/chat_gemini.py); ChatCanvas shows a loader while isHydrating instead of flashing the EmptyState.
  10. Action-card buttons must not yank the user out of the agent shell unless navigation is their stated purpose. "Go to …" / "View …" buttons navigate; "Apply Solution" applies the patch via applyAgentUpdate, persists with saveCurrentProject() after the commit (latest-ref effect in SolverInlineCard), and stays put.

Manual smoke

  1. .\start-all-servers-windowed.ps1 with VITE_FF_AI_NATIVE_SHELL=true in root .env.
  2. Open http://localhost:5180/app/agent.
  3. Type: "Design a 5.8 GHz 1 W satellite uplink."
  4. Verify the stream renders: thinking → 5+ tool_call events → final action cards (phase view, BOM, link budget, export-ready).
  5. Reload the page. Verify the conversation hydrates from the Supabase store (no re-stream).
  6. Click "Open full editor" on a stackup card → switches to /app with the same phase state.

Where to file changes

  • Spec changes: update the relevant section of docs/planning/ai_native_transformation/ai-native-transformation.md (the canonical source).
  • Tool registry edits: backend tools/ (handlers, routed via services/tool_dispatcher.py) + frontend components/Chat/actionCards/registry.tsx. Cards need a resultType-keyed entry; tools need a JSON schema for Gemini function declarations.
  • Feature-flag dormancy: every new SSE consumer added to a mount point needs a new row in src/__tests__/featureFlag/dormancy.test.ts.
  • Mobile fallback: keep <640 px on the sidebar path; do not collapse the canvas.