Building an AI Coding Assistant

This guide extracts the core patterns from jiki’s AI coding examples (Claude, OpenAI, Mistral) into reusable recipes. Each pattern is a standalone building block you can adapt to your own AI-powered editor.

Architecture

Every AI coding assistant built with jiki follows the same flow:

User prompt → LLM API (streaming) → Code extraction → container.writeFile() → Live preview
                                                                        ↑
                                                            Error → Feed back to LLM

The key pieces are:

  1. Stream tokens from an LLM API in the browser
  2. Extract code from the streamed response (code fences)
  3. Write it into a jiki container
  4. Render a preview via iframe
  5. Catch errors and feed them back to the LLM for correction

Streaming LLM responses

All three providers use the same fetch + ReadableStream pattern. The differences are in headers and event parsing.

Anthropic Messages API:

const response = await fetch("https://api.anthropic.com/v1/messages", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "x-api-key": apiKey,
    "anthropic-version": "2023-06-01",
    "anthropic-dangerous-direct-browser-access": "true",
  },
  body: JSON.stringify({
    model: "claude-opus-4-6",
    max_tokens: 4096,
    system: SYSTEM_PROMPT,
    stream: true,
    messages,
  }),
});

OpenAI / Mistral Chat Completions API:

const response = await fetch("https://api.openai.com/v1/chat/completions", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${apiKey}`,
  },
  body: JSON.stringify({
    model: "gpt-5.4-mini",
    max_completion_tokens: 4096,
    messages: [{ role: "system", content: SYSTEM_PROMPT }, ...messages],
    stream: true,
  }),
});

Parsing the stream — both APIs use Server-Sent Events. Read the stream line by line and extract text deltas:

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let fullText = "";

while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  buffer += decoder.decode(value, { stream: true });
  const lines = buffer.split("\n");
  buffer = lines.pop() || "";

  for (const line of lines) {
    if (!line.startsWith("data: ")) continue;
    const data = line.slice(6).trim();
    if (data === "[DONE]") break;

    try {
      const event = JSON.parse(data);

      // Anthropic: event.delta.text
      // OpenAI/Mistral: event.choices[0].delta.content
      const text =
        event.delta?.text ?? event.choices?.[0]?.delta?.content;

      if (text) {
        fullText += text;
        onToken(text); // update UI incrementally
      }
    } catch {
      // skip unparseable lines
    }
  }
}

Extracting code from responses

LLM responses contain code inside markdown fences. Extract them with a regex:

Single component (Claude example pattern):

function extractAppCode(text: string): string | null {
  const regex = /```(\w*)\n([\s\S]*?)```/g;
  const blocks: Array<{ lang: string; code: string }> = [];
  let match;

  while ((match = regex.exec(text)) !== null) {
    blocks.push({ lang: match[1] || "jsx", code: match[2].trim() });
  }

  if (blocks.length === 0) return null;

  // Prefer blocks containing a function named App
  for (let i = blocks.length - 1; i >= 0; i--) {
    if (/function\s+App\s*\(/.test(blocks[i].code)) {
      return blocks[i].code;
    }
  }

  return blocks[blocks.length - 1].code;
}

Multi-file extraction (OpenAI example pattern — generates multiple files per response):

function extractNamedFiles(text: string): Record<string, string> {
  const files: Record<string, string> = {};

  // Match code blocks with filename labels: ```tsx filename="/src/App.tsx"
  const regex = /```\w*\s+filename="([^"]+)"\n([\s\S]*?)```/g;
  let match;

  while ((match = regex.exec(text)) !== null) {
    files[match[1]] = match[2].trim();
  }

  return files;
}

Writing code into jiki

Once you have the extracted code, write it to the container and trigger a preview update:

Single file:

const code = extractAppCode(llmResponse);
if (code) {
  container.writeFile("/src/App.jsx", code);
  // Rebuild preview
  const html = await assembleHtml(container);
  iframe.srcdoc = html;
}

Multiple files:

const files = extractNamedFiles(llmResponse);
for (const [path, code] of Object.entries(files)) {
  container.writeFile(path, code);
}
// Rebuild preview after all files are written

If you’re using a dev server with HMR (ViteDevServer), the preview updates automatically when files change — no manual rebuild needed.

Error recovery

When generated code has runtime errors, catch them and feed the error back to the LLM for correction. This creates a self-healing loop.

Step 1 — Inject an error overlay that captures exceptions in the preview iframe:

const overlayScript = `
  window.onerror = (msg, src, line, col, err) => {
    parent.postMessage({
      type: "runtime-error",
      error: { message: msg, stack: err?.stack, line, column: col }
    }, "*");
  };
  window.onunhandledrejection = (e) => {
    parent.postMessage({
      type: "runtime-error",
      error: { message: e.reason?.message || String(e.reason) }
    }, "*");
  };
`;

Step 2 — Listen for errors in the parent window and display them:

window.addEventListener("message", (e) => {
  if (e.data?.type === "runtime-error") {
    const errorMsg = e.data.error.message;
    // Display to user, or automatically ask the LLM to fix
    addMessage("user", `I got this error: ${errorMsg}. Please fix the code.`);
  }
});

Step 3 — The LLM sees the error in the conversation history and generates corrected code. The system prompt should include: “If the user reports an error, fix the code and output the corrected version.”

Chat history persistence

Store conversation history in IndexedDB so it survives page refreshes:

const DB_NAME = "jiki-ai-chat";
const STORE_NAME = "messages";

async function openDB(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        const store = db.createObjectStore(STORE_NAME, { keyPath: "id" });
        store.createIndex("timestamp", "timestamp");
      }
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

async function saveMessage(msg: { id: string; role: string; content: string; timestamp: number }) {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, "readwrite");
  tx.objectStore(STORE_NAME).put(msg);
}

async function loadMessages(limit = 50): Promise<any[]> {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, "readonly");
  const index = tx.objectStore(STORE_NAME).index("timestamp");
  const cursor = index.openCursor(null, "prev");
  const results: any[] = [];

  return new Promise((resolve) => {
    cursor.onsuccess = () => {
      const c = cursor.result;
      if (c && results.length < limit) {
        results.push(c.value);
        c.continue();
      } else {
        resolve(results.reverse());
      }
    };
  });
}

On app startup, restore messages and the last generated code:

const messages = await loadMessages();
const lastCode = messages
  .filter((m) => m.role === "assistant")
  .pop()?.content.match(/```(?:jsx|tsx)?\n([\s\S]*?)```/)?.[1];

if (lastCode) {
  container.writeFile("/src/App.jsx", lastCode);
}

Element inspection

Let users click elements in the preview and reference them in chat. The pattern uses postMessage between the iframe and parent:

In the preview iframe (injected script):

document.addEventListener("click", (e) => {
  if (!inspectMode) return;
  e.preventDefault();
  const el = e.target;
  const descriptor = `<${el.tagName.toLowerCase()}> with text "${el.textContent?.slice(0, 50)}"`;
  parent.postMessage({ type: "element-selected", descriptor }, "*");
});

In the parent (chat input):

window.addEventListener("message", (e) => {
  if (e.data?.type === "element-selected") {
    // Pre-fill chat: "Change the <button> with text 'Submit'..."
    setChatInput(`Modify the ${e.data.descriptor}: `);
  }
});

Next steps