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:
- Stream tokens from an LLM API in the browser
- Extract code from the streamed response (code fences)
- Write it into a jiki container
- Render a preview via iframe
- 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
- See the full working implementations: Claude, OpenAI, Mistral
- Set up API access: Provider Setup
- Learn about jiki’s error system: Errors API