Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/thePrnvBot/dispel-web-stylist/llms.txt

Use this file to discover all available pages before exploring further.

Dispel’s AI layer is built on the Vercel AI SDK (ai package). Rather than asking the model to return CSS as plain text in a chat reply, Dispel uses a tool-calling loop where the model must invoke a generateCss or reportError tool — ensuring structured, parseable output every time. This design means the side panel never has to parse markdown fences or strip prose from a model response; it receives either a clean CSS string or a typed error object with an AppErrorCode.
The AI model never outputs plain text. It must call generateCss or reportError. If neither tool is called within four steps, Dispel automatically emits a STREAM_INCOMPLETE error to the caller.

Agent Tools

Three tools are registered with the Vercel AI SDK streamText call. Two are static singleton objects (generateCss and reportError) and one is created per-request (getPageHTML) because it must capture the current tabId.

generateCss

The terminal success tool. When the model has enough context to produce styles, it calls generateCss with a raw CSS string. The execute function is intentionally trivial — it returns the CSS value unchanged so the streaming pipeline can forward it directly to the content script.
import { tool } from "ai";
import { z } from "zod";

const generateCss = tool({
  description:
    "Generate CSS styles to the page. Return only valid CSS rules with no markdown, explanations, or comments.",
  inputSchema: z.object({
    css: z
      .string()
      .describe("Raw CSS rules targeting specific selectors on the page"),
  }),
  execute: async ({ css }: { css: string }) => css,
});

reportError

The terminal failure tool. When the model cannot satisfy the request — because the target element is not in the DOM, the request is ambiguous, or the operation is unsafe — it calls reportError with one of the permitted LLM_ERROR_CODES values and a human-readable explanation. The Zod enum on code ensures the model cannot invent arbitrary error strings.
const reportErrorSchema = z.object({
  code: z.enum(LLM_ERROR_CODES),
  message: z
    .string()
    .describe("Specific reason the request cannot be fulfilled"),
});

const reportError = tool({
  description:
    "Report why the CSS request cannot be fulfilled with a specific error code and message.",
  inputSchema: reportErrorSchema,
  execute: async ({ code, message }: z.infer<typeof reportErrorSchema>) => ({
    code,
    message,
  }),
});

getPageHTML (dynamic)

A per-request tool bound to a specific browser tab. It is created fresh for each streamChat call via createGetPageHTMLTool(tabId). When invoked by the model, it sends the GET_PAGE_HTML message to the content script and returns the sanitized viewport HTML as a string. The optional selector parameter lets the model narrow the extraction to a single element when it already has a candidate selector and wants to verify its structure before writing rules.
export function createGetPageHTMLTool(tabId: number) {
  return tool({
    description:
      "Fetch cleaned HTML of the current viewport. Call with no selector to see all visible top-level elements. Call with a selector to inspect a specific element. Returns the HTML string, or 'Element not found in current viewport' if the selector does not match a visible element.",
    inputSchema: z.object({
      selector: z
        .string()
        .optional()
        .describe(
          "Optional CSS selector to scope extraction to a specific element"
        ),
    }),
    execute: async ({ selector }: { selector?: string }) => {
      const result = await sendTabMessage(tabId, "GET_PAGE_HTML", { selector });
      if (result._tag === "Err") {
        logError("agentTools.getPageHTML", result.error);
        return `Failed to fetch page HTML (${result.error.code})`;
      }
      return result.value;
    },
  });
}
To observe what the model receives as DOM context, enable Debug viewport size in Settings. The side panel will display the character count of the HTML string returned by getPageHTML, helping you diagnose CONTEXT_LIMIT_REACHED errors caused by large pages.

System Prompt Structure

The system prompt is assembled from two parts: a static STATIC_GUIDELINES constant and a dynamic section generated by generateSystemPrompt(). Together they tell the model what CSS practices to follow, what constraints apply, and the specifics of the current page and request.

STATIC_GUIDELINES

The static portion never changes across requests. It defines selector strategy, scoping rules, forbidden patterns, modern CSS preferences, and the Shadow DOM constraint. Keeping it static means it can be sent as a single string without recomputation.
export const STATIC_GUIDELINES = `
===================
SELECTOR STRATEGY
===================
- Use precise, stable, and minimal selectors that exist on page reload
- Prefer:
  1. id selectors (#id)
  2. data-testid, data-test, data-qa attributes
  3. class selectors (.class)
  4. structural selectors (tag, nth-child, descendant combinators)
- Use selectors from the provided node list when available
- Avoid overly deep selector chains
- Avoid generic selectors like div, span unless necessary

IMPORTANT - USE PROVIDED SELECTORS:
- The node list provides stable selectors (id, data-testid, class combinations)
- Prefer these over generating new selectors
- Only fall back to structural selectors if no stable selector exists

===================
SCOPING & SAFETY
===================
- Scope styles to the smallest relevant element or container
- Do NOT affect unrelated parts of the page
- Do NOT modify global layout unless explicitly requested

FORBIDDEN:
- Universal selectors (*) unless absolutely required
- all: unset / all: initial / aggressive resets
- Styling html or body unless explicitly requested
- Excessive use of !important
- position: fixed or absolute on major layout elements unless required

===================
MODERN CSS PRACTICES
===================
- Use modern CSS (flexbox, grid, gap, clamp, etc.)
- Prefer responsive units (%, rem, vw, vh) over fixed px where appropriate
- Use shorthand properties when clean and readable
- Use CSS variables ONLY if they improve clarity and reuse

===================
EXTERNAL RESOURCES
===================
- Avoid external resources (remote fonts, @import URLs, CDN-hosted assets, external images)
  unless explicitly required by the user prompt
- Prefer pure CSS solutions and system fonts when possible

===================
ATTACHED FILES
===================
- Attached files may contain reference styles, design tokens, implementation constraints,
  or existing CSS provided by the user
- Follow the guidance in attached files unless it directly conflicts with explicit instructions
  in the user's prompt
- When conflicts exist, the user's prompt text takes precedence

===================
VISUAL QUALITY
===================
- Maintain clean spacing, alignment, and hierarchy
- Ensure readability and contrast
- Avoid redundant or conflicting rules

===================
ROBUSTNESS
===================
- Handle incomplete or noisy data gracefully
- Ignore duplicate or irrelevant elements
- If elements cannot be confidently identified, use the reportError tool

===================
SHADOW DOM CONSTRAINT
===================
- Do NOT assume styles can affect elements inside Shadow DOM
- Only target elements accessible from the main DOM tree
- If element is inside Shadow DOM, use the reportError tool
`;

generateSystemPrompt()

The dynamic portion is appended after STATIC_GUIDELINES and contains three optional sections, each rendered only when the relevant data is present:
1

PAGE CONTEXT

Appended when pageContext.meta is available. Provides the page URL, title, and viewport dimensions (width × height with scroll offsets). The model uses this to understand layout constraints and write responsive CSS.
===================
PAGE CONTEXT
===================
URL: https://example.com/dashboard
Title: Dashboard — Example
Viewport: 1440x900 (scroll: 0,120)
2

SELECTED ELEMENTS

Appended when the user has picked one or more elements via the picker tool. Each entry is the element’s outerHTML prefixed with its tag label. The model is instructed to prioritise these elements when writing selectors.
===================
SELECTED ELEMENTS (prioritize styling these)
===================
1. <button> <button class="btn-primary" data-testid="submit">Submit</button>
3

CURRENT STYLES

Appended when there is already a compiled CSS string applied to the page (from a previous turn in the draft session). Critically, this section ends with an instruction that the model must output a complete replacement stylesheet — not just the new rules — so that each generateCss call is idempotent and the <style> tag can be replaced wholesale.
===================
CURRENT STYLES (already applied to page)
===================
.btn-primary { background: #0066cc; }

CRITICAL: Output a COMPLETE replacement CSS stylesheet that includes ALL existing styles above PLUS the new changes. Do NOT output only the new changes — the output must be a full, self-contained CSS block that replaces the previous stylesheet entirely.

Streaming Pipeline

The streamChat function is an async generator that manages the full AI request lifecycle. It accepts a StreamChatOptions bag, constructs the model, initiates streamText, and yields StreamChatChunk items.
export type StreamChatChunk =
  | { type: "css"; css: string }
  | { type: "error"; code: AppErrorCode; message: string };

interface StreamChatOptions {
  abortSignal?: AbortSignal;
  apiKey: string;
  baseUrl: string;
  compiledCss?: string;
  message: string;
  modelId: string;
  pageContext: IPageContext;
  pickedElements?: { outerHTML: string; label: string }[];
  provider: PROVIDER_TYPES;
  tabId: number;
}
The pipeline proceeds through these stages:
1

Model construction

createModel selects the correct provider factory from modelFactories using the provider field, calls it with the apiKey and baseUrl, then calls the returned function with modelId to obtain a LanguageModel instance.
2

streamText invocation

The Vercel AI SDK streamText is called with the assembled system prompt, the user’s message, and the three agent tools (generateCss, reportError, getPageHTML).
3

prepareStep hook — DOM fetch guard

Before each step, prepareStep inspects the steps completed so far. If getPageHTML has already been called in any previous step, it is removed from activeTools. This prevents the model from making repeated DOM fetches in a single session, keeping the request count and cost predictable.
prepareStep: ({ steps }) => {
  const alreadyCalled = steps.some((step) =>
    step.toolCalls?.some((call) => call.toolName === "getPageHTML")
  );
  return {
    activeTools: alreadyCalled
      ? ["generateCss", "reportError"]
      : ["generateCss", "reportError", "getPageHTML"],
  };
},
4

stopWhen — termination conditions

The stream stops as soon as any of three conditions is met:
  • hasToolCall("generateCss") — the model produced CSS
  • hasToolCall("reportError") — the model reported an error
  • stepCountIs(4) — the model has taken four steps without a terminal call
stopWhen: [
  hasToolCall("generateCss"),
  hasToolCall("reportError"),
  stepCountIs(4),
],
5

fullStream filtering

result.fullStream is piped through asyncFilterMap with toStreamChunk. Only tool-result events with toolName === "generateCss" or toolName === "reportError" are yielded as StreamChatChunk values; all other stream events (text deltas, step metadata) are dropped.
6

STREAM_INCOMPLETE guard

After the stream ends, if no streamErrors were collected and neither terminal tool appears in any step’s toolResults, a STREAM_INCOMPLETE error chunk is yielded. This handles the edge case where the model exhausts all four steps calling only getPageHTML.

Error Classification

When the streamText call throws, Dispel classifies the error using an ordered list of matchers before falling back to API_ERROR.
const CONTEXT_LIMIT_RE =
  /(context[_\s-]?length|context_length_exceeded|context window|maximum (context|input|content)( length)?|token(s)? (limit|exceeded|too many|count exceeds)|too many tokens|input too long|prompt too long|request (exceeds|too large)|maximum size|exceeded.*(context|token))/i;
The matchers are evaluated in order and the first non-null match wins:
ConditionEmitted code
APICallError with statusCode === 429RATE_LIMITED
APICallError with statusCode === 413CONTEXT_LIMIT_REACHED
APICallError with statusCode === 400 or 422 and message/body matches CONTEXT_LIMIT_RECONTEXT_LIMIT_REACHED
Any other thrown valueAPI_ERROR
const errorMatchers: ErrorMatcher[] = [
  (e) =>
    APICallError.isInstance(e) && e.statusCode === 429
      ? {
          code: "RATE_LIMITED",
          message: "Rate limit reached. Please wait a moment and try again.",
        }
      : null,
  (e) =>
    APICallError.isInstance(e) && e.statusCode === 413
      ? { code: "CONTEXT_LIMIT_REACHED", message: CONTEXT_LIMIT_MESSAGE }
      : null,
  (e) =>
    APICallError.isInstance(e) &&
    (e.statusCode === 400 || e.statusCode === 422) &&
    CONTEXT_LIMIT_RE.test(`${e.message ?? ""} ${e.responseBody ?? ""}`)
      ? { code: "CONTEXT_LIMIT_REACHED", message: CONTEXT_LIMIT_MESSAGE }
      : null,
];
The CONTEXT_LIMIT_RE pattern is intentionally broad to catch the varied wording that different providers use for context-window errors — OpenAI uses context_length_exceeded, Anthropic uses prompt too long, and OpenRouter may relay either.

Provider Factories

Each supported provider has a factory function that wraps the respective AI SDK constructor. The modelFactories record maps every PROVIDER_TYPES value to a ModelFactory — a function that takes an API key and optional base URL and returns a function from modelId to LanguageModel.
type ModelFactory = (
  apiKey: string,
  baseUrl?: string
) => (modelId: string) => LanguageModel;

const modelFactories: Record<PROVIDER_TYPES, ModelFactory> = {
  openai:      (apiKey, baseUrl) => createOpenAI({ apiKey, baseURL: baseUrl }),
  anthropic:   (apiKey, baseUrl) => createAnthropic({ apiKey, baseURL: baseUrl }),
  google:      (apiKey, baseUrl) =>
    createGoogleGenerativeAI({ apiKey, baseURL: baseUrl }),
  local:       (apiKey, baseUrl) =>
    createOpenAICompatible({ name: "local", apiKey, baseURL: baseUrl ?? "" }),
  openrouter:  (apiKey, baseUrl) => createOpenRouter({ apiKey, baseUrl }),
};
The local provider uses @ai-sdk/openai-compatible so it works with any OpenAI-compatible server (Ollama, LM Studio, vLLM). For OpenRouter, the baseUrl is normalised: if the configured URL’s origin matches https://openrouter.ai, the baseUrl override is discarded and the SDK default is used, because the OpenRouter SDK ignores custom base URLs anyway.

CSS Application Flow

Once streamChat yields a { type: "css", css: string } chunk, the CSS propagates to the page through the following chain:
1

streamCSS accumulates chunks

streamCSS in stream-css.ts iterates over streamChat’s async generator and calls accumulate on each chunk, building a growing CSS string in state.css.
2

onChunk callback is invoked

Each time state.css grows, streamCSS calls the onChunk callback provided by the caller (the side panel’s submit workflow), passing the current accumulated CSS string.
3

applyCssToTab sends APPLY_CSS

Immediately after the onChunk callback returns, streamCSS awaits applyCssToTab(tabId, state.css), which sends the APPLY_CSS message to the content script in the active tab. The two calls are sequential within each chunk iteration.
4

Content script injects the style tag

The content script’s APPLY_CSS handler replaces the contents of its managed <style> tag with the new CSS string. Because this happens on every chunk, the user sees the page update in real time as the model streams the stylesheet.
Because each chunk overwrites the entire <style> tag rather than appending, the model must output a complete stylesheet in a single generateCss call. This is enforced by the CURRENT STYLES section of the system prompt, which instructs the model to include all prior styles when a compiled CSS string is provided.

Build docs developers (and LLMs) love