The AI Agent is a model-powered assistant integrated into the Site editor and Content workspace. The shared Agent Panel owns conversation state, provider selection, streaming, history, and the browser bridge; each workspace supplies its own snapshot builder and tool executor.
In the Site editor, the agent reads the current page snapshot, plans a sequence of edits, and executes them by calling tools. Structure is written as semantic HTML (site_insert_html / site_replace_node_html); styling is written as CSS — a <style> block and/or class= attributes inside the insert, or the dedicated site_apply_css tool for authoring/editing any CSS on its own. There is one CSS path and it accepts every selector; site_assign_class / site_remove_class attach existing classes to nodes.
In the Content workspace, the agent works against content collections and entries. It reads collection schemas and document state server-side, then mutates the live content editor through a browser bridge so the open draft, Tiptap body editor, and sidebar selection stay authoritative.
The agent runs on a provider-agnostic AI runtime (server/ai/) that can drive any supported model (Anthropic Claude, OpenAI, OpenRouter, Ollama, or any OpenAI-compatible endpoint). Every driver talks directly to its provider's REST API over HTTP/SSE — no provider SDKs. All drivers share one multi-turn tool loop (drivers/http/toolLoop.ts); each supplies only a small ProviderAdapter of pure mapping functions. The plain @anthropic-ai/sdk (and any provider SDK) is banned repo-wide. Gated by ai-driver-isolation.test.ts.
- Structure via HTML.
site_insert_htmlandsite_replace_node_htmlaccept semantic HTML strings; the browser executor callsimportHtml(the same pipeline as the paste-HTML UI) to convert them into first-class, editablePageNodes. - Styling via CSS. The agent emits CSS the same way a human pastes it: a
<style>block and/orclass=attributes inside thesite_insert_html/site_replace_node_htmlpayload, or the standalonesite_apply_csstool. The importer (cssToStyleRules) classifies every selector — a bare.foo {}rule becomes a reusable Selectors-panel class bound toclass="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 structuredclassesparameter — the agent never hand-builds classes node-by-node at insert time.site_apply_cssis the single tool for CSS on its own, with explicit merge, replace, rule-delete, and property-removal operations; exact selector identity and!importantpriority survive the round trip. - Site scope: 35 tools total. 6 server-side catalog read tools (resolved server-side from the posted snapshot / DB) + 29 browser-bridged tools.
- Content scope: 15 tools total. 7 server-side content/catalog/media/user read tools + 8 browser-bridged document mutation/navigation tools.
- Two-endpoint bridge.
POST /admin/api/ai/chat/:scopeopens an NDJSON stream. When the model calls a browser-bridged tool, the server emitstoolRequest; the browser executor reads or mutates the live workspace and POSTs theAiToolOutputresult toPOST /admin/api/ai/tool-result. - Provider-agnostic. The runtime selects a driver (Anthropic, OpenAI, OpenRouter, Ollama, Custom Provider) from the conversation's configured credential.
- Site tool input schemas are a single source of truth in
@core/ai(src/core/ai/toolSchemas.ts). The server registry and browser executor import from that shared leaf. Most tools reuse the exact same schema object;site_apply_cssdeliberately advertises a flat provider object because Anthropic rejects root-level schema composition, then the executor validates the payload against the leaf's exact operation union. Gated byai-tool-input-object.test.ts,ai-tool-schema-ssot.test.ts, andai-tools-typebox-only.test.ts. - Capabilities.
ai.chatrequired to stream;ai.tools.writerequired for write tools. Gated byai-handlers-capability-gated.test.ts.
src/core/ai/
├── toolOutput.ts — AiToolOutput type + AiToolOutputSchema + aiToolOk / aiToolError
├── toolSchemas.ts — all site write-tool schemas; provider/execution layers for site_apply_css share field definitions here
└── index.ts — barrel re-export (canonical @core/ai import path)
server/ai/
├── handlers/
│ ├── chat.ts — POST /admin/api/ai/chat/:scope (NDJSON stream)
│ ├── toolResult.ts — POST /admin/api/ai/tool-result (bridge POST)
│ ├── conversations.ts — CRUD for ai_conversations rows
│ ├── credentials.ts — CRUD for ai_credentials rows (encrypted secrets + endpoint credentials); auto-seeds defaults on create
│ ├── defaults.ts — GET/PUT/DELETE /admin/api/ai/defaults (per-scope defaults)
│ ├── models.ts — list available models per provider; enriches Anthropic/OpenAI with catalogue prices + context windows
│ └── audit.ts — GET /admin/api/ai/audit (usage rollups for the Audit tab; gated by ai.audit.read)
├── audit/
│ └── store.ts — getUsageTotals / getUsageByUser / getUsageByScope / getUsageByModel / getUsageByDay (four rollup queries; daily rollup bins into the viewer's local calendar day via localDayKeyFactory)
├── conversations/
│ ├── history.ts — buildMessageHistory(): reconstruct AiMessage[] from persisted rows; heals interrupted tool calls (synthetic error results for unanswered tool_use blocks)
│ ├── store.ts — appendMessage / listMessagesForConversation / readConversationForUser
│ └── types.ts — MessageRecord type
├── pricing/
│ ├── index.ts — resolveCostUsd / getModelCatalogue (6h in-memory cache, DB fallback)
│ ├── openrouterCatalogue.ts — fetches OpenRouter /api/v1/models; pricingKey() normaliser; ModelCatalogue type
│ └── store.ts — durable DB cache in ai_model_pricing (prices + context_window column)
├── contextTokens.ts — normalizeContextTokens(): provider-normalised "context used" for the meter
├── tools/
│ ├── site/
│ │ ├── writeTools.ts — browser-bridged site tools (TypeBox schemas), including document reads/opening and write mutations
│ │ ├── readTools.ts — server-side catalog read tools
│ │ ├── render.ts — catalog derivations (`describeAgentModules`, `describeAgentTokens`, `filterTokenFamily`)
│ │ ├── systemPrompt.ts — HTML-native static prefix + buildDynamicSuffix
│ │ └── snapshot.ts — `SiteAgentSnapshotSchema` + `SiteAgentSnapshot` re-export + catalog output types (ModuleInfo, SnapshotTokens, …)
│ └── content/ — content-workspace tools (separate scope)
├── drivers/
│ ├── http/
│ │ ├── sse.ts — parseSseStream(res): reassemble SSE frames across chunks
│ │ ├── execTool.ts — executeAiTool(): server-handler vs browser-bridge dispatch; normaliseToolOutput(): wraps raw handler results in the canonical AiToolOutput envelope, validated via TypeBox (not duck-typed)
│ │ ├── toolLoop.ts — runToolLoop(): provider-agnostic multi-turn loop
│ │ ├── toolArgs.ts — parseToolArguments(json): shared tool-argument JSON parsing (one copy for all drivers)
│ │ ├── chatCompletions.ts — shared /v1/chat/completions SSE adapter (makeChatCompletionsAdapter); used by ollama + openai-compatible
│ │ └── errors.ts — isAbortError / classifyHttpError
│ ├── responses-shared.ts — OpenAI-Responses mapping + SSE translator + adapter factory (openai + openrouter)
│ ├── anthropic.ts — Anthropic driver: direct POST /v1/messages (no SDK)
│ ├── openai.ts — OpenAI driver: direct POST /v1/responses (no SDK)
│ ├── openrouter.ts — OpenRouter driver: direct POST /v1/responses (shared Responses path; live /models; native cost)
│ ├── ollama.ts — Ollama driver: POST /v1/chat/completions via shared chatCompletions adapter; live /api/tags catalogue
│ └── openaiCompatible.ts — Custom Provider driver: any /v1/chat/completions endpoint; live GET /v1/models catalogue
└── runtime/
├── runner.ts — runChat(): drives a driver, emits stream events
├── persister.ts — ConversationsPersister: messages + usage to DB; writes contextTokens snapshot
├── types.ts — canonical AiStreamEvent / AiMessage / AiTool / ToolContext
└── transport.ts — createBridge() / resolveBridgeToolResult()
src/admin/ai/
├── ndjsonStream.ts — shared validated NDJSON reader
├── toolResultApi.ts — shared browser-tool result POST
└── useMcpWorkspaceBridge.ts— scope-aware external MCP stream + browser dispatcher
src/admin/pages/site/agent/
├── index.ts — public barrel (all external imports go through here)
├── agentSlice.ts — scope-agnostic Zustand slice factory (createAgentSlice(config))
├── agentProviderUpdate.ts — timed provider/model persistence and fail-closed reconciliation
├── agentSliceConfig.site.ts— site-editor config: scope, snapshot builder, executor wiring
├── agentConfig.ts — conversation/default API path constants
├── agentApi.ts — conversation bootstrap and message rehydration
├── streamEvents.ts — NDJSON schema (ServerStreamEventSchema) + processStreamEvent reducer
├── siteAgentSnapshot.ts — `SiteAgentSnapshotSchema` (TypeBox) + derived `SiteAgentSnapshot` type + `buildSiteAgentSnapshot` serializer
├── pageContext.ts — editor adapter: reads active page + store scalars, calls `buildSiteAgentSnapshot`
├── executor.ts — browser-side dispatcher: validates + runs write tools; auto-navigates canvas to node's owning document before each write
├── cssTools.ts — site_apply_css parser + exact-selector merge/replace/delete runners
├── documentTools.ts — list/read/open document helpers for pages, templates, and visual components
├── tokenRunners.ts — site_set_color_tokens / site_set_font_tokens / site_set_type_scale / site_set_spacing_scale runners (split from executor.ts)
├── renderEvidence.ts — captureAgentRenderSnapshot (site_render_snapshot tool)
├── storeRef.ts — setAgentStoreApi / getAgentStoreApi (avoids store ↔ executor cycle)
└── types.ts — ServerStreamEvent, AgentMessage, AgentRequestBody, …
src/admin/pages/content/agent/
├── agentSliceConfig.content.ts — content-workspace config: scope, snapshot builder, executor wiring
├── contentAgentStore.ts — standalone per-mount Zustand store (AgentSlice only)
├── contentBridge.ts — content workspace write-tool executor
├── contentBridgeHandle.ts — live ContentPage operation handle
└── useContentToolBridge.ts — always-mounted handle + content-scope MCP relay
src/admin/pages/site/panels/AgentPanel/
├── AgentPanel.tsx — panel shell, persisted message thread, and image-gallery orchestration
├── AgentComposer.tsx — controlled draft, paste/send lifecycle, active-model capability check
├── AgentImageGallery.tsx — compact shared thumbnails for user and agent-tool images
├── AgentImagePreview.tsx — modeless draggable full-image preview
├── AgentImageContextMenu.tsx — shared copy/download/Media actions for every image surface
├── PendingImageAttachmentGrid.tsx — compact pending tiles and per-image actions
├── agentImageActions.ts — authenticated blob reads, clipboard/download, and Media upload
├── agentImageTypes.ts — shared preview/menu image contracts and keyboard positioning
├── agentImageAttachment.ts — browser decode, resize, JPEG normalisation, and base64 encoding
├── usePendingImageAttachments.ts — ref-backed sequential image queue and per-item cancellation
├── ModelPicker.tsx — credential + model selector used in the input bar
├── ConversationHistory.tsx — history popover (browse, restore, delete past threads)
├── ContextMeter.tsx — compact five-segment context + conversation-usage tooltip
├── ContextMeter.module.css
├── contextMeterMetrics.ts — five-band fill/tone calculation
├── AgentPanel.module.css
└── index.ts — barrel export
src/admin/pages/ai/
├── AiPage.tsx — /admin/ai workspace; three tabs gated by ai.providers.manage + ai.audit.read
├── AiPage.module.css
└── tabs/
├── ProvidersTab.tsx — CRUD for ai_credentials rows (provider-derived API key or endpoint credential shape)
├── DefaultsTab.tsx — per-scope model defaults editor
├── AuditTab.tsx — usage audit view: totals strip, by-model/user/scope tables, daily bar chart
└── UsageTablePanel.tsx — shared table scaffolding (title + hint header, numeric-aligned columns, empty row)
Shared AI number and spend formatting lives in src/admin/ai/usageFormat.ts, so
the Audit workspace and compact composer usage detail use identical labels.
The Agent Panel owns the credential list load for its header, lock-state empty states, and model picker. The header always contains a ConversationHistory popover (browse and restore past threads), a "New chat" button (startNewAgentConversation), a conditional "Clear conversation" button (visible when agentMessages.length > 0), a streaming badge, and an "AI settings" shortcut that routes to /admin/ai. The AI settings button is always visible in the header, independent of credential state.
The composer has two distinct lock states, expressed as lockReason: 'setup' | 'chooseModel' | null:
'setup'— no credentials exist at all. The message area shows a "Connect an AI provider" empty state with a CTA to/admin/ai. The model picker is hidden. The textarea placeholder reads "Add AI credentials to start chatting" and the send button tooltip reads "Add AI credentials first".'chooseModel'— credentials are loaded but no scope default or explicit pick is active yet (activeCredentialIdoractiveModelIdis null). The message area shows "Choose a model to get started" with a link to set a default in AI settings. The model picker remains visible so the user can pick inline. The textarea placeholder reads "Choose a model below to start" and the send button tooltip reads "Choose a model first".null—Boolean(activeCredentialId && activeModelId)is true; the composer is fully usable.
While credentials are still loading, lockReason stays null so the panel does not flash a setup prompt before loadScopeDefault() resolves.
When the panel opens, AgentPanel calls loadScopeDefault() so the model picker immediately shows the configured scope default — no "Default" placeholder, no send-time no-provider surprise. composerLocked is gated by hasActiveProvider (Boolean(activeCredentialId && activeModelId)), meaning a stale "No AI provider configured" error string never locks out the UI once a credential + model is staged; picking a model via setAgentProvider clears agentError immediately, re-enabling the composer.
The composer action row includes a compact five-segment <ContextMeter> immediately before Attach images and Send. AgentComposer resolves the full active-model descriptor from GET /admin/api/ai/providers/:id/models?credentialId=… (the same catalogue-enriched response the picker uses), then uses its contextWindow, pricing, capabilities.visionInput, and capabilities.toolCalling. A model known not to support tools is blocked with an inline "choose an agent-capable model" message; the server repeats that gate authoritatively. The meter appears as soon as a model with a known window is selected. It represents context remaining: a fresh conversation is five green segments and the battery drains toward amber/red as context is consumed. Hover or keyboard focus opens a wide graphical tooltip with exact context used/available, cumulative conversation input/output/cache tokens, authoritative USD spend, and current-model list rates. A context snapshot belongs to the credential/model selection that measured it, so switching models renders the meter indeterminate until the next provider response rather than comparing stale usage to a new window. The meter stays hidden when no context window is known (Ollama, uncatalogued models).
The composer accepts up to eight local or clipboard images alongside optional text. The icon button beside Send uses the shared FileUpload primitive and supports multi-selection; picking the same file again works after removal. Pasting or picking reserves every accepted attachment slot synchronously, then normalises the files sequentially so one selection cannot fan out into eight large browser decoders. Send stays disabled while any image is processing, while model support is being checked, after a processing failure, when the selected model is not vision-capable, or when it is known not to support agent tools. Pending attachments use compact thumbnails in a responsive grid. They never point an <img> at the potentially huge source file: a placeholder is shown during decoding, then replaced with the bounded normalised JPEG. Each primitive Button removes only its image. Removing an attachment or replacing the composer aborts its queued preparation and stops all downstream resize/encode work after the browser's current decode returns (createImageBitmap itself has no cancellation API). A message may contain only images: the server persists the turn normally and titles a new conversation Image or Images.
Prepared attachments, persisted user images, and session-only images returned by agent tools use the same compact 2/3-column gallery. Each thumbnail is a keyboard-accessible button that opens the original fitted inside a modeless draggable preview window. The window uses the shared admin FloatingWindow shell, has only a title and close action, closes on Escape without also closing the Agent Panel, and restores focus to the thumbnail that opened it.
Right-clicking an image in a pending tile, conversation gallery, or preview opens the same point-anchored ContextMenu; keyboard users can use the Context Menu key or Shift+F10. Actions copy the image bytes as PNG, start a MIME-correct browser download, or explicitly upload the bytes to Media. The latter uses the canonical uploadCmsMediaAsset pipeline (magic-byte validation, storage adapter selection, variants), requires media.write, primes the editor media cache, and upserts an already-mounted Media explorer. Escape closes the menu before the preview, and the preview before the Agent Panel.
The provider-neutral v1 policy is defined once in src/core/ai/userImage.ts and enforced on both sides of the boundary:
- accepted clipboard sources: PNG, JPEG, or WebP;
- maximum source file: 12 MiB;
- source-header guard: at most 16,384 px on either edge and 40,000,000 encoded pixels; PNG/JPEG/WebP dimensions (including JPEG EXIF orientation) are read before allocating a decoder, and
createImageBitmapis asked for the bounded output size; - at most eight images per message; there is no per-conversation image-count quota;
- browser output: metadata-stripped
image/jpeg, transparent pixels composited over white; - maximum output: 1,500,000 bytes, 1568 px on either edge, and 1,500,000 total pixels;
- complete chat-request envelope: eight maximum base64 images plus a further 16 MiB reserve for JSON framing and the scope snapshot (about 32.8 MB total).
agentImageAttachment.ts fits both the edge and pixel budgets, tries progressively lower JPEG qualities, then reduces dimensions when necessary. The server never trusts that browser work: server/ai/inputImages.ts checks canonical base64, decoded byte length, JPEG magic bytes and dimensions, then fully decodes and re-encodes the JPEG through Sharp before appending the message. That second canonicalisation rejects truncated pixel data and strips EXIF/XMP/ICC metadata even when a direct authenticated client bypasses the browser. Unsupported, malformed, or oversized content is rejected before it can enter conversation history. The conversation single-writer lease is acquired before Sharp work, and the request signal is checked around every sequential decode: a competing request returns 409 without decoding, while a disconnected request finishes only its active Sharp pipeline and never starts the remaining images.
Each entry in Settings → AI → Providers stores one credential. The provider id is fixed; the auth mode and input fields are derived from it — the UI never asks you to choose.
| Provider | Label in UI | Auth mode | Required field | Optional field | Model discovery |
|---|---|---|---|---|---|
anthropic |
Anthropic (Claude) | apiKey |
API key (sk-ant-…) |
— | Static claude-* catalogue enriched with OpenRouter prices + context windows |
openai |
OpenAI | apiKey |
API key (sk-…) |
— | Static gpt-* / o* catalogue enriched with OpenRouter prices + context windows |
openrouter |
OpenRouter | apiKey |
API key (sk-or-…) |
— | Live GET /api/v1/models (cross-provider; native cost reporting) |
ollama |
Ollama (local) | baseUrl |
Base URL (e.g. http://localhost:11434) |
API key (bearer, for proxied deployments) | Live GET {baseUrl}/api/tags, with POST {baseUrl}/api/show capability lookup per model; static fallback list when unreachable |
openai-compatible |
Custom Provider | baseUrl |
Base URL — any host serving the OpenAI /v1/chat/completions wire protocol |
API key (bearer; cloud services need one, local servers often don't) | Live GET {baseUrl}/v1/models (standard OpenAI list shape); model id used as label |
Custom Provider (id openai-compatible) is the generic adapter for any endpoint that speaks the OpenAI chat/completions wire protocol — Groq (https://api.groq.com/openai), Together, DeepSeek, Mistral, Fireworks, self-hosted vLLM, LM Studio, and others. Capabilities default to { toolCalling: true, visionInput: false, toolResultImages: false, promptCache: false, streaming: true }; the operator is responsible for selecting a model that actually supports tool calling. Because arbitrary endpoints are not in the OpenRouter catalogue, no context-window enrichment is available and the context meter stays hidden for these models.
The server is the model-capability authority. The composer catalogue flags are early UX gates, but they are not trusted for persistence, provider calls, or tool screenshots. chat.ts resolves the selected model on every turn through resolveModelCapabilities. Providers with stable capabilities (Anthropic, OpenAI, Custom Provider) use their driver default. Model-specific providers own a selected-model lookup: OpenRouter resolves the exact entry's architecture.input_modalities, while Ollama sends an authenticated POST /api/show for only the selected model. The shared resolver de-duplicates concurrent lookups, applies a ten-second provider timeout, includes credential/backend revisions in its cache key, and caches successful results for five minutes. Missing or unavailable model-specific metadata fails closed for vision input; Custom Provider also remains fail-closed in v1. An image targeting a non-vision model, or an editor-agent turn targeting a model known not to support tools, receives 422 before the user message is stored.
User types text and/or pastes images → Agent Panel
│
▼
agentSlice.sendAgentMessage(contentBlocks)
│
├─→ buildSnapshot() → SiteAgentSnapshot or ContentSnapshot
├─→ ensure conversation row (lazily created from AI defaults on first call)
├─→ POST /admin/api/ai/chat/<scope> { conversationId, content, snapshot }
│
▼
Server: chat.ts
│
├─→ CSRF + requireCapability('ai.chat')
├─→ load conversation row (credentialId, modelId) + full message history
├─→ decrypt credential; resolveDriver(credential.providerId)
├─→ preflight text/image blocks + encoded bytes; enforce the per-message bound
├─→ resolve/cache the selected model's capabilities (also gates tool screenshots)
├─→ acquire the conversation's single-writer stream lease
├─→ fully decode/canonicalise images sequentially (request-cancellable)
├─→ project persisted images for the selected model
├─→ selectToolsForScope(scope, capabilities)
│ — write tools excluded unless caller has ai.tools.write
├─→ build scope system prompt → [staticPrefix, BOUNDARY, dynamicSuffix]
├─→ createBridge(emit) → { bridgeId, bridge, destroy }
├─→ emit { type: 'bridgeReady', bridgeId }
└─→ runChat({ driver, request, persister, emit }) — streaming begins
│ request carries the conversation history as req.messages; user
│ images use the provider replay policy described below.
│ Direct HTTP drivers have no server-side session — every turn
│ replays the whole log, mapped into the provider's message array.
│
├─→ catalog read tool (e.g. site_list_documents / content_list_documents)
│ → resolved server-side from snapshot; result returned to model
│
├─→ browser-backed read/open tool (e.g. site_read_document / site_open_document)
│ → bridge.callBrowser(toolName, input)
│ → browser reads or opens the target page/template/visual component
│ → result returned to model
│
└─→ browser-bridged mutating tool (e.g. site_insert_html / content_set_document_fields)
→ bridge.callBrowser(toolName, input)
→ emit { type: 'toolRequest', requestId, toolName, input }
→ driver loop pauses; awaits tool-result POST
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 } ← browser-bridged tools only
{ type: 'toolResult', toolCallId, toolName, ok, error? }
{ type: 'usage', promptTokens, completionTokens, costUsd, cacheReadTokens?, cacheCreationTokens? }
{ type: 'context', contextTokens } ← per-round meter update
{ type: 'done' }
{ type: 'error', message } ← on server error
Browser: processStreamEvent(event) in streamEvents.ts
│
├─→ 'bridgeReady' → store bridgeId in closure
├─→ 'toolRequest' → executeAgentTool(toolName, input) (executor.ts)
│ – TypeBox-validates input
│ – e.g. runInsertHtml → importHtml(html) → insertImportedNodes(parentId, …)
│ → POST /admin/api/ai/tool-result { bridgeId, requestId, result }
│ → server resolves pending waiter → driver sees tool_result → continues
└─→ 'text' / 'toolCall' / 'toolResult' / 'done' → update agentSlice.agentMessages
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).
Before each sendAgentMessage call, buildCurrentPageContext(get) (in pageContext.ts) builds a SiteAgentSnapshot from the live editor store. pageContext.ts reads the active page, current editor document (page, template, or visualComponent), and the two editor-only scalars (selectedNodeId, activeBreakpointId) off the store and calls buildSiteAgentSnapshot(activePage, state.site, opts) (in siteAgentSnapshot.ts). The result is the raw authoritative tree — no pre-flattening.
// SiteAgentSnapshot = Static<typeof SiteAgentSnapshotSchema>
type SiteAgentSnapshot = {
page: Page // active page with full nodes map
currentDocument: AgentDocumentRef
site: SiteDocument // breakpoints, styleRules, settings intact; non-active pages emptied
selectedNodeId: string | null
activeBreakpointId: string
}Only the active page carries full nodes. Non-active pages keep metadata (id, title, slug, template) with empty nodes, bounding the per-turn payload on multi-page sites. Server-side catalog tools read site.settings, document metadata, and the server module registry from this snapshot. Full annotated document reads are browser-backed (site_read_document) so the agent can inspect any page, template, or visual component from the live store without shipping every tree in every turn.
Server-side validation. The chat handler validates the incoming snapshot against SiteAgentSnapshotSchema via safeParseValue (a soft boundary). A malformed or absent snapshot falls back silently to an empty placeholder — the stream continues with Untitled page context rather than crashing. SiteAgentSnapshotSchema lives in src/admin/pages/site/agent/siteAgentSnapshot.ts and is the source of truth for the type; there is no parallel interface SiteAgentSnapshot.
Mid-turn refresh. The snapshot is rebuilt once per sendAgentMessage, but a single turn runs many tool calls, and browser tools mutate the live store during the turn. To keep server-side catalog tools (site_list_documents, site_list_tokens, …) from seeing stale turn-start state, the browser re-captures buildSnapshot() after every browser tool and posts it with the tool result (postToolResult(..., snapshot)). The server threads it through resolveBridgeToolResult(..., snapshot) → the bridge's onSnapshot → toolContextBase.snapshot (a mutable per-turn field). Because executeAiTool re-reads toolContextBase for each call, the next catalog tool sees the state the previous browser tool produced. Without this, a catalog read after a write (e.g. site_list_documents right after site_add_page) returned the document set from the start of the turn.
// Request body
{
conversationId: string // ai_conversations row id
prompt: string
snapshot: unknown // scope-specific: SiteAgentSnapshot or ContentSnapshot
}
// Response: NDJSON stream of ServerStreamEvent (one JSON line + '\n' each)The handler (server/ai/handlers/chat.ts):
- CSRF-checks and requires
ai.chat. - Loads the conversation row (credentialId, modelId) and the full persisted message history (
listMessagesForConversation→buildMessageHistory→AiMessage[]). - Decrypts the credential and resolves the driver.
- Calls
selectToolsForScope(scope, capabilities)— write tools excluded withoutai.tools.write. - Builds the scope system prompt (
buildSiteSystemPrompt(snapshot)orbuildContentSystemPrompt(snapshot)). - Creates a bridge (
createBridge(emit, req.signal)), emitsbridgeReady. - Calls
runChat(...)with the full history asreq.messages. Direct HTTP drivers have no server-side session, so each driver maps the wholeAiMessage[]log into the provider's native message array every turn (the Anthropic driver pairs assistanttool_useblocks with their followingtool_resultturns). The runner pipes all stream events to the HTTP response. Before recording a terminal usage event, the runner flushes any pending assistant text so text-only replies have an assistant message row for per-turn usage and audit rollups. The multi-turn agentic loop lives indrivers/http/toolLoop.ts, not in a provider SDK. - Emits a terminal
ai.chat.completed/ai.chat.failedaudit event.
Valid scopes are site, content, data, and plugin; only site and content currently register tools and prompts. The handler rejects a request when the URL scope does not match the ai_conversations.scope row.
Returns four rollups consumed by the /admin/ai Audit tab and the dashboard "AI usage this month" widget. Gated by ai.audit.read.
// Query params
since?: string // ISO 8601 start of window; defaults to 30 days ago
tz?: string // IANA timezone (e.g. "Europe/Bratislava"); defaults to UTC
// Response
{
since: string // resolved ISO start instant
totals: UsageRow // aggregate totals across the window
byUser: UsageByUserRow[] // one row per user_id, sorted by cost desc
byScope: UsageByScopeRow[]// one row per chat scope ('site' | 'content' | …)
byModel: UsageByModelRow[]// one row per (provider, model) pair
byDay: UsageByDayRow[] // one row per calendar day in the viewer's timezone
}byDay is the time-series chart data — each day field is YYYY-MM-DD in the viewer's local timezone (not UTC). The daily rollup pulls raw message rows and bins them in JS via localDayKeyFactory(timeZone) (server/time.ts) rather than SQL date-truncation, because the day boundary depends on the viewer's timezone which the database doesn't know. The client (see AuditTab.tsx → listAiAudit) reads Intl.DateTimeFormat().resolvedOptions().timeZone and passes it as ?tz=.
The Audit tab (src/admin/pages/ai/tabs/AuditTab.tsx) consumes this endpoint. The daily rollup there also aligns its "Today" range window to local midnight (setHours(0, 0, 0, 0)) so the day boundary is consistent both in the filter and in the bar chart. The by-model, by-user, and by-scope rollups all render through UsageTablePanel (tabs/UsageTablePanel.tsx) — a shared table component that takes a columns config and handles the empty-state row. Number and cost formatting (formatNumber, formatCost) live in src/admin/ai/usageFormat.ts, a plain shared leaf used by both Audit and the composer context tooltip.
// Request body
{
bridgeId: string
requestId: string
result: AiToolOutput // { ok: boolean; data?: unknown; error?: string; images?: { mimeType, data }[] } — from src/core/ai/
snapshot?: unknown // optional post-mutation scope snapshot (see "Mid-turn refresh")
}Requires ai.tools.write. Calls resolveBridgeToolResult(bridgeId, requestId, result, snapshot) which (when a snapshot is present) refreshes toolContextBase.snapshot via the bridge's onSnapshot, then resolves the pending tool waiter inside the driver loop so streaming continues. If the bridge is gone (stream already closed), returns 404 and the result is silently dropped.
AiToolOutput is the canonical result type shared by both sides of the bridge. Constructors: aiToolOk(data?, images?) and aiToolError(message) from @core/ai. The optional images channel carries base64 attachments (e.g. a site_render_snapshot PNG) that drivers forward as native image blocks or drop with a note — see "Heavy evidence" below.
Resolved server-side from the posted SiteAgentSnapshot or the data repositories via ctx.db. No browser round-trip. Results are returned directly to the model. Full annotated HTML reads are browser-backed because the live browser store owns every page/template/visual-component tree.
| Tool | What it returns |
|---|---|
site_list_documents |
Editable document refs for pages, templates, and visual components. Each item includes { document: { type, id }, title, rootNodeId, active, current, summary, template? }; pass those refs to site_read_document / site_open_document |
site_list_modules |
Module registry (id, name, category, props schema, defaults); category filter |
site_list_breakpoints |
Configured breakpoints + active id |
site_list_post_types |
Routable collections eligible as a postTypes template target — { slug, label, routeBase, kind } per entry, filtered to a non-empty routeBase. Queries the data repositories via ctx.db |
site_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 |
site_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) |
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
| Tool | Input | Success data |
What it does |
|---|---|---|---|
site_read_document |
{ document?: { type, id }, part? } |
{ document, title, html, css, pageInfo } |
Read a page/template/visual-component document as annotated HTML (uid="<nodeId>") plus compact CSS without switching the visible canvas. Omit document to read the current editor document. Result is size-budgeted; call again with part: pageInfo.nextPart until nextPart is null |
site_open_document |
{ document: { type, id } } |
{ document } |
Visibly switch the editor to a page/template/visual component. Use before site_render_snapshot when the target is not current |
Structure (HTML-native)
| Tool | Input | Success data |
What it does |
|---|---|---|---|
site_insert_html |
{ parentId, index?, html } |
{ nodeIds } or { cssRulesCreated, cssRulesUpdated } |
Parse HTML (+ any <style> CSS) → import as PageNodes under parentId. Custom <instatic-loop> elements import as real Loop nodes; <instatic-outlet> imports as a template outlet. A <style>-only payload (no elements) upserts CSS rules without inserting nodes (prefer site_apply_css for that) |
site_get_node_html |
{ nodeId } |
{ html } |
Render subtree to HTML via the publisher's renderNode |
site_replace_node_html |
{ nodeId, html } |
{ nodeIds } or { cssRulesCreated, cssRulesUpdated } |
Delete existing children; re-import HTML under the same parent. A <style>-only payload upserts CSS rules WITHOUT touching the children |
Styling rides on the html payload — there is no separate classes parameter. The executor runs importHtml(html), which harvests any <style> block's CSS, then hands it to cssToStyleRules. That classifier routes each selector:
- a bare
.foo {}rule → a reusable Selectors-panel class, bound to everyclass="foo"node in the fragment; - any other selector (
.hero a,a:hover,nav > li,@media …) → an ambient rule (media queries fold into the matching breakpoint'scontextStyles); - supported stylesheet-level rules such as
@keyframes→ ambient raw CSS rules emitted by the publisher; - inline
style="…"attributes → the node's inline styles.
insertImportedNodes then links every class= token on the imported nodes to its registry class id in the same undo step, so class="hero-section" renders and is styleable whether its styles came from a <style> rule or an automatically-created bare class. See html-import.md → Class linking.
Authoring CSS with site_apply_css. The required operation discriminator makes destructive intent explicit:
{ operation: "merge", css }creates missing selectors and patches only the supplied declarations/contexts. Touched declarations move to the end of the stored rule in authored order, so longhand/shorthand cascade order stays truthful.{ operation: "replace", css }makes every supplied selector's complete CSS payload authoritative: omitted base declarations and contexts are removed, while stable rule id, cascade order, metadata, and class assignments survive. An empty.foo {}therefore clears its CSS without detaching the class.{ operation: "remove-properties", selectors, properties }removes CSS-native property names from base plus every viewport/custom-condition bag without rebuilding unrelated CSS. Vendor names and custom properties are accepted; emittedpadding/marginshorthands also clear their stored side longhands.{ operation: "delete", selectors }removes every exact matching rule; class-kind rules are detached from page and Visual Component nodes in the same undo step.
Selectors are matched by their exact emitted text across rule kinds. .grad, .hero .grad, and .grad, .hero .grad are separate rules—there is no unsafe attempt at semantic selector equivalence. Destructive batches preflight missing/locked targets and fail without partial mutation. Merge/replace accept real CSS through cssToStyleRules, including conditions, vendor/custom properties, raw keyframes, and structurally preserved !important. Framework-generated locked utilities are never changed. <style>-only site_insert_html/site_replace_node_html payloads keep merge behavior as a forgiving fallback; a <style> block accompanying inserted elements remains additive (mergeImportedStyleRules) so dropping in structure cannot clobber a shared rule.
Loops through HTML. A repeated list is authored with the custom importer marker:
<instatic-loop data-source-id="data.rows" data-table-id="<table id>" data-order-by="publishedAt" data-direction="desc" data-limit="3">
<article>
<a href="{currentEntry.permalink}">
<img src="{currentEntry.featuredMedia}">
<h3>{currentEntry.title}</h3>
</a>
</article>
</instatic-loop>The agent calls site_list_loop_sources first to get the valid source id, data table id, order options, and field tokens. The token grammar is single-brace {currentEntry.field}; aliases such as {{post.title}} are invalid and should never be generated.
Node edits
| Tool | Input | Success data |
What it does |
|---|---|---|---|
site_update_node_props |
{ nodeId, breakpointId?, patch } |
none | Shallow-merge props; breakpointId requires schema breakpointOverridable: true |
site_move_node |
{ nodeId, newParentId, newIndex } |
none | Re-parent or reorder; newIndex is 0-based |
site_delete_node |
{ nodeId } |
none | Remove node and all descendants |
site_duplicate_node |
{ nodeId, count? } |
{ nodeId, nodeIds } |
Clone subtree 1–50 times right after the source |
site_rename_node |
{ nodeId, label } |
none | Set the node's display label in the DOM panel (editor-only) |
CSS + class assignment
| Tool | Input | Success data |
What it does |
|---|---|---|---|
site_apply_css |
{ operation:'merge'|'replace', css } or { operation:'delete', selectors } or { operation:'remove-properties', selectors, properties } |
{ cssRulesCreated?, cssRulesUpdated?, cssRulesDeleted?, cssPropertiesRemoved? } |
Merge/replace authored CSS, delete exact rules, or remove selected properties across all contexts |
site_assign_class |
{ nodeId, classId } |
none | Attach an existing class to a node; classId accepts id or name |
site_remove_class |
{ 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 |
|---|---|---|---|
site_list_code_assets |
{ type?: 'script' | 'style' } |
{ assets } |
List runtime code assets with file ids, paths, full-content hashes, sizes, timestamps, and runtime config |
site_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 |
site_write_code_asset |
{ path, type, content, runtime?, dependencies? } |
asset summary + { action, dependencies } |
Create or replace a runtime script/stylesheet and normalize its runtime config. Existing paths are updated, new paths are created. For module scripts, dependencies is a package-name → version/range map added to site.packageJson.dependencies |
site_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 |
site_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 |
site_insert_html / site_replace_node_html intentionally strip <script> elements and inline event handlers (onclick, onload, etc.). When a request needs behavior, the agent should use site_write_code_asset({ type: "script", ... }) and then site_inspect_code_runtime, not raw <script> tags or event attributes in HTML.
Module scripts that need npm packages should import bare package specifiers and declare those packages in the same site_write_code_asset call:
site_write_code_asset({
path: 'src/scripts/motion.js',
type: 'script',
content: `import { Motion } from '@motion.page/sdk';`,
runtime: { format: 'module' },
dependencies: { '@motion.page/sdk': '1.2.4' },
})Agents should not use npm CDN URLs such as esm.sh, unpkg, or jsDelivr for packages that can live in the site dependency manifest.
Pages
| Tool | Input | Success data |
What it does |
|---|---|---|---|
site_add_page |
{ title, slug? } |
{ pageId, rootNodeId } |
Create an empty page and make it active. Slug is auto-uniqued. Build into it via site_insert_html({ parentId: rootNodeId, … }) |
site_delete_page |
{ pageId } |
none | Delete page; fails if it would leave the site with 0 pages |
site_rename_page |
{ pageId, title, slug? } |
none | Change title/slug; slug="index" makes this the homepage |
site_duplicate_page |
{ pageId, title, slug? } |
{ pageId } |
Deep-clone page (all nodes, props, class assignments) |
Templates (CMS layouts)
A template is a page carrying a target plus a single <instatic-outlet> where matched content flows in. These bridge to the editor's convertPageToTemplate / convertTemplateToPage store actions. The outlet itself is placed via site_insert_html — the importer maps the custom <instatic-outlet> element to a base.outlet node (see html-import.md and templates.md). No save-time outlet guard: a template with no outlet simply doesn't apply at render time.
| Tool | Input | Success data |
What it does |
|---|---|---|---|
site_set_page_template |
{ pageId, target: {kind:'everywhere'} | {kind:'postTypes', tableSlugs:[…]}, priority? } |
none | Convert a page to a template (or update its target/priority). priority defaults to 100. Get post-type slugs from site_list_post_types |
site_clear_page_template |
{ pageId } |
none | Revert a template to an ordinary page (drops target + dynamic bindings); errors if the page is not a template |
Design system (tokens)
The agent works design-system-first: it establishes or reuses tokens, then references them (var(--<slug>), --text-*, --space-*, var(--<font-var>)) instead of hardcoding hex/px/font-family. Colors and fonts are list-shaped (one entry per token); typography and spacing are scale-shaped (a group config from which the framework generates per-step values). All four are create-or-update — keyed by color slug, font variable, or scale group — so re-runs patch in place. The executor dispatches to the framework/font store actions (createFrameworkColorToken, create/updateFrameworkTypographyGroup, create/updateFrameworkSpacingGroup, addFont/createFontToken).
| Tool | Input | Success data |
What it does |
|---|---|---|---|
site_set_color_tokens |
{ tokens: [{ slug, lightValue, category?, darkValue?, darkModeEnabled? }] } |
{ tokens: [{ slug, ref, action }] } |
Create/update color tokens → var(--<slug>) + utilities/variants |
site_set_font_tokens |
{ tokens: [{ name, variable?, fallback?, googleFamily?, variants?, subsets?, familyId? }] } |
{ tokens: [{ name, variable, ref, installed?, action }] } |
Create/update font tokens. googleFamily installs a new web font via POST /admin/api/cms/fonts/install then binds the token; familyId references an already-installed family; neither = fallback-only. Prefer exactly one of googleFamily/familyId; if both are sent, googleFamily wins and the stale familyId is ignored |
site_set_type_scale |
{ groupId?, namingConvention?, steps?, baseScaleIndex?, min?: { fontSize?, scaleRatio? }, max?: {…} } |
{ groupId, action, namingConvention, generatedVars } |
Configure the typography scale → --text-*. Creates the group if none exists, else updates it |
site_set_spacing_scale |
{ groupId?, namingConvention?, steps?, baseScaleIndex?, min?: { size?, scaleRatio? }, max?: {…} } |
{ groupId, action, namingConvention, generatedVars } |
Configure the spacing scale → --space-*. Same shape as site_set_type_scale but min/max carry size |
Capture
| Tool | Input | Success data |
What it does |
|---|---|---|---|
site_render_snapshot |
{ breakpointId?, nodeId? } |
{ breakpointId, nodeId?, label, width, capturedAt, layout, screenshot } + optional images[] |
Inspect the rendered canvas: always returns geometry, warnings, and per-node computed styles including background image/clip and WebKit text-mask values; capable providers also receive a PNG. breakpointId renders any configured viewport through a deterministic one-shot frame at its exact width, independent of Live mode or collapsed/disabled frames. nodeId crops to one subtree while preserving ancestor paint. Unknown ids error. Pair computed evidence with site_read_document source CSS when debugging the cascade |
When a node-targeting write tool (site_insert_html, site_get_node_html, site_replace_node_html, site_delete_node, site_update_node_props, site_move_node, site_rename_node, site_duplicate_node, site_assign_class, site_remove_class) receives a node id that belongs to a different document (another page, a template, or a VC), the executor automatically navigates the canvas to that document before running the mutation. This is done via focusNodeDocument in executor.ts, which calls store.openPageInCanvas or store.setActiveDocument as appropriate. The effect: the edit lands in the correct tree, stays visible to the user, and the mid-turn snapshot refresh picks up the navigated state for any subsequent read tool in the same turn.
site_render_snapshot, catalog tools (site_list_documents, etc.), and token tools have no node target — they are excluded from auto-navigation.
Content-scope tools are registered under server/ai/tools/content/. They use the same POST /admin/api/ai/chat/content stream and POST /admin/api/ai/tool-result bridge as the Site editor, but the snapshot and browser executor are content-specific:
ContentAgentMountbuilds aContentSnapshotfrom the live Content workspace: visiblepostTypecollections, active collection id, active document fields/schema, and current user identity.contentAgentStore.tsmounts a standaloneAgentSliceinstance perContentPagemount. The Content workspace is hook-based rather than a global Zustand store, so the bridge is exposed throughcontentBridgeHandle.ts.- Server read tools hit the data, media, and user repositories through
ctx.db; write tools are browser-bridged so unsaved draft state inuseContentEntryDraftand the Tiptap body editor stay authoritative.
Server-side content reads — 7
| Tool | What it returns |
|---|---|
content_list_collections |
Visible content collections (postType and page tables only) with id, slug, label, kind, row count, and primary field id |
content_get_collection_schema |
One collection's field schema, including per-field type metadata and select/media/relation extras |
content_list_documents |
Light document rows in a collection, filterable by status/author and paginated by limit/offset |
content_get_document |
One document's full state: field values, status, author, slug, timestamps |
content_search_documents |
Free-text lookup across document slugs, projected to light document summaries |
content_list_users |
Active users available as authors; gated by users.manage |
content_list_media |
Existing media assets for media fields; gated by media.read; upload remains a user/media-workspace action |
Browser-bridged content writes/navigation — 8
| Tool | What it does |
|---|---|
content_create_document |
Creates a draft in a collection, optionally with initial fields/status, then switches the UI to the new document |
content_delete_document |
Soft-deletes a document; restore remains available through the Trash UI |
content_set_document_status |
Changes status (draft, unpublished, published, scheduled); scheduled status requires scheduledAt |
content_set_document_field |
Writes one field; body values are markdown and are converted by the browser bridge |
content_set_document_fields |
Batch-writes multiple fields in one save |
content_set_document_author |
Reassigns author; gated by the same author-management capability path as the HTTP UI |
content_set_active_document |
Loads a document by id across post-type collections and commits the live editor focus before subsequent writes |
content_set_active_collection |
Switches the sidebar focus to a collection |
The content system prompt is markdown-native: it tells the model to exchange body content as standard markdown, to read schemas before writing unfamiliar fields, and to prefer content_set_document_fields for whole-post generation. The prompt is built with the same [staticPrefix, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, dynamicSuffix] shape as the Site prompt so provider prompt caching works the same way.
site_render_snapshot (and site_read_document / site_get_node_html) return large payloads. Five rules keep them from exploding context (a screenshot inlined as base64 JSON text once pushed a single turn past 1M tokens):
- Image channel, not text.
AiToolOutputcarries an optionalimages: { mimeType, data }[](src/core/ai/toolOutput.ts).site_render_snapshotputs the PNG there — never indata. The Anthropic driver forwards it as a nativeimageblock inside thetool_result(billed at the rendered image's token cost). Text-only tool channels (Ollama / OpenAI-compatiblefunction_call_output) drop the image and append a one-line[N screenshot(s) omitted…]note. The capture caps the screenshot's long edge atMAX_IMAGE_EDGE(1568px inrenderEvidence.ts) — a tall landing page would otherwise exceed Anthropic's hard 8000px-per-dimension limit (400 error), and the model downsizes the long edge to ~1568px anyway. - Capture is provider-channel-gated. The chat handler resolves the selected model on every turn and places the result in
AiStreamRequest.modelCapabilities.visionInputmeans pasted user images are accepted; the separatetoolResultImagesflag means that provider's tool-result wire shape can actually carry a native image. The shared tool loop injectscaptureScreenshot: visionInput && toolResultImagesintosite_render_snapshot. Today Anthropic supports both; Responses and chat/completions providers accept user images but have text-only function/tool results, so they get the layout report without paying for a screenshot that would be discarded. (The model never setscaptureScreenshotitself.) site_read_documentCSS is document-relevant, not the public full-site CSS bundle. Public pages can share page-invariant CSS files, butsite_read_documentinlines CSS into model context. It keeps framework variables/utilities, font token variables, target-document module CSS, used class rules, ambient selectors whose class tokens all exist on the target document, classless/global ambient selectors, and document-targeted user stylesheets. It omits browser-only@font-facefile declarations and ambient selectors from unrelated imported pages.site_read_documentis cleaned and paged before it reaches the model.renderAgentDocumentstrips pathological strings from the broad read surface: long base64/data URLs becomedata:<mime>;base64,[omitted N chars], and very long URLs are middle-truncated. The returned object always includespageInfowithpart,totalParts,nextPart,ranges,serializedChars, and cleanup counts. The hard budget is measured againstJSON.stringify({ html, css, pageInfo }).length, because that is the text providers receive as the tool result. IfnextPartis notnull, the agent callssite_read_document({ document, part: nextPart })to continue. For exact node-level markup, use theuidwithsite_get_node_html.- Stale evidence is elided. Within one tool loop, only the most recent heavy result per tool name (
site_render_snapshot,site_read_document,site_get_node_html, or anything with an image) is replayed at full fidelity; earlier ones are rewritten to a one-line breadcrumb ("Earlier <tool> output removed… Call <tool> again…"). Older snapshots describe page state the model has since mutated, so they carry no value. SeeapplyHeavyElisioninserver/ai/drivers/http/toolLoop.ts.
Snapshot pixels come from the iframe document's authored rendering. Full-page captures rasterise <html> at the exact iframe viewport width and full document height, so a narrow/transformed body cannot shrink the reported viewport or omit document gutters. Node-scoped captures rasterise that same document painting context and crop to the node rectangle, so transparent sections retain HTML/body/ancestor colors, gradients, and background images. A white browser-default fallback is composited only behind pixels the authored page leaves transparent — it is never written onto the cloned document.
Every request uses AgentSnapshotFrame, an offscreen one-shot IframeFrameSurface at the configured width. Before it becomes capturable, a revisioned barrier waits for template preview rows, nested loop data, media metadata, web fonts, the resulting React commit, and a quiet DOM window. Readiness lives on the host iframe, never on authored <html>/<body>, so user attribute selectors cannot distinguish Agent evidence from the published page. Lazy <img> resources are left authored as-is; html-to-image makes its private clones eager and embeds image/background resources before toCanvas() resolves. The frame deliberately does not execute authored runtime scripts, and it is released after capture without changing activeBreakpointId, canvasView, or collapsed-frame state. Parallel requests are serialized so they cannot replace the single transient frame mid-capture.
server/ai/tools/site/systemPrompt.ts builds a 3-element array:
[staticPrefix, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, dynamicSuffix]Drivers that support explicit prompt-cache controls (Anthropic) apply cache_control to the static prefix automatically. OpenAI concatenates the prompt parts and sends a stable prompt_cache_key derived from the scope + toolset so repeated prefixes route more consistently. Other drivers concatenate the three strings. Content is intentionally static across providers — every observable behaviour comes from the tool definitions, not prompt knobs.
SYSTEM_PROMPT_DYNAMIC_BOUNDARY is the literal '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__', declared once in server/ai/runtime/types.ts and imported everywhere — prompt builders and every driver. A duplicate definition would silently break prompt caching on whichever driver drifted. Gated by ai-driver-shared-helpers.test.ts.
Static prefix key rules (full text lives in server/ai/tools/site/systemPrompt.ts):
- Design system first. Establish or reuse tokens before/while building (
site_set_color_tokens,site_set_type_scale,site_set_spacing_scale,site_set_font_tokens), then reference them in CSS (var(--<slug>),var(--text-l),var(--space-m),var(--<font-var>)) instead of raw hex/px/font-family. The dynamic suffix'sTokens —line shows what already exists;(none …)means no design system yet. - Structure as HTML (
site_insert_html/site_replace_node_html); style with CSS in the same payload — a<style>block and/orclass=attributes referencing the design tokens. The importer classifies selectors, so the agent never hand-builds classes at insert time. <style>blocks inside imported HTML are parsed: a bare.foo {}rule becomes a Selectors-panel class bound toclass="foo"; any other selector (.hero a,a:hover,@media …) becomes an ambient rule, and supported@keyframespublish as raw keyframes CSS.style=attributes land on the node's inline styles. These are applied — not stripped.- CSS-only edits use an explicit
site_apply_cssoperation: merge for additive patches, remove-properties for stale declarations, replace only with the selector's complete desired CSS, and delete for whole exact rules. Read the document first before destructive operations; grouped and ungrouped selectors are different identities. - One
site_insert_htmlcall per logical section (nav, hero, pricing, footer = 4–6 calls); smaller chunks recover better if one fails. - Per-breakpoint variation:
@mediaqueries — in the<style>block of an insert or insidesite_apply_css— with min/max-width queries that line up with the breakpoint widths in the dynamic suffix. Never invent ids like"mobile"or"desktop". - Document refs come from the dynamic suffix or
site_list_documents; never invent them. Shared chrome/layout/theme/navigation/footer requests should inspect template documents first. - Page ids for page operations come from the dynamic suffix; never invent them.
- Write-tool success data uses explicit keys:
cssRulesCreated/cssRulesUpdated/cssRulesDeleted/cssPropertiesRemovedforsite_apply_css,pageIdforsite_add_page/site_duplicate_page,nodeId/nodeIdsforsite_duplicate_node,nodeIdsfor HTML inserts. - Editing existing content: call
site_read_documentfirst — it returns annotated document HTML where every element carriesuid="<nodeId>"pluspageInfo; followpageInfo.nextPartwhen more of the document is needed. Passuidverbatim to write tools (site_update_node_props,site_replace_node_html, etc.). For a single subtree,site_get_node_htmlis sufficient. - Reply rule: 1–2 narrating sentences only. No raw HTML/CSS/JSON in the reply.
Dynamic suffix (built per request by buildDynamicSuffix(snap: SiteAgentSnapshot)):
Page: "My Site" · root: <rootNodeId> · selected: <nodeId|none>
· active breakpoint: <id> · all breakpoints: [<id>@<width>px, …]
· Documents: [page:<id>="Home" (current, active-page, root=<rootNodeId>; Homepage), template:<id>="Chrome" (root=<rootNodeId>; Everywhere template wrapping all pages), …]
· Pages: [<id>=<slug> (active), <id>=<slug>, …]
· Tokens — colors: [primary=…, ink=…]; type --text-*: [xs, s, m, …]; spacing --space-*: […]; fonts: [--font-heading→Inter]
The static prefix is cache-friendly (unchanged across prompts for the same provider). Anthropic marks only that prefix with cache_control; OpenAI relies on automatic prefix caching plus prompt_cache_key. The dynamic suffix carries per-request state. The Tokens — digest is a compact, always-inlined summary of the site's design tokens (describeAgentTokens(snap.site)) so the agent sees the design system every turn without a site_list_tokens round-trip; when no tokens exist it reads Tokens: (none — no design system yet; establish one first …). site_list_tokens remains the on-demand full-detail read (variants, utility classes).
The previous tool surface required the model to reference internal module ids (base.text, base.container, …) and construct node trees as structured JSON. The current surface lets the model write plain HTML:
- LLMs produce correct semantic HTML far more reliably than custom JSON node-tree payloads.
- No module enumeration is needed in the system prompt — shorter context, lower token cost.
- The importer (
@core/htmlImport) guarantees every element becomes a first-class editablePageNode: selectable, draggable, deletable, and re-styleable in the canvas. site_get_node_html(backed by the publisher'srenderNode) gives the agent read-back at the same semantic level it writes.
The same importer that powers the Agent's site_insert_html tool also powers the paste-HTML UI — see docs/features/html-import.md. No duplicated mapping logic.
Reads are HTML-native. The site_read_document tool returns the same semantic surface the agent writes: annotated HTML where every element carries uid="<nodeId>", plus document-relevant CSS rather than the public full-site CSS bundle. It accepts document refs for pages, templates, and visual components, and omitting document reads the current editor document. The response is cleaned and size-budgeted; if pageInfo.nextPart is set, subsequent site_read_document({ document, part }) calls return the remaining cleaned ranges. The agent reads uid values from the HTML and passes them verbatim to write tools — no separate node-lookup round-trip. Catalog tools (site_list_modules, site_list_tokens, site_list_documents, site_list_post_types, site_list_loop_sources, site_list_breakpoints) describe things not visible in the document HTML (what is insertable, design token CSS vars, editable document refs, CMS route targets, and loop binding fields) and remain as JSON tools.
createAgentSlice(config) (src/admin/pages/site/agent/agentSlice.ts) is a scope-agnostic Zustand slice factory. Scope-specific wiring is kept out of the factory — each surface supplies its own AgentSliceConfig. The site editor uses siteAgentSliceConfig from agentSliceConfig.site.ts:
// agentSliceConfig.site.ts — wired in store.ts via createAgentSlice(siteAgentSliceConfig)
export const siteAgentSliceConfig: AgentSliceConfig = {
scope: 'site',
buildSnapshot: () => buildCurrentPageContext(
() => getAgentStoreApi<EditorStore>().getState(),
),
dispatchTool: executeAgentTool,
noProviderMessage: 'No AI provider configured for the site editor. …',
}getAgentStoreApi reads the live store via storeRef.ts, wired in store.ts after store creation (setAgentStoreApi(useEditorStore)). This avoids a static import cycle: executor → store → agentSlice → executor.
The content workspace uses the same factory with contentAgentSliceConfig mounted in a standalone per-page store (contentAgentStore.ts).
agentProviderUpdate.ts owns the existing-conversation provider/model PUT and its failure reconciliation. A definite 4xx can roll the picker back to the re-read row; a timeout, network failure, or 5xx stays fail-closed unless the re-read already proves that the requested selection committed. agentSlice.ts keeps the ordering queue and Send lock because those coordinate store actions rather than HTTP persistence.
Key slice state and actions:
interface AgentSlice {
// ── UI state ──────────────────────────────────────────────────────────
isAgentOpen: boolean
isAgentStreaming: boolean
agentMessages: AgentMessage[]
agentError: string | null
/** Active ai_conversations row id — created lazily on first send. */
agentConversationId: string | null
/** Active (credentialId, modelId) surfaced by the model picker. */
agentActiveCredentialId: string | null
agentActiveModelId: string | null
/** Conversation summaries for the history popover. */
agentConversations: ConversationView[]
/** Current-context snapshot plus cumulative conversation billing totals. */
agentUsage: {
contextTokens: number | null
contextCredentialId: string | null
contextModelId: string | null
promptTokens: number
completionTokens: number
cacheReadTokens: number
cacheCreationTokens: number
costUsd: number
}
/** Blocks Send/navigation while a history load or delete may replace the active chat. */
isAgentConversationPending: boolean
/** Blocks Send/navigation while an existing chat's model PUT is pending. */
isAgentProviderPending: boolean
/** Incremented when a conversation is replaced so local text/image drafts remount cleanly. */
agentComposerEpoch: number
// ── Actions ───────────────────────────────────────────────────────────
openAgent(): void
closeAgent(): void
toggleAgent(): void
sendAgentMessage(content: AiUserContentBlock[]): Promise<{ accepted: boolean }>
abortAgent(): void
clearAgentMessages(): void
startNewAgentConversation(): void
loadAgentConversations(): Promise<void>
loadAgentConversation(id: string): Promise<void>
deleteAgentConversation(id: string): Promise<void>
/** Change which credential + model is active. Updates the conversation row if one exists; stages the values for the next create if not. Also clears `agentError` so a sticky "no provider" error doesn't keep the composer disabled after the user picks a model. */
setAgentProvider(credentialId: string, modelId: string): Promise<void>
/** Preload the per-scope default (credentialId, modelId) from GET /admin/api/ai/defaults. No-op when a conversation or explicit pick is already active. Called by AgentPanel on open. */
loadScopeDefault(): Promise<void>
}Conversations and their message history are persisted server-side in ai_conversations + ai_messages. loadAgentConversation(id) rehydrates a past thread into agentMessages without re-running the conversation.
Content blocks have one persisted vocabulary and one safe browser projection. Every stored/provider message body is an AiContentBlock[] — a discriminated union of text / base64 image / toolCall / toolResult kinds defined once as a TypeBox schema in @core/ai (src/core/ai/contentBlock.ts). The server runtime type and the content_json read boundary derive from it. Conversation-detail responses derive from the sibling AiContentViewBlockSchema: non-image blocks keep the same schemas, while an image carries an authenticated lazy url instead of inline data. The client validates that view schema before rehydrating its render model.
User turns use the same canonical blocks at the HTTP boundary. AiChatRequestBodySchema in src/core/ai/chatRequest.ts accepts { conversationId, content, snapshot? }, where content contains at most one trimmed text block plus up to eight canonical JPEG blocks. The server canonicalises a mixed turn as text followed by the images in paste order and removes whitespace-only text. It does not accept toolCall or toolResult blocks from the browser.
{
conversationId,
content: [
{ kind: 'text', text: 'Use this mockup as the reference.' },
{ kind: 'image', mimeType: 'image/jpeg', data: '<canonical base64>' },
{ kind: 'image', mimeType: 'image/jpeg', data: '<canonical base64>' },
],
snapshot,
}Persisted images, browser history, and provider replay are deliberately different views. Every accepted user JPEG is stored inline in ai_messages.content_json; conversations have no image-count quota. A conversation-detail response replaces each base64 block with GET /admin/api/ai/conversations/:conversationId/messages/:messageId/images/:blockIndex. The ownership-guarded endpoint returns only a canonical JPEG with private, no-store; native lazy image loading means reopening a large collection does not embed all bytes in one JSON response. Before a provider call, projectUserImagesForModel creates a non-mutating outbound projection:
- a vision model first receives every persisted image in conversation order; there is no Instatic replay count cap;
- a non-vision model receives no image bytes at all; every persisted image becomes a text breadcrumb, so switching models cannot poison the conversation;
- the database rows are never rewritten by projection, so the UI history remains intact and switching back to a vision model restores the complete persisted image history.
Providers may enforce a physical request, context, or routed image limit before accepting that full replay. The shared HTTP tool loop classifies only those explicit overflow responses (413, or a matching provider 400) and retries once before any SSE or tool side effect: images on older user turns become one breadcrumb per turn, while every image on the newest/current user turn remains. Generic 400s, authentication, quota, rate-limit, and service failures are never retried. If the reduced request still fails—or only the current turn has images—the error explains that history remains saved and suggests a new conversation or a larger-context model. This is provider-triggered fallback, not a stored-image quota or an arbitrary app-side count.
User attachments are private chat data by default, not media-library assets: the normalised base64 bytes live in the database until the conversation is deleted and purged, are exposed to the owning authorised user only through the lazy conversation-image endpoint, and are sent to the configured AI provider whenever they survive the outbound replay projection. They enter public Media storage only when a user with media.write explicitly chooses Save to Media from the image context menu; saving creates a separate media asset and does not change or delete the private conversation copy.
The server admits only one active writer per conversation. A concurrent tab receives a retryable 409 before appending, which keeps message positions ordered. In the browser, model changes for an existing conversation are serialized; Send waits for the provider/model update to reach the server, and conversation/model controls stay disabled while a turn streams. If a model PUT times out after an ambiguous commit, the browser re-reads the conversation before re-enabling Send, so the picker cannot disagree with server routing. Stop owns the whole first-send lifecycle, including default lookup and lazy conversation creation, so an aborted bootstrap cannot leave the composer locked.
User attachments are not tool screenshots. A pasted image is a persisted kind:'image' block on a user message. Conversation-detail responses, authenticated image responses, and chat streams use Cache-Control: private, no-store; the database remains the intentional durable copy. Images returned by site_render_snapshot or another browser tool instead travel transiently on the plural AiToolOutput.images channel, are subject to the heavy-evidence rules above, and remain session-only even though the panel exposes every returned image through the same gallery and draggable preview. The two paths share provider-native image mapping but have different storage and replay lifecycles.
Tool outcomes are first-class. A role:'tool' row records its result as a { kind: 'toolResult', ok, error? } block — ok is an explicit boolean, never inferred from the emptiness of a text block. The persister writes it (appendToolResult), buildMessageHistory reads ok/error straight off the block to reconstruct the replay AiToolOutput, and the client folds it back into the matching tool-call badge (rehydrateMessages). A loaded conversation never owns a live bridge from its previous process: any persisted call without a matching valid result is finalized as INTERRUPTED_TOOL_RESULT_ERROR, never restored as pending. The heavy successful data an AiToolOutput may carry is intentionally not persisted: the model already consumed it in the round that produced the result, so replay only needs { ok, error } — re-feeding large tool payloads every turn would bloat the context for no benefit.
The <ContextMeter> is a five-segment battery-style status beside the image action. Its hover/focus tooltip deliberately separates current context from cumulative billing:
- Window (
windowTokensprop fromAgentComposer): the model's max total tokens, resolved once fromGET /admin/api/ai/providers/:id/models?credentialId=…. The models endpoint enriches Anthropic and OpenAI models withcontextWindowfrom the live OpenRouter catalogue (server/ai/pricing/); OpenRouter populates it from its own native fetch. Ollama models and uncatalogued models have no window — the meter hides. - Current context (
agentUsage.contextTokens): the provider-normalised input held by the LATEST provider round, tagged with the credential/model selection that produced it.normalizeContextTokens(providerId, buckets)inserver/ai/contextTokens.tscomputes it:- Anthropic reports
input_tokensexcluding cache buckets, so the true total ispromptTokens + cacheReadTokens + cacheCreationTokens. - OpenAI / OpenRouter / Ollama / Custom Provider report
input_tokensas the full input;promptTokensalone is the total.
- Anthropic reports
Live context, cumulative billing. A turn makes one provider round-trip per tool batch. The tool loop emits a context event each round carrying THAT round's input buckets; the chat handler injects the normalised contextTokens and the browser updates the meter on every round — so the remaining-capacity battery drains during a long tool loop instead of only at the end. The measurement is the LATEST round's input, never the sum across rounds (which would over-count, since each round re-sends the growing context). The terminal usage event is billing only: prompt/completion/cache counts are summed across rounds. Before forwarding that terminal event, the persister resolves authoritative cache-aware spend (or accepts OpenRouter's native cost), writes the usage, then includes the resolved costUsd on the wire. The browser accumulates those totals in agentUsage; loadAgentConversation hydrates the same totals from ConversationView. The tooltip labels the sections “Context remaining” and “Conversation billing” so the two token meanings cannot be confused.
Five equal bands approximate remaining capacity: an empty conversation has all five filled, then the display drains by fifths until no capacity remains. More than 40% remaining is healthy, 20–40% warns, and below 20% is danger. An unmeasured model switch uses five neutral segments until its first response. The keyboard-focusable details button exposes the exact remaining/window counts and percentage in its accessible name; segment count is only the compact visual approximation.
The browser de-duplicates concurrent requests for the same credential, applies a ten-second timeout, and retains successful catalogues for five minutes so the composer capability check and model picker share one result across conversation switches. Credential deletion invalidates its cached catalogue. The server independently applies the same ten-second deadline and forwards request cancellation into provider fetches; Ollama resolves /api/show metadata in batches of six rather than launching an unbounded fan-out.
server/ai/pricing/ is the single source for per-model prices and context windows. It sources from OpenRouter's public /api/v1/models endpoint (no key required), which publishes list prices and context_length for Anthropic and OpenAI models. The module lifecycle:
- Cold start: loads the DB cache from
ai_model_pricing(durable fallback) and kicks a background refresh. The first turn prices immediately off the last-known data. - No DB cache yet: blocks once on a live fetch.
- Thereafter: serves from a 6-hour in-memory memo, refreshing in the background past the TTL.
- A failed refresh is logged and keeps the previous data — never fatal.
pricingKey(modelId) normalises a provider's native id (claude-opus-4-8-20260514) and the OpenRouter slug (anthropic/claude-opus-4.8) to the same key (claude-opus-4-8), stripping date suffixes, dots, and provider prefixes. Variant suffixes (:thinking, -fast) are preserved — they have different pricing.
The getModelCatalogue(db) export (used by the models handler for picker enrichment) and resolveCostUsd(db, providerId, modelId, usage) (used by the persister) share the same in-memory cache. Two callers, one memo.
When POST /admin/api/ai/credentials creates a new credential, seedEmptyDefaults auto-assigns it as the default for every scope (site, content, data, plugin) that has no default yet. The default model is the tier === 'smartest' live-catalogue entry from driver.listModels(), or the first live model if no smartest tier is found. If the model list can't be resolved (offline, bad key), seeding is skipped silently — it never fails the credential creation. Driver fallback models can still help the picker explain common local options, but they are not trusted for automatic defaults. Scopes that already point at a credential are left untouched.
Defaults can also be cleared per scope from the Defaults tab. The UI calls
DELETE /admin/api/ai/defaults/:scope, removes the row from ai_defaults, and
unblocks deletion of the credential that had been protected by the default FK.
- Abort owns the whole response. "Stop" calls
agentSlice.abortAgent()and aborts the chat fetch. The server also owns a response-lifecycle controller:ReadableStream.cancel()or a failedcontroller.enqueue()aborts the same turn even when the original request signal does not observe a disappearing response consumer.AbortSignal.any()threads that combined signal into the provider request and browser bridge, and the handler'sfinallydestroys the bridge and releases the per-conversation writer lock. - Pending calls become terminal.
runChatpersistsINTERRUPTED_TOOL_RESULT_ERRORfor every declared tool call still unresolved on a graceful abort or terminal driver event. A hard process stop can still land between those two writes, so both recovery projections enforce the same invariant:buildMessageHistoryinjects a synthetic error for provider replay, whilerehydrateMessagesrenders an unmatched or malformed call as a failed historical badge.pendingtherefore means only work owned by the current live stream; a reload never shows an old spinner. Adjacent synthetic results plus the following real user prompt are merged into one user turn bypushUserContentinserver/ai/drivers/anthropic.ts, satisfying Anthropic's strict user/assistant alternation requirement. - Browser bridge failures are terminal once. A browser executor resolving
{ ok: false }remains an ordinary model-correctable tool outcome. A rejectedcallBrowseris transport failure instead: the loop emits exactly one failedtoolResult, then one terminalerror, and does not spend another provider round retrying against the same dead bridge. A missing result still has a 90-second upper bound (BROWSER_TOOL_TIMEOUT_MS), but it now ends the turn rather than starting a chain of 90-second retries. - Crash on server. If
runChatthrows, the stream emits{ type: 'error', message }. The browser surfaces the message verbatim in the Agent Panel (admin-only surface, so info-disclosure is not a concern). - Tool failure. Browser executors wrap every call in try/catch. Failures return
{ ok: false, error }. The model reads the error message in the next turn and retries with corrected input. - Tool-result delivery failure. A 404 means the browser completed work for a bridge the active runtime no longer owns (commonly a server restart). While the chat signal is active,
postToolResultpropagates that failure, the client aborts the stale response, finalizes its pending badge, and asks the user to send again. Only a POST already being torn down by an aborted signal is ignored quietly. A clean NDJSON EOF withoutdoneorerroris handled the same way instead of being mistaken for success. - Page reload mid-stream. The response cancel hook aborts the provider and releases the writer lock. Conversation rows survive; loading the thread shows any unmatched call once as interrupted, with no reconstructed session-only screenshot and no live timeout/spinner.
| Pattern | Use instead |
|---|---|
Importing any provider SDK (@anthropic-ai/sdk, @anthropic-ai/claude-agent-sdk, @openai/agents, @openrouter/agent) |
Banned repo-wide — no exceptions, including inside server/ai/drivers/. Drivers talk directly to the REST API. Gated by ai-driver-isolation.test.ts. |
Importing @modelcontextprotocol/sdk outside server/ai/mcp/ |
The MCP SDK is scoped to Instatic's MCP server implementation only. Drivers and browser code must not import it. Gated by ai-driver-isolation.test.ts. |
Importing zod anywhere |
Banned repo-wide — TypeBox schemas pass directly as JSON Schema to every provider. Gated by ai-driver-isolation.test.ts. |
Writing a private parseToolArguments / parseJsonOrEmpty copy inside a driver |
Import parseToolArguments from ./http/toolArgs. Private copies diverge silently — the same malformed model output produces different outcomes per provider. Gated by ai-driver-shared-helpers.test.ts. |
Redefining SYSTEM_PROMPT_DYNAMIC_BOUNDARY in a driver or prompt builder |
Import it from server/ai/runtime/types.ts. One source — if a driver or builder drifts the literal, prompt caching silently breaks for that driver. Gated by ai-driver-shared-helpers.test.ts. |
| Routing a write tool as a server-side read (resolving from snapshot) | Write tools are execution: 'browser' — they must go through the bridge. The Site editor store or Content workspace bridge is the write authority. |
Using invented breakpoint ids in breakpointStyles ("mobile", "desktop", etc.) |
Use verbatim ids from the dynamic suffix. Invalid ids are rejected by the executor. |
docs/features/html-import.md— theimportHtmlpipeline thatsite_insert_htmlandsite_replace_node_htmlrun throughdocs/editor.md— agent slice composition inside the editor storedocs/server.md— handler routing;/admin/api/ai/is matched before/admin/api/cms/docs/features/auth-and-access.md— capability model (ai.chat,ai.tools.write)docs/features/content-workspace.md— content workspace UI and content-scope Agent Panel mount- Source-of-truth files:
src/core/ai/toolOutput.ts—AiToolOutputtype,AiToolOutputSchema,aiToolOk,aiToolError(canonical bridge result)src/core/ai/chatRequest.ts— canonical browser-to-server chat envelope and computed multi-image request ceilingsrc/core/ai/contentBlock.ts— persisted/provider content blocks plus the lazy-URL conversation-detail view schemasrc/core/ai/userImage.ts— accepted source formats, normalised JPEG schema, byte/dimension limits, and eight-image per-message boundsrc/core/ai/toolSchemas.ts— all site browser-tool input schemas (single source of truth); includes the flat provider schema and exact execution union required forsite_apply_csssrc/core/ai/documentRefs.ts— document refs/descriptors for pages, templates, and visual componentssrc/core/ai/readSurface.ts— runtime-agnosticrenderAgentDocumentannotated HTML + compact CSS renderersrc/core/ai/index.ts— barrel re-exporting the aboveserver/ai/tools/site/writeTools.ts— 29 browser-bridged site tool definitions (uses@core/aiinput schemas)server/ai/tools/site/readTools.ts— 6 server-side catalog tool definitionsserver/ai/tools/site/render.ts—describeAgentModules,describeAgentTokens,filterTokenFamilyserver/ai/tools/site/systemPrompt.ts— HTML-native system promptserver/ai/tools/site/snapshot.ts—SiteAgentSnapshotSchema+SiteAgentSnapshotre-export + catalog output types (ModuleInfo,SnapshotTokens, …)server/ai/tools/content/readTools.ts— 7 server-side content read tool definitionsserver/ai/tools/content/writeTools.ts— 8 browser-bridged content write/navigation tool definitionsserver/ai/inputImages.ts— server-side base64, JPEG, byte, and dimension validation before persistenceserver/ai/drivers/modelCapabilities.ts— cached, timed, authoritative/fail-closed selected-model capability resolution on every turnserver/ai/drivers/modelList.ts— bounded provider catalogue lookup with caller cancellationserver/ai/drivers/http/toolLoop.ts— provider loop, heavy tool-result elision, and one provider-triggered historical-image fallbackserver/ai/conversations/history.ts— interrupted-tool healing plus outbound user-image replay projectionserver/ai/tools/content/systemPrompt.ts— markdown-native content system promptserver/ai/tools/content/snapshot.ts—ContentSnapshotshape consumed by the content prompt and tool contextsrc/admin/pages/site/agent/siteAgentSnapshot.ts—SiteAgentSnapshotSchema(TypeBox source of truth) +SiteAgentSnapshot(derived type) +buildSiteAgentSnapshotserver/ai/handlers/chat.ts—POST /admin/api/ai/chat/:scopeendpointserver/ai/handlers/conversations.ts— conversation CRUD plus the ownership-guarded lazy image endpointserver/ai/handlers/toolResult.ts—POST /admin/api/ai/tool-resultendpointsrc/core/ai/toolOutput.ts— canonicalAiToolOutputenvelope + sharedINTERRUPTED_TOOL_RESULT_ERRORserver/ai/conversations/store.ts—appendMessage,listMessagesForConversation,readConversationForUserserver/ai/runtime/runner.ts—runChat()driver loopserver/ai/contextTokens.ts—normalizeContextTokens()— provider-normalised "context used" for the meterserver/ai/pricing/index.ts—resolveCostUsd,getModelCatalogue,computeCostUsdserver/ai/pricing/openrouterCatalogue.ts—fetchOpenRouterCatalogue,pricingKey,ModelCatalogueserver/ai/pricing/store.ts— durableai_model_pricingDB cacheserver/ai/runtime/persister.ts—ConversationsPersisterinterface +createConversationsPersister()server/ai/runtime/types.ts— canonicalAiStreamEvent,AiMessage,AiTool,ToolContexttypesserver/ai/runtime/transport.ts—createBridge()/resolveBridgeToolResult()src/admin/ai/toolResultApi.ts— browser tool-result delivery; active failures terminate the stale chat turnsrc/admin/pages/site/agent/agentApi.ts— conversation bootstrap + terminal historical tool-call rehydrationsrc/admin/pages/site/agent/toolCallLifecycle.ts— live-stream pending-call finalizationserver/ai/audit/store.ts—getUsageTotals,getUsageByUser,getUsageByScope,getUsageByModel,getUsageByDay(usage rollup queries)server/ai/handlers/audit.ts—GET /admin/api/ai/audithandlerserver/time.ts—resolveTimeZone+localDayKeyFactory(shared timezone day-bucketing utilities)src/admin/pages/ai/AiPage.tsx—/admin/aiworkspace (Providers / Defaults / Audit tabs)src/admin/pages/ai/tabs/AuditTab.tsx— usage audit view (totals strip, tables, daily bar chart)src/admin/pages/ai/tabs/UsageTablePanel.tsx— shared table scaffolding for audit rollupssrc/admin/ai/usageFormat.ts— sharedformatNumber/formatCosthelperssrc/admin/pages/site/agent/agentSlice.ts— scope-agnostic slice factory (createAgentSlice)src/admin/pages/site/agent/agentProviderUpdate.ts— timed provider/model update and ambiguous-commit reconciliationsrc/admin/pages/site/agent/agentSliceConfig.site.ts— site-editor scope configsrc/admin/pages/site/agent/agentApi.ts— conversation bootstrap and message rehydrationsrc/admin/pages/site/agent/streamEvents.ts—ServerStreamEventSchema+processStreamEventsrc/admin/pages/site/panels/AgentPanel/AgentImageGallery.tsx— shared compact gallery for persisted and session-only imagessrc/admin/pages/site/panels/AgentPanel/AgentImagePreview.tsx— draggable modeless image previewsrc/admin/pages/site/panels/AgentPanel/AgentImageContextMenu.tsx— shared image actions menusrc/admin/pages/site/panels/AgentPanel/agentImageActions.ts— clipboard, download, and Media-save pipelinesrc/admin/shared/FloatingWindow/— shared portal, panel header, and persisted drag behavior for admin floating windowssrc/admin/pages/site/agent/pageContext.ts—buildCurrentPageContextsrc/admin/pages/site/agent/executor.ts— write-tool browser dispatcher + auto-navigationsrc/admin/pages/site/agent/tokenRunners.ts— design-system token tool runners (site_set_color_tokens,site_set_font_tokens,site_set_type_scale,site_set_spacing_scale)src/admin/pages/site/agent/agentConfig.ts— API path constantssrc/admin/pages/site/agent/renderEvidence.ts—captureAgentRenderSnapshotsrc/admin/pages/site/agent/types.ts—ServerStreamEvent,AgentMessage,AgentRequestBody, …src/admin/pages/site/agent/index.ts— public barrelsrc/admin/pages/content/agent/ContentAgentMount.tsx— content workspace AgentPanel mount + live bridge handle registrationsrc/admin/pages/content/agent/contentAgentStore.ts— standalone content-workspace agent storesrc/admin/pages/content/agent/contentBridge.ts— content write-tool browser dispatchersrc/admin/pages/content/agent/contentBridgeHandle.ts— imperative bridge handle registered by ContentPagesrc/admin/pages/site/panels/AgentPanel/AgentComposer.tsx— resolves model window/pricing/capabilities and places the meter in the action rowsrc/admin/pages/site/panels/AgentPanel/ContextMeter.tsx— five-segment context status and rich usage tooltipsrc/admin/pages/site/panels/AgentPanel/contextMeterMetrics.ts— exact five-band fill/tone calculation
- Gate tests:
src/__tests__/architecture/ai-tool-input-object.test.tssrc/__tests__/architecture/ai-tool-schema-ssot.test.tssrc/__tests__/architecture/ai-driver-isolation.test.tssrc/__tests__/architecture/ai-tools-typebox-only.test.tssrc/__tests__/architecture/ai-handlers-capability-gated.test.tssrc/__tests__/architecture/ai-driver-shared-helpers.test.ts