diff --git a/knip.json b/knip.json
index 6789a3bf0..61c243b96 100644
--- a/knip.json
+++ b/knip.json
@@ -1,6 +1,7 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"tags": ["-@public"],
+ "entry": ["src/agent/worker.ts"],
"ignore": [
"src/api/**",
"src/components/ui/**",
diff --git a/package.json b/package.json
index df3d5095f..fedfc80c2 100644
--- a/package.json
+++ b/package.json
@@ -89,6 +89,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+ "comlink": "^4.4.2",
"date-fns": "^4.3.0",
"dexie": "^4.4.2",
"dexie-react-hooks": "^4.4.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 93b497b31..6022c0d8f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -119,6 +119,9 @@ importers:
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ comlink:
+ specifier: ^4.4.2
+ version: 4.4.2
date-fns:
specifier: ^4.3.0
version: 4.3.0
@@ -2833,6 +2836,9 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
+ comlink@4.4.2:
+ resolution: {integrity: sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==}
+
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@@ -7880,6 +7886,8 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
+ comlink@4.4.2: {}
+
comma-separated-tokens@2.0.3: {}
command-line-args@5.2.1:
diff --git a/src/agent/ARCHITECTURE.md b/src/agent/ARCHITECTURE.md
new file mode 100644
index 000000000..0612d1d2e
--- /dev/null
+++ b/src/agent/ARCHITECTURE.md
@@ -0,0 +1,334 @@
+# Agent architecture
+
+This document describes the in-browser agent that lives under [`src/agent/`](src/agent/). It is the implementation behind the AI Chat panel in the v2 editor.
+
+The agent is split across two threads:
+
+- The **main thread** owns the live MobX `ComponentSpec`, the React UI, and the chat store.
+- A dedicated **Web Worker** owns all LLM traffic, the OpenAI Agents SDK runtime, tool execution, prompt assembly, and per-thread conversation memory.
+
+The two halves communicate over [Comlink](https://github.com/GoogleChromeLabs/comlink). The worker holds a Comlink-proxied `ToolBridgeApi` that it invokes whenever a tool needs to read or mutate the spec; the bridge implementation on the main thread routes those calls into MobX actions inside an undo group, so the agent's edits show up in the editor immediately and undo as a single user action.
+
+All LLM requests are routed through the Shopify AI proxy, configured at boot in [`config.ts`](src/agent/config.ts).
+
+## Directory layout
+
+- [`worker.ts`](src/agent/worker.ts) — Web Worker entry point. Exposes `init` and `ask` via Comlink.
+- [`agents/tangleDispatcher.ts`](src/agent/agents/tangleDispatcher.ts) — Top-level dispatcher agent and the `invokeDispatcher` entry point.
+- [`agents/subagents/`](src/agent/agents/subagents/) — Five sub-agents that the dispatcher hands off to.
+- [`tools/`](src/agent/tools/) — Tool definitions: CSOM mutations, registry/docs search, run submission, execution debugging.
+- [`toolBridgeApi.ts`](src/agent/toolBridgeApi.ts) — The contract between the worker and the main-thread bridge. Pure types.
+- [`session.ts`](src/agent/session.ts) — Per-turn `AgentSession` (thread id, bridge proxy, status callback, recent runs, component-reference map).
+- [`middleware/observability.ts`](src/agent/middleware/observability.ts) — Translates SDK lifecycle events into status strings for the UI.
+- [`skills/loader.ts`](src/agent/skills/loader.ts) — ETag-revalidated fetch of `SKILL.md` documents, cached in IndexedDB.
+- [`idb/agentDb.ts`](src/agent/idb/agentDb.ts) — Dexie schema for the skill cache.
+- [`prompts/*.md`](src/agent/prompts/) — Raw prompt text imported via Vite `?raw`.
+- [`config.ts`](src/agent/config.ts) — Env-driven proxy and model configuration.
+- [`types.ts`](src/agent/types.ts) — Shared types (`AgentResponse`, `ComponentRefData`).
+
+## High-level topology
+
+```mermaid
+flowchart LR
+ subgraph mainThread [Main thread]
+ chatStore[AiChat store]
+ agentClient[AgentClient]
+ toolBridge[ToolBridge impl]
+ mobxSpec[(MobX ComponentSpec)]
+ storeActions[Editor store actions]
+ end
+
+ subgraph workerThread [Web Worker]
+ workerEntry[worker.ts]
+ dispatcher[tangle-dispatcher]
+ subAgents[Sub-agents]
+ tools[Tools]
+ memory[MemorySession map]
+ skills[Skills loader + IDB]
+ end
+
+ backend[(Tangle backend)]
+ proxy[(Shopify AI proxy)]
+
+ chatStore --> agentClient
+ agentClient -- "Comlink ask" --> workerEntry
+ workerEntry --> dispatcher
+ dispatcher -- handoff --> subAgents
+ dispatcher --> tools
+ subAgents --> tools
+ tools -- "Comlink bridge call" --> toolBridge
+ toolBridge --> storeActions
+ storeActions --> mobxSpec
+ tools -- "fetch" --> backend
+ dispatcher -- "LLM" --> proxy
+ subAgents -- "LLM" --> proxy
+ workerEntry --> memory
+ workerEntry --> skills
+```
+
+## Request lifecycle
+
+Two views of the same turn. The first is a high-level overview of the LLM round-trip and how external knowledge-base tool calls are resolved through the backend. The second is a detailed trace including handoffs, MobX mutations, and the response shape.
+
+### High-level: chat turn and a knowledge-base tool call
+
+This view collapses the dispatcher / sub-agent topology and the bridge into single boxes. It focuses on the loop between the worker and the LLM proxy, and the round-trip the worker performs to the Tangle backend when the model issues a knowledge-base tool call (`search_components` or `search_docs`).
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant UI as AiChat UI
+ participant Worker as Agent Worker
+ participant LLM as Shopify AI proxy (LLM)
+ participant API as Tangle backend (semantic search)
+
+ UI->>+Worker: ask({ message, threadId })
+ Worker->>+LLM: chat completion (prompt + tool schemas)
+ Note over LLM: Decides it needs
external knowledge
+ LLM-->>-Worker: tool_call: search_components(query)
+ Worker->>+API: POST /api/agent/search_components
+ Note over API: Vector search over
component registry
+ API-->>-Worker: { results: [{ id, name, yamlText, score, ... }] }
+ Worker->>Worker: recordComponentReference per hit
+ Worker->>+LLM: chat completion (+ tool_result)
+ LLM-->>-Worker: final answer (markdown with component:// links)
+ Worker-->>-UI: AgentResponse { answer, componentReferences }
+```
+
+Key points:
+
+- Steps 2-3: the worker drives the agent loop. Every turn issues one or more chat completions to the proxy; the LLM never talks to the backend directly.
+- Steps 4-6: knowledge-base tools (`search_components`, `search_docs`) are plain `fetch` calls inside the worker's tool `execute` body. The backend owns the vector index and embeddings — the worker just forwards the natural-language query and receives ranked hits. See [`searchComponents.ts`](src/agent/tools/searchComponents.ts) and [`searchDocs.ts`](src/agent/tools/searchDocs.ts).
+- Step 7: hits are stored in `session.componentReferences` so that any `[Name](component://id)` link in the final answer can be expanded into a chip on the main thread.
+- Steps 8-9: the worker re-enters the proxy loop with the tool result appended. This may repeat for additional tool calls before the model emits a final answer.
+
+The same shape applies to other read-only tools (`search_docs`, the execution-debug tools, `get_run_status`): LLM emits a `tool_call`, worker hits an HTTP endpoint, result returns to the model. Mutating tools (CSOM) follow the detailed diagram below instead, because they cross the Comlink boundary back to the main thread.
+
+### Detailed: full turn with handoff and bridge
+
+The diagram below traces a single `ask()` turn end-to-end. The very first turn also performs lazy worker spawn and `init`; subsequent turns reuse the same worker and the `MemorySession` keyed on `threadId`.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant UI as AiChat UI
+ participant Client as AgentClient (main)
+ participant Bridge as ToolBridge (main)
+ participant Worker as worker.ts
+ participant Disp as Dispatcher Agent
+ participant Sub as Sub-agent
+ participant Mobx as MobX spec
+
+ UI->>+Client: ask({ message, threadId, ... })
+ Note over Client: First turn only: spawn Worker,
Comlink.wrap, call init(bridge, onStatus)
+ Client->>+Worker: remote.ask(options)
+ activate Worker
+ Worker->>Worker: getOrCreateSession(threadId)
+ Worker->>+Disp: invokeDispatcher(message, session)
+ Disp->>Disp: ensureProxyConfigured()
+ Disp->>Disp: Agent.run(agent, userContent, { session })
+ Disp->>+Bridge: get_pipeline_state (Comlink)
+ Bridge->>Mobx: serializeSpecForAi(spec)
+ Bridge-->>-Disp: AiSpec JSON
+ Disp->>+Sub: handoff (e.g. pipeline-architect)
+ Note over Sub: Status hooks emit
"Building pipeline..."
+ Sub->>+Bridge: add_task / connect_nodes / ... (Comlink)
+ Bridge->>Mobx: undo.withGroup(() => action(...))
+ Bridge-->>-Sub: { success, taskId, ... }
+ Sub-->>-Disp: final text answer
+ Disp-->>-Worker: result.finalOutput
+ Worker->>Worker: scan answer for component://id refs
+ Worker-->>-Client: AgentResponse { answer, threadId, componentReferences }
+ deactivate Worker
+ Client-->>-UI: AgentResponse
+```
+
+Notes on individual steps of the detailed diagram:
+
+- Steps 1-3: lazy spawn is implemented in [`agentClient.ts`](src/routes/v2/pages/Editor/components/AiChat/agentClient.ts). The bridge proxy and status callback must be passed as separate top-level arguments to `init` because Comlink only applies its proxy transfer handler to top-level argument values.
+- Step 4: `MemorySession` per `threadId` lives for the lifetime of the worker. Reload drops it.
+- Steps 6-9: `Agent.run` is the `@openai/agents` driver loop. It iterates LLM → tool calls → LLM until the agent emits final output or hands off.
+- Steps 10-13: a handoff swaps the active agent. Observability hooks are attached to each sub-agent in [`tangleDispatcher.ts`](src/agent/agents/tangleDispatcher.ts) and each subagent factory so status events keep flowing after the handoff.
+- Step 19: see [`worker.ts`](src/agent/worker.ts) lines 95-100 for the substring-match that decides which `componentReferences` to surface to the main thread.
+
+## Dispatcher and sub-agents
+
+The dispatcher is built fresh per turn but pinned to the per-thread `MemorySession` so multi-turn conversation memory survives across calls. It carries only `get_pipeline_state` as a direct tool — every other capability is reachable via handoff.
+
+```mermaid
+flowchart TD
+ dispatcher["tangle-dispatcher
(orchestratorModel)"]
+
+ dispatcher -- "general-purpose Q&A (read-only)" --> generic["generic-assistant
(subagentModel)"]
+ dispatcher -- "build / extend (mutating)" --> architect["pipeline-architect
(orchestratorModel)"]
+ dispatcher -- "fix validation issues (mutating)" --> repair["pipeline-repair
(orchestratorModel)"]
+ dispatcher -- "diagnose runs (read-only)" --> debug["debug-assistant
(subagentModel)"]
+ dispatcher -- "product/docs Q&A" --> help["general-help
(subagentModel)"]
+```
+
+Each sub-agent declares its capability via `handoffDescription`; the dispatcher's prompt ([`prompts/dispatcher.md`](src/agent/prompts/dispatcher.md)) plus the `RECOMMENDED_PROMPT_PREFIX` from `@openai/agents-core` teaches it to pick the right one. The bindings live here:
+
+```30:45:src/agent/agents/tangleDispatcher.ts
+function createDispatcherAgent(session: AgentSession) {
+ const csom = createCsomTools(session.bridge);
+ const agent = Agent.create({
+ name: "tangle-dispatcher",
+ model: config.orchestratorModel,
+ instructions: `${RECOMMENDED_PROMPT_PREFIX}\n\n${dispatcherPrompt}`,
+ tools: [csom.getPipelineState],
+ handoffs: [
+ createGenericAssistantAgent(session),
+ createPipelineArchitectAgent(session),
+ createPipelineRepairAgent(session),
+ createDebugAssistantAgent(session),
+ createGeneralHelpAgent(session),
+ ],
+ });
+```
+
+Tool surface per sub-agent:
+
+- **`generic-assistant`** ([genericAssistant.ts](src/agent/agents/subagents/genericAssistant.ts)) — `get_pipeline_state`, `search_components`. Read-only.
+- **`pipeline-architect`** ([pipelineArchitect.ts](src/agent/agents/subagents/pipelineArchitect.ts)) — full CSOM toolset, `search_components`, `pipelineRunTools`. Mutating + can submit runs.
+- **`pipeline-repair`** ([pipelineRepair.ts](src/agent/agents/subagents/pipelineRepair.ts)) — full CSOM toolset, `search_components`. Mutating.
+- **`debug-assistant`** ([debugAssistant.ts](src/agent/agents/subagents/debugAssistant.ts)) — `get_pipeline_state`, `executionDebugTools`. Read-only. Receives recent runs as appended prompt context.
+- **`general-help`** ([generalHelp.ts](src/agent/agents/subagents/generalHelp.ts)) — `search_components`, `search_docs`. Read-only.
+
+## Tool registry
+
+All tools are `@openai/agents` `tool()` definitions with Zod parameter schemas. They fall into four groups.
+
+### CSOM (live spec mutations)
+
+Defined in [`tools/csomTools.ts`](src/agent/tools/csomTools.ts) via the `createCsomTools(bridge)` factory. Every tool is a thin wrapper that JSON-serializes the bridge response: `get_pipeline_state`, `set_pipeline_name`, `set_pipeline_description`, `add_task`, `delete_task`, `rename_task`, `add_input`, `delete_input`, `rename_input`, `add_output`, `delete_output`, `rename_output`, `connect_nodes`, `delete_edge`, `set_task_argument`, `create_subgraph`, `unpack_subgraph`, `validate_pipeline`.
+
+Schema convention: OpenAI structured-outputs strict mode rejects bare `.optional()`. Every optional field is declared as `.nullable().optional()` and the execute body normalizes `null` to `undefined` before handing data to the bridge. See [`csomTools.ts`](src/agent/tools/csomTools.ts) lines 10-37 and the `dropNulls` helper.
+
+### Registry / docs search
+
+- [`searchComponents.ts`](src/agent/tools/searchComponents.ts) — `search_components`. POSTs to `${tangleApiUrl}/api/agent/search_components`. Every hit is recorded in `session.componentReferences` so that any `[Name](component://id)` link in the final answer can be resolved to a chip on the main thread.
+- [`searchDocs.ts`](src/agent/tools/searchDocs.ts) — `search_docs`. POSTs to `${tangleApiUrl}/api/agent/search_docs`. Result payload includes a pre-formatted `citation` and an explicit `instruction` reminding the model to include the URL.
+
+### Run submission
+
+[`runTools.ts`](src/agent/tools/runTools.ts) — `submit_pipeline_run`, `get_run_status`, `debug_pipeline_run`. Plain `fetch` against the backend, returning `{ success, ... }` JSON.
+
+### Execution debugging
+
+[`debugTools.ts`](src/agent/tools/debugTools.ts) — `get_pipeline_run`, `get_execution_state`, `get_execution_details`, `get_container_state`, `get_container_log`. Read-only. Includes `truncateExecutionDetails` and `truncateContainerState` to keep tool results inside model context windows.
+
+## Tool bridge
+
+The bridge is the only path by which the worker can read or mutate the editor's MobX state. Its contract lives in [`toolBridgeApi.ts`](src/agent/toolBridgeApi.ts) and is imported by both sides for types only. The runtime implementation is on the main thread in [`toolBridge.ts`](src/routes/v2/pages/Editor/components/AiChat/toolBridge.ts).
+
+```mermaid
+flowchart LR
+ workerTool["Worker tool
(execute fn)"]
+ comlinkProxy["Comlink remote
(ToolBridgeApi)"]
+ bridgeImpl["toolBridge.ts
(main thread)"]
+ undoGroup["UndoGroupable
withGroup()"]
+ storeActions["Editor store actions
(task / io / connection / pipeline)"]
+ spec[(MobX ComponentSpec)]
+ serialize["serializeSpecForAi"]
+
+ workerTool -- "postMessage" --> comlinkProxy
+ comlinkProxy --> bridgeImpl
+ bridgeImpl --> undoGroup
+ undoGroup --> storeActions
+ storeActions --> spec
+ bridgeImpl -- "getPipelineState only" --> serialize
+ serialize --> spec
+```
+
+Behavioural notes worth knowing:
+
+- Every mutating bridge method calls the relevant editor action (`addTask`, `connectNodes`, etc.) wrapped in `deps.undo.withGroup(...)` or implicitly via the action itself, so a multi-step agent edit can be undone as one action.
+- `getPipelineState` returns a serialized projection (`AiSpec`) computed by [`serializeSpecForAi.ts`](src/routes/v2/pages/Editor/components/AiChat/serializeSpecForAi.ts), not the raw MobX tree. This is the only spec data the model ever sees.
+- `addTask` calls `hydrateComponentReference` so a `search_components` result, which may only carry `url`, is expanded into a full `ComponentReference` before insertion.
+
+## Sessions and memory
+
+Three different "session" concepts coexist; do not confuse them.
+
+- **`AgentSession`** ([`session.ts`](src/agent/session.ts)) — per-turn, in-worker. Carries `threadId`, the Comlink bridge, the status callback, recent pipeline runs (for the debug assistant), and a `componentReferences` map that accumulates hits across all `search_components` calls in the turn.
+- **`MemorySession`** (from `@openai/agents`) — per-thread, lives for the lifetime of the worker. Provides the agent's multi-turn message memory. Created lazily in [`tangleDispatcher.ts`](src/agent/agents/tangleDispatcher.ts) lines 18-26 and keyed on `threadId`. It is not persisted across worker reloads.
+- **AiChat store** ([`aiChatStore.ts`](src/routes/v2/pages/Editor/components/AiChat/aiChatStore.ts)) — main-thread MobX store. Owns the user-visible chat history and is independent from `MemorySession`.
+
+The `componentReferences` map drives the chip-rendering convention used in prompts: a sub-agent emits `[Display Name](component://component-id)` in markdown, and `worker.ts` filters `session.componentReferences` down to the ones actually referenced before returning:
+
+```95:100:src/agent/worker.ts
+ const componentReferences: AgentResponse["componentReferences"] = {};
+ for (const [id, ref] of session.componentReferences) {
+ if (result.answer.includes(`component://${id}`)) {
+ componentReferences[id] = { name: ref.name, yamlText: ref.yamlText };
+ }
+ }
+```
+
+This is a known code smell; see the caveats section.
+
+## Observability
+
+The worker has no access to the React tree, so the only status surface the user sees is a single line of text that the chat panel renders while the agent is running. [`middleware/observability.ts`](src/agent/middleware/observability.ts) wires that surface up by attaching listeners to each agent's `EventEmitter` and translating raw SDK events into pre-mapped status strings, which are forwarded over the Comlink-proxied `onStatus` callback received during `init`.
+
+Events handled:
+
+- `agent_start` → `"Thinking..."`
+- `agent_end` → `"Preparing response..."`
+- `agent_tool_start` → tool-specific label (see `TOOL_STATUS_LABELS` in [observability.ts](src/agent/middleware/observability.ts) lines 18-43)
+- `agent_handoff` → sub-agent-specific label (`SUB_AGENT_LABELS` lines 45-51)
+
+Because each sub-agent has its own emitter, `attachObservabilityHooks` is called on the dispatcher and on every sub-agent (inside each factory) — otherwise the status line would freeze immediately after a handoff.
+
+SDK tracing is disabled in [`config.ts`](src/agent/config.ts) line 71. The official exporter would try to POST to `api.openai.com`, which is unreachable through the Shopify proxy.
+
+## Skills loader and IndexedDB
+
+[`skills/loader.ts`](src/agent/skills/loader.ts) implements lazy, ETag-revalidated loading of `SKILL.md` files from the configured `skillsBaseUrl`. Each load:
+
+1. Reads any cached `{ content, version }` from IndexedDB.
+2. Issues `fetch` with `If-None-Match: ` when the cache exists.
+3. On `304` returns the cached content; on `200` overwrites the cache; on any other error falls back to the cache if one exists, otherwise throws.
+
+Results are deduped via an `inflight` map so concurrent callers in the same worker share one request.
+
+The cache lives in Dexie. The schema went from v1 to v2 to drop the now-unused `vectorStores` table — semantic search is delegated to the backend now (see [`searchComponents.ts`](src/agent/tools/searchComponents.ts) and [`searchDocs.ts`](src/agent/tools/searchDocs.ts)):
+
+```23:32:src/agent/idb/agentDb.ts
+agentDb.version(1).stores({
+ vectorStores: "key",
+ skills: "id",
+});
+
+agentDb.version(2).stores({
+ vectorStores: null,
+ skills: "id",
+});
+```
+
+The worker eagerly warms two skill caches at `init` time: `tangleBestPractices` and `componentYamlFormat`. No sub-agent currently injects skill content into its prompt — the loader is infrastructure for the next iteration.
+
+## Configuration
+
+All runtime knobs come from Vite env vars at build time. Defaults live in [`config.ts`](src/agent/config.ts):
+
+- `VITE_AI_PROXY_BASE_URL` (default `https://proxy.shopify.ai/v1`) — base URL for the proxy.
+- `VITE_AI_PROXY_TOKEN` — required at runtime. The proxy token is currently shipped to the browser; securing it is out of scope for this experiment.
+- `VITE_AGENT_ORCHESTRATOR_MODEL` (default `claude-sonnet-4-6`) — used for the dispatcher and the two mutating sub-agents.
+- `VITE_AGENT_SUBAGENT_MODEL` (default `claude-haiku-4-5`) — used for the read-only sub-agents.
+- `VITE_BACKEND_API_URL` (default `http://localhost:8000`) — Tangle backend; `searchComponents`, `searchDocs`, run tools, and debug tools all hit this host.
+- `VITE_AGENT_SKILLS_BASE_URL` (default `/agent-skills`) — root used by the skills loader.
+
+`ensureProxyConfigured()` is idempotent and runs at the start of every `invokeDispatcher`. It points `@openai/agents` at the Shopify proxy using `setDefaultOpenAIClient`, forces the Chat Completions surface (Claude is served through it), and disables tracing.
+
+## Caveats and known smells
+
+These are documented because they are easy to trip over when changing the agent.
+
+- **`process` polyfill in `worker.ts`**. `@openai/agents-core` v0.4.x reads `process.env.OPENAI_AGENTS__DEBUG_SAVE_SESSION` without a `typeof process` guard. The polyfill at [`worker.ts`](src/agent/worker.ts) lines 28-39 stubs `globalThis.process = { env: {} }` and deliberately omits `.on` / `.exit` so the SDK's `typeof process.on === "function"` checks still skip Node-only branches. A second `hasOwnProperty` check defeats Rolldown tree-shaking in `vite build`. SDK upgrades may break this.
+- **`@openai/agents-core/_shims` alias** in `vite.config.js`. Same root cause; resolves the SDK's shim module to its browser variant inside the worker bundle.
+- **Comlink top-level proxy rule**. `Comlink.proxy(bridge)` and `Comlink.proxy(onStatus)` MUST be passed as separate top-level arguments to `init`. Wrapping them in a single object argument would cause Comlink to structured-clone the bridge methods and fail. See [`agentClient.ts`](src/routes/v2/pages/Editor/components/AiChat/agentClient.ts) lines 62-69.
+- **`componentReferences` substring match**. [`worker.ts`](src/agent/worker.ts) lines 95-100 filters the per-turn references map by whether the final answer text contains `component://`. It works in practice but is brittle: any markdown reformat that changes the link shape silently drops the chip. Replacing it with explicit tool-result reference emission is a documented follow-up.
+- **No `useCallback` / `useMemo`** anywhere downstream of this module. The v2 scope is under React Compiler, which handles memoization automatically — see the workflow rules.
+
+For the broader decision tree on worker-vs-main-thread and the future of this stack, see [`.local/agent-loop-tech-design.md`](.local/agent-loop-tech-design.md).
diff --git a/src/agent/types.ts b/src/agent/types.ts
new file mode 100644
index 000000000..a41daa266
--- /dev/null
+++ b/src/agent/types.ts
@@ -0,0 +1,9 @@
+/**
+ * Shared types between the worker and the main thread.
+ */
+
+export interface AgentResponse {
+ answer: string;
+ threadId: string;
+ componentReferences: Record;
+}
diff --git a/src/agent/worker.ts b/src/agent/worker.ts
new file mode 100644
index 000000000..031c55cee
--- /dev/null
+++ b/src/agent/worker.ts
@@ -0,0 +1,86 @@
+/**
+ * Web Worker entry point for the in-browser agent.
+ *
+ * A placeholder `ask()` that echoes the user's message — it
+ * proves the bundling, lazy-spawn, and Comlink round-trip are working
+ * end-to-end before we wire the LLM and the tool bridge.
+ *
+ * The `globalThis.process` stub below covers an unguarded
+ * `process.env.X` read in `@openai/agents-core` v0.4.x
+ * (`runner/sessionPersistence.mjs` reads
+ * `process.env.OPENAI_AGENTS__DEBUG_SAVE_SESSION` without a
+ * `typeof process` guard) that would otherwise throw
+ * `ReferenceError: process is not defined` on every turn that
+ * persists session state. The stub deliberately omits `.on` /
+ * `.exit` so the SDK's `typeof process.on === 'function'` checks
+ * still skip the Node-only branches and we do not pretend to be
+ * Node. It is inlined here (rather than living in a separate
+ * polyfill file) so Rolldown's worker bundle does not tree-shake
+ * the side-effect import, and so it runs in both `vite build` and
+ * `vite serve` (dev) modes, which `worker.rolldownOptions.define`
+ * would not.
+ */
+const __workerGlobal = globalThis as { process?: { env?: unknown } };
+if (typeof __workerGlobal.process === "undefined") {
+ __workerGlobal.process = { env: {} };
+}
+// Anchor: keep Rolldown from tree-shaking the assignment above. In
+// `vite build` Vite statically replaces `process.env` with `{}`, so the
+// rest of the bundle never reads `globalThis.process` and Rolldown
+// would otherwise treat the write as dead. Throwing on a hasOwnProperty
+// check forces the write to be observable.
+if (!Object.prototype.hasOwnProperty.call(globalThis, "process")) {
+ throw new Error("Tangle agent worker: globalThis.process polyfill failed");
+}
+
+import * as Comlink from "comlink";
+
+import type { AgentResponse } from "./types";
+
+export interface AskParams {
+ message: string;
+ threadId?: string;
+}
+
+export interface AgentWorkerApi {
+ ask(params: AskParams): Promise;
+ ping(): Promise<"pong">;
+}
+
+let emitStatus: (status: { text: string }) => void = () => {};
+
+function generateThreadId(): string {
+ return `thread-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+}
+
+const api: AgentWorkerApi = {
+ async ping() {
+ return "pong";
+ },
+
+ async ask({ message, threadId }) {
+ /**
+ * todo: replace with actual AI response.
+ */
+ return {
+ answer: `Worker echo: ${message}`,
+ threadId: threadId ?? generateThreadId(),
+ componentReferences: {},
+ };
+ },
+};
+
+/**
+ * Initialization entry point. Called once by the main thread immediately
+ * after spawning the worker. Splits init from ask() so future bridge /
+ * skill plumbing has an explicit lifecycle hook.
+ */
+export function init(onStatus: (status: { text: string }) => void): void {
+ emitStatus = onStatus;
+ // TODO: read `emitStatus` from the dispatcher's observability hooks;
+ // until then this no-op read keeps `noUnusedLocals` quiet without
+ // dropping the assignment that locks in the init() contract.
+ void emitStatus;
+}
+
+Comlink.expose({ ...api, init });
diff --git a/src/routes/v2/pages/Editor/components/AiChat/agentClient.ts b/src/routes/v2/pages/Editor/components/AiChat/agentClient.ts
new file mode 100644
index 000000000..c776fe590
--- /dev/null
+++ b/src/routes/v2/pages/Editor/components/AiChat/agentClient.ts
@@ -0,0 +1,76 @@
+/**
+ * Main-thread client for the in-browser agent worker.
+ *
+ * Spawns a single Web Worker (lazy, on first use), wires it up over
+ * Comlink, and exposes a typed `ask()` method that the AI Chat store
+ * calls.
+ */
+import * as Comlink from "comlink";
+
+import type { AgentResponse } from "@/agent/types";
+import type { AgentWorkerApi } from "@/agent/worker";
+
+interface WorkerExports extends AgentWorkerApi {
+ init(onStatus: (status: { text: string }) => void): void;
+}
+
+interface InitDeps {
+ onStatus: (status: { text: string }) => void;
+}
+
+interface AskOptions {
+ message: string;
+ threadId?: string;
+}
+
+class AgentClient {
+ private worker: Worker | null = null;
+ private remote: Comlink.Remote | null = null;
+ private initPromise: Promise | null = null;
+
+ private async ensureInit(
+ deps: InitDeps,
+ ): Promise> {
+ if (!this.worker) {
+ this.worker = new Worker(new URL("@/agent/worker.ts", import.meta.url), {
+ type: "module",
+ name: "tangle-agent",
+ });
+ this.remote = Comlink.wrap(this.worker);
+ }
+ if (!this.remote) {
+ throw new Error("Worker remote was not created");
+ }
+ if (!this.initPromise) {
+ // Pass onStatus as a top-level Comlink-proxied arg.
+ // Each new one must stay a separate top-level argument because Comlink only applies its proxy
+ // transfer handler to top-level argument values, it does not
+ // recursively walk into properties of an object arg.
+ //
+ // Wrapping proxied values in a single object would cause structured-clone
+ // of the methods and fail.
+ this.initPromise = this.remote.init(Comlink.proxy(deps.onStatus));
+ }
+ await this.initPromise;
+ return this.remote;
+ }
+
+ async ask(deps: InitDeps, options: AskOptions): Promise {
+ const remote = await this.ensureInit(deps);
+ return remote.ask(options);
+ }
+
+ terminate(): void {
+ this.worker?.terminate();
+ this.worker = null;
+ this.remote = null;
+ this.initPromise = null;
+ }
+}
+
+let singleton: AgentClient | null = null;
+
+export function getAgentClient(): AgentClient {
+ if (!singleton) singleton = new AgentClient();
+ return singleton;
+}
diff --git a/src/routes/v2/pages/Editor/components/AiChat/aiChatStore.ts b/src/routes/v2/pages/Editor/components/AiChat/aiChatStore.ts
index c6248e49a..f8c52ddbd 100644
--- a/src/routes/v2/pages/Editor/components/AiChat/aiChatStore.ts
+++ b/src/routes/v2/pages/Editor/components/AiChat/aiChatStore.ts
@@ -2,6 +2,7 @@ import { action, makeObservable, observable, runInAction } from "mobx";
import { getErrorMessage } from "@/utils/string";
+import { getAgentClient } from "./agentClient";
import type { ChatMessage } from "./aiChat.types";
function generateMessageId(): string {
@@ -15,9 +16,6 @@ interface SendMessageOptions {
/**
* Stores AI chat state (messages, thread, pending status) outside the
* React component tree so it survives window minimize / hide / unmount.
- *
- * PR 1: `sendMessage` is a stub that appends a hardcoded assistant echo.
- * The real worker / LLM round-trip lands in PR 2 / PR 3.
*/
export class AiChatStore {
@observable.shallow accessor messages: ChatMessage[] = [];
@@ -55,17 +53,30 @@ export class AiChatStore {
});
try {
- /**
- * Echo the user's message back to the user.
- * TODO: replace with actual AI response.
- */
+ const client = getAgentClient();
+ const response = await client.ask(
+ {
+ onStatus: (status) => {
+ runInAction(() => {
+ this.thinkingText = status.text;
+ });
+ },
+ },
+ {
+ message: prompt,
+ ...(this.threadId && { threadId: this.threadId }),
+ },
+ );
+
runInAction(() => {
+ this.thinkingText = null;
+ this.threadId = response.threadId;
this.messages = [
...this.messages,
{
id: generateMessageId(),
role: "assistant",
- content: `${prompt}`,
+ content: response.answer,
},
];
});
diff --git a/vite.config.js b/vite.config.js
index 448d98e75..84e478850 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -74,6 +74,13 @@ export default defineConfig(({ mode }) => {
},
},
assetsInclude: ["**/*.yaml", "**/*.py"],
+ // The agent runs in a Web Worker. The `@openai/agents-core/_shims`
+ // alias and matching plugin land in PR 3 alongside the SDK
+ // dependency itself; PR 2 only needs ESM output for the worker
+ // bundle so dynamic `import.meta.url` resolves correctly.
+ worker: {
+ format: "es",
+ },
test: {
globals: true,
environment: "jsdom",