Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
feat(agent): add runtime code asset tools
  • Loading branch information
DavidBabinec committed Jun 17, 2026
commit ee7c396a65b5fa689302ae49d879ea8845abfff7
30 changes: 22 additions & 8 deletions docs/features/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ The agent runs on a provider-agnostic AI runtime (`server/ai/`) that can drive a

- **Structure via HTML.** `insertHtml` and `replaceNodeHtml` accept semantic HTML strings; the browser executor calls `importHtml` (the same pipeline as the paste-HTML UI) to convert them into first-class, editable `PageNode`s.
- **Styling via CSS.** The agent emits CSS the same way a human pastes it: a `<style>` block and/or `class=` attributes inside the `insertHtml`/`replaceNodeHtml` payload, or the standalone `applyCss` tool. The importer (`cssToStyleRules`) classifies every selector — a bare `.foo {}` rule becomes a reusable Selectors-panel class bound to `class="foo"`; any other selector (`.hero a`, `a:hover`, `nav > li`) becomes an ambient rule; `style=` attributes land on the node's inline styles. There is no structured `classes` parameter — the agent never hand-builds classes node-by-node at insert time. `applyCss` is the single tool for authoring/editing CSS on its own; it **upserts**, so re-applying a selector edits the existing rule (the way descendant/pseudo rules get restyled).
- **29 tools total.** 7 server-side read tools (resolved server-side from the posted snapshot / DB) + 22 browser-bridged write tools.
- **Two-endpoint bridge.** `POST /admin/api/ai/chat/site` opens an NDJSON stream. When the model calls a write tool, the server emits `toolRequest`; the browser executor applies it to the editor store and POSTs the `AiToolOutput` result to `POST /admin/api/ai/tool-result`.
- **35 tools total.** 6 server-side catalog read tools (resolved server-side from the posted snapshot / DB) + 29 browser-bridged tools.
- **Two-endpoint bridge.** `POST /admin/api/ai/chat/site` opens an NDJSON stream. When the model calls a browser-bridged tool, the server emits `toolRequest`; the browser executor reads or mutates the editor store and POSTs the `AiToolOutput` result to `POST /admin/api/ai/tool-result`.
- **Provider-agnostic.** The runtime selects a driver (Anthropic, OpenAI, OpenRouter, Ollama) from the conversation's configured credential.
- **Tool input schemas are a single source of truth** in `@core/ai` (`src/core/ai/toolSchemas.ts`). The server tool registry (`server/ai/tools/site/writeTools.ts`) and the browser executor (`executor.ts` + `tokenRunners.ts`) import the exact same schema objects — a constraint added once is enforced on both sides at build time. Gated by `ai-tool-schema-ssot.test.ts` and `ai-tools-typebox-only.test.ts`.
- **Capabilities.** `ai.chat` required to stream; `ai.tools.write` required for write tools. Gated by `ai-handlers-capability-gated.test.ts`.
Expand Down Expand Up @@ -165,7 +165,7 @@ Server: chat.ts
│ → browser reads or opens the target page/template/visual component
│ → result returned to model
└─→ write tool (e.g. insertHtml)
└─→ browser-bridged mutating tool (e.g. insertHtml)
→ bridge.callBrowser(toolName, input)
→ emit { type: 'toolRequest', requestId, toolName, input }
→ driver loop pauses; awaits tool-result POST
Expand All @@ -174,7 +174,7 @@ NDJSON stream events (one JSON object + \n per line):
{ type: 'bridgeReady', bridgeId }
{ type: 'text', text: '…' }
{ type: 'toolCall', toolCallId, toolName, input, status: 'pending' }
{ type: 'toolRequest', requestId, toolName, input } ← write tools only
{ type: 'toolRequest', requestId, toolName, input } ← browser-bridged tools only
{ type: 'toolResult', toolCallId, toolName, ok, error? }
{ type: 'usage', promptTokens, completionTokens, costUsd?, cacheReadTokens?, cacheCreationTokens? }
{ type: 'context', contextTokens } ← per-round meter update
Expand All @@ -192,7 +192,7 @@ Browser: processStreamEvent(event) in streamEvents.ts
└─→ 'text' / 'toolCall' / 'toolResult' / 'done' → update agentSlice.agentMessages
```

The two-endpoint design keeps the **browser as editor-store authority** (write tools mutate the live Zustand store in the browser) while the **server runs the model** (driver + tool routing live server-side).
The two-endpoint design keeps the **browser as editor-store authority** (browser-bridged tools read or mutate the live Zustand store in the browser) while the **server runs the model** (driver + tool routing live server-side).

---

Expand Down Expand Up @@ -301,9 +301,9 @@ Resolved server-side from the posted `SiteAgentSnapshot` or the data repositorie
| `list_loop_sources` | Loop source ids, source fields, order/filter options, and data-table field catalogs with valid `{currentEntry.field}` tokens. For post/custom table loops, use source id `data.rows`, the returned table `id` as `<instatic-loop data-table-id>`, and the returned tokens inside the loop body |
| `list_tokens` | Design tokens: colors (with shades/tints), typography/spacing scale steps, font tokens — each with CSS variable + utility classes; optional `family` filter (`colors`\|`typography`\|`spacing`\|`fonts`) |

### Browser tools — 24, browser-bridged
### Browser tools — 29, browser-bridged

All 24 tools carry `execution: 'browser'` in their `AiTool` definition. The server emits `toolRequest`; the browser executor validates input with TypeBox, runs the store action or read helper, and POSTs the canonical `AiToolOutput` result back.
All 29 tools carry `execution: 'browser'` in their `AiTool` definition. The server emits `toolRequest`; the browser executor validates input with TypeBox, runs the store action or read helper, and POSTs the canonical `AiToolOutput` result back.

**Documents**

Expand Down Expand Up @@ -366,6 +366,20 @@ The agent calls `list_loop_sources` first to get the valid source id, data table
| `assignClass` | `{ nodeId, classId }` | none | Attach an existing class to a node; `classId` accepts id or name|
| `removeClass` | `{ nodeId, classId }` | none | Detach a class from a node (the class itself remains) |

**Code assets**

Scripts and user stylesheets live in `site.files[]`; runtime targeting and loading options live in `site.runtime.scripts` / `site.runtime.styles`. These tools expose that existing Code Editor storage to the agent, so behavior such as theme toggles, tabs, menus, filters, and DOM-ready interactions is authored as a real runtime script instead of attempted through HTML import.

| Tool | Input | Success `data` | What it does |
|------------------------|--------------------------------------------|-----------------------------------------|-------------------------------------------------------|
| `list_code_assets` | `{ type?: 'script' \| 'style' }` | `{ assets }` | List runtime code assets with file ids, paths, full-content hashes, sizes, timestamps, and runtime config |
| `read_code_asset` | `{ fileId? \| path?, part?, maxChars? }` | `{ fileId, path, type, content, hash, runtime, pageInfo }` | Read an exact script/stylesheet content slice. The `hash` is for the full file; page through with `pageInfo.nextPart` |
| `write_code_asset` | `{ path, type, content, runtime? }` | asset summary + `{ action }` | Create or replace a runtime script/stylesheet and normalize its runtime config. Existing paths are updated, new paths are created |
| `patch_code_asset` | `{ fileId? \| path?, expectedHash, replacements }` | asset summary + `{ replacements }` | Apply exact text replacements only when `expectedHash` matches the latest content. Ambiguous matches require a wider `oldText` or explicit `replaceAll:true` |
| `inspect_code_runtime` | `{ document?: { type, id } }` | `{ pageId, document, scripts, styles }` | Report which runtime scripts/stylesheets apply to the current page/template or supplied page/template document ref |

`insertHtml` / `replaceNodeHtml` intentionally strip `<script>` elements and inline event handlers (`onclick`, `onload`, etc.). When a request needs behavior, the agent should use `write_code_asset({ type: "script", ... })` and then `inspect_code_runtime`, not raw `<script>` tags or event attributes in HTML.

**Pages**

| Tool | Input | Success `data` | What it does |
Expand Down Expand Up @@ -610,7 +624,7 @@ When `POST /admin/api/ai/credentials` creates a new credential, `seedEmptyDefaul
- `src/core/ai/documentRefs.ts` — document refs/descriptors for pages, templates, and visual components
- `src/core/ai/readSurface.ts` — runtime-agnostic `renderAgentDocument` annotated HTML + compact CSS renderer
- `src/core/ai/index.ts` — barrel re-exporting the above
- `server/ai/tools/site/writeTools.ts` — 24 browser-bridged site tool definitions (uses `@core/ai` input schemas)
- `server/ai/tools/site/writeTools.ts` — 29 browser-bridged site tool definitions (uses `@core/ai` input schemas)
- `server/ai/tools/site/readTools.ts` — 6 server-side catalog tool definitions
- `server/ai/tools/site/render.ts` — `describeAgentModules`, `describeAgentTokens`, `filterTokenFamily`
- `server/ai/tools/site/systemPrompt.ts` — HTML-native system prompt
Expand Down
2 changes: 2 additions & 0 deletions docs/features/html-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ Callers splice the fragment into the page tree via `insertImportedNodes(parentId
| `style="…"` attributes | Declarations harvested onto `node.inlineStyles`; the attribute is removed |
| HTML comments and processing instructions | Stripped silently — no count |

The AI agent should not use stripped constructs for behavior. If an edit needs JavaScript, it writes a real runtime script with `write_code_asset({ type: "script", ... })` and verifies targeting with `inspect_code_runtime` instead of embedding `<script>` or `onclick` in an HTML import.

After insert, `ImportHtmlModal` builds a toast body from the added-selector count plus the non-zero stripped counts, e.g. `"3 CSS selectors, stripped 2 <script>"`. If nothing notable happened, the toast shows only the node count.

**Inline `style="…"` → `node.inlineStyles`.** Before `stripUnsafe` removes a `style` attribute, `harvestInlineStyles` (`inlineStyle.ts`) reads the element's parsed CSSOM declaration and copies **every** declaration into a camelCase bag, dropping only property names rejected by `isEmittableProperty` (the publisher's security denylist — the same gate `cssToStyleRules` uses). A `url(…)` background is canonicalised to `url('payload')` form so the Super Import asset rewriter and the editor's `BackgroundImageControl` recognise it. The bag is attached to the produced node as `node.inlineStyles` — the editor's first-class per-node `style=""` layer — which the publisher emits verbatim and the user edits via the Properties panel's inline-style mode. In Super Import any `url(…)` is uploaded to the media library and rewritten to its media URL.
Expand Down
3 changes: 3 additions & 0 deletions server/ai/tools/site/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const READ_ONLY_NAMES_IN_WRITE_FILE = new Set([
'getNodeHtml',
'read_document',
'open_document',
'list_code_assets',
'read_code_asset',
'inspect_code_runtime',
'render_snapshot',
])

Expand Down
6 changes: 6 additions & 0 deletions server/ai/tools/site/systemPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ Structure as HTML, styling as CSS:
- applyCss is the ONE tool for authoring or editing CSS on its own — after insertion, or for any selector a class= can't express. Pass real CSS text: a bare \`.foo { … }\` selector creates/edits a reusable class; ANY other selector (\`.hero a\`, \`a:hover\`, \`nav > li\`, \`.card::before\`, \`h1\`) creates/edits an ambient rule that attaches by matching. Re-applying a selector MERGES onto it, so applyCss both creates AND edits — that is how you restyle an existing descendant/pseudo rule (e.g. \`applyCss(".hero a:hover { color: var(--primary) }")\`). There is no class-by-id patch tool; just write the CSS, referencing tokens via var(--…).
- Per-breakpoint variation: use @media queries — in the <style> block of an insert, or inside applyCss — with min/max-width queries that line up with the breakpoint widths in the dynamic suffix. Don't invent "mobile"/"tablet"/"desktop".

Behavior and runtime code:
- insertHtml/replaceNodeHtml deliberately strip <script> and inline event handlers (onclick/onload/etc). NEVER try to add behavior with <script>, onclick, or custom inline JS in HTML.
- To add behavior such as theme toggles, tabs, menus, filters, or DOM-ready interactions, use write_code_asset({ type:"script", path:"src/scripts/...", content, runtime }). The script file is stored in the site file layer and loaded through site.runtime.
- Before changing existing scripts or user stylesheets, call list_code_assets/read_code_asset. Patch exact spans with patch_code_asset using the latest hash; if the text occurs multiple times, use a larger oldText span or replaceAll:true intentionally.
- Use inspect_code_runtime after writing code to confirm scripts/styles apply to the current page/template, are enabled, and have the intended priority/placement/timing.

Responsive:
- Design for every breakpoint in the suffix from the start. All variation is CSS via @media (in an insert's <style> block or applyCss), matched against the suffix breakpoint widths.

Expand Down
64 changes: 64 additions & 0 deletions server/ai/tools/site/writeTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ import {
ApplyCssInputSchema,
AssignClassInputSchema,
RemoveClassInputSchema,
ListCodeAssetsInputSchema,
ReadCodeAssetInputSchema,
WriteCodeAssetInputSchema,
PatchCodeAssetInputSchema,
InspectCodeRuntimeInputSchema,
AddPageInputSchema,
DeletePageInputSchema,
RenamePageInputSchema,
Expand Down Expand Up @@ -210,6 +215,60 @@ const removeClassTool: AiTool = {
inputSchema: RemoveClassInputSchema,
}

// ---------------------------------------------------------------------------
// Code asset tools — scripts and user stylesheets in site.files + site.runtime
// ---------------------------------------------------------------------------

const listCodeAssetsTool: AiTool = {
name: 'list_code_assets',
scope: 'site',
execution: 'browser',
requiredCapabilities: ['site.read'],
description:
'List user-authored runtime code assets stored in the site file layer. Optional `type` filters to scripts or styles. Returns file ids, paths, content hashes, size metadata, and current runtime config. Use before read_code_asset / patch_code_asset when modifying existing scripts or stylesheets.',
inputSchema: ListCodeAssetsInputSchema,
}

const readCodeAssetTool: AiTool = {
name: 'read_code_asset',
scope: 'site',
execution: 'browser',
requiredCapabilities: ['site.read'],
description:
'Read one script or stylesheet by fileId or path. Returns the exact content slice, full-file SHA-256 hash, runtime config, and pageInfo for pagination. If pageInfo.nextPart is not null, call read_code_asset again with the same asset and part.',
inputSchema: ReadCodeAssetInputSchema,
}

const writeCodeAssetTool: AiTool = {
name: 'write_code_asset',
scope: 'site',
execution: 'browser',
requiredCapabilities: SITE_STRUCTURE_CAPS,
description:
'Create or replace a runtime script/style file in site.files and attach normalized site.runtime config. Use `type:"script"` for behavior such as theme toggles, menus, tabs, analytics hooks, and DOM-ready interactions; use `type:"style"` for global user stylesheets that should load as files. `path` is a safe site-relative path such as src/scripts/theme-toggle.js or src/styles/theme.css. `runtime` is optional and merges with existing/default config.',
inputSchema: WriteCodeAssetInputSchema,
}

const patchCodeAssetTool: AiTool = {
name: 'patch_code_asset',
scope: 'site',
execution: 'browser',
requiredCapabilities: SITE_STRUCTURE_CAPS,
description:
'Patch an existing script or stylesheet by exact text replacement. Requires the latest `expectedHash` from read_code_asset/list_code_assets to prevent stale edits. Each replacement must match exactly; if oldText occurs multiple times, either make oldText more specific or set replaceAll:true.',
inputSchema: PatchCodeAssetInputSchema,
}

const inspectCodeRuntimeTool: AiTool = {
name: 'inspect_code_runtime',
scope: 'site',
execution: 'browser',
requiredCapabilities: ['site.read'],
description:
'Inspect which runtime scripts and user stylesheets apply to the current page/template, or to a supplied page/template document ref. Returns each asset path, enabled state, scope applicability, priority, and script placement/timing. Use after write_code_asset to confirm a script/style is targeted correctly.',
inputSchema: InspectCodeRuntimeInputSchema,
}

// ---------------------------------------------------------------------------
// Page-level write tools
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -363,6 +422,11 @@ export const siteWriteTools: AiTool[] = [
applyCssTool,
assignClassTool,
removeClassTool,
listCodeAssetsTool,
readCodeAssetTool,
writeCodeAssetTool,
patchCodeAssetTool,
inspectCodeRuntimeTool,
addPageTool,
deletePageTool,
renamePageTool,
Expand Down
Loading