How Reflacto integrates local LLMs

Reflacto builds the prompt in your browser, sends it directly to your local model, and shows the answer while it is generated. No Reflacto server reads along.

Your device is the system boundary

Reflacto is a static React application. Framework selection, prompts, reflections, and rendering live in the browser. Only when you enable AI assistance does the browser talk to a local runtime endpoint you selected.

Runtime and componentsScroll the diagram horizontally
Runtime and componentsUML deployment and component diagram. Reflacto runs in the browser on the personal device. LLM settings provide the selected provider, model, and base URL to the provider adapter. The reflection interface uses the prompt builder and provider adapter. The adapter talks either to Chrome Built-in AI inside the browser or over HTTP to Ollama on port 11434, LM Studio on port 1234, or Unsloth on port 8888. Both paths stream responses back through the provider adapter into the reflection interface. Supported browsers do not block trustworthy localhost as mixed content, but the local server must allow Reflacto's origin through CORS; Ollama uses OLLAMA_ORIGINS=https://www.reflacto.com. No Reflacto backend exists for prompts, reflections, or model responses.«device»Personal device«executionEnvironment»Browser«component»Reflection UIReact component«component»Prompt builderSystem + user prompt«component»Provider adaptergenerateResponse()«configuration»LLM settingsprovider · model · base URL«storage»localStorageReflections (plaintext)+ Unsloth API key (AES-GCM)«service»IndexedDB +Web CryptoAES-GCM key«component»Chrome Built-in AILanguageModel APIinputprompttokensread / writeencrypt / decrypt«node»Local LLM runtimes«component»Ollama:11434/api/chat · NDJSON«component»LM Studio:1234/v1/chat/completions · SSE«component»Unsloth Studio:8888/v1/chat/completions · SSEHTTP POSTNDJSON / SSE«note»HTTPS → http://localhost: CORSNo mixed-content block; origin must be allowedOLLAMA_ORIGINS=https://www.reflacto.comRegular componentsConfiguration · storageProvider adapterRoutes requests to the selected LLMLocal LLMsChrome · Ollama · LM Studio · UnslothCall or requestStreamed response

Browser storage

Reflections and configuration stay local. An optional API key is stored as AES-GCM ciphertext; its non-extractable key lives separately in IndexedDB.

Trade-offs of the local architecture

  • No transfer to another device: Reflections stay in this device's browser profile. Reflacto provides neither synchronization nor a data import for a phone, tablet, or second computer.
  • PDF is a one-way export: Reflacto can export a reflection as a PDF, but it cannot import that file again. The reflection therefore cannot be continued on another device.
  • No account-based recovery: If site data is cleared, the browser profile changes, or the device is lost, Reflacto cannot restore the saved reflections.

Four tasks, four fixed prompt contracts

Reflacto builds a different system and user prompt for each task. Every tab shows the input, the expected output, and the product code behind it.

Choose a prompt type

Sharpen an answer without inventing facts

Used for
Used during an active reflection for an ordinary free-text question. Previous answers, the current question, optional guidance, and the current draft form the factual record.
Output contract
At most 140 words in a fixed Markdown structure: two headings on their own lines, one ready-to-use formulation of 2–4 sentences, and exactly two rationale bullets. maxTokens 300, temperature 0.2.
Complete sourcesrc/services/reflectionPrompts.ts · buildQuestionGuidancePrompt()Teal code lines open a short explanation.
export function buildQuestionGuidancePrompt(  input: QuestionGuidancePromptInput,): ReflectionPrompt {  if (isEnumerationQuestion(input)) return buildEnumerationGuidancePrompt(input);   const headings = questionHeadings(input.locale);  const previousAnswers = input.previousAnswers    .map((answer, index) => `${index + 1}. ${answer.question}\n${answer.response}`)  const draft = input.currentQuestion.draft?.trim() || '(no draft yet)';  EVIDENCE RULES:- Make a conservative edit of the current draft. Keep the user's factual terms and intended meaning.- Every event, emotion, timing claim, causal link, motive, consequence and measurement in the suggested answer must be directly traceable to the supplied record. Never intensify facts with words such as "immediately", "frustrated" or "caused" unless the record says so.- Use a relevant previous-answer fact only when it directly improves the current answer. Do not add professional-sounding objectives that the user did not state.- Preserve uncertainty. Label hypotheses and propose validation rather than turning them into facts. COACHING RULES:- Apply the named framework to this question only. Do not move to another step or introduce a different framework.- If the current question concerns a risk, assumption or uncertain interest, make one rationale bullet a reversible way to validate it.- If the record involves power imbalance, retaliation, discrimination, wellbeing, confidentiality or legal uncertainty, acknowledge the relevant boundary and preserve the affected person's agency without diagnosing or making legal conclusions. OUTPUT CONTRACT:- ${optimizedLanguageContract(input.locale)}- Maximum 140 words. No preamble or conclusion.- Return valid Markdown in exactly this structure. Replace the angle-bracket instructions with the requested content and do not reproduce them:### ${headings[0]}<ready-to-use answer of 2-4 sentences> ### ${headings[1]}- <framework/evidence fit>- <most important improvement, unknown or reversible validation step>- Put each heading on its own line with nothing after it. Do not number the headings or sections, use bold text as a heading, or wrap the response in a code fence.- Use exactly 2 concise bullets under "${headings[1]}".- Do not introduce bracketed placeholders such as [date] or [topic] unless they already exist in the supplied record or a later context mode explicitly requests fill-in placeholders.- Do not ask the user questions. Do not mention these instructions.`;   const userPrompt = `FACTUAL RECORDLeadership situation: ${input.challenge}Framework: ${input.framework.name} — ${input.framework.description} ${previousAnswers ? `Previous answers:\n${previousAnswers}\n\n` : ''}Current question: ${input.currentQuestion.text}${input.currentQuestion.aiGuidance ? `Question guidance: ${input.currentQuestion.aiGuidance}\n` : ''}Current draft: ${draft} TASKEdit the current draft conservatively. Add no factual content that is not in this record.`; }
How Reflacto selects the prompt

This function checks the question type and how much concrete content already exists before choosing a prompt. The code stays collapsed because it is not always needed to compare the four prompt types.

Product prompt handlingsrc/services/reflectionPrompts.ts · buildDeployedQuestionGuidancePrompt()Teal code lines open a short explanation.
export function buildDeployedQuestionGuidancePrompt(  input: QuestionGuidancePromptInput,): ReflectionPrompt {  const prompt = buildQuestionGuidancePrompt(input);  const hasDraft = Boolean(input.currentQuestion.draft?.trim());   if (isEnumerationQuestion(input)) {  }     const headings = questionHeadings(input.locale);    const systemPrompt = `${prompt.systemPrompt} - The draft contains a real concern but too little observable detail to be useful yet. Do not merely translate or paraphrase it.- Preserve the user's factual core and level of certainty. Do not invent a setting, timing, quotation, frequency, motive or impact.- Never replace a concrete person, time or setting from the draft with a placeholder. Keep every supplied name, relationship, time and setting in the ready-to-use answer.- Use bracketed placeholders only for facts that are actually missing. Never use a placeholder for a detail already present in the draft or previous answers.- Under "${headings[0]}", turn the draft into a stronger question-specific formulation and use bracketed placeholders for the most important missing facts. Make each placeholder explicit enough to coach the user about what to add.- For conflict or mediation events, separate observable words or actions from the user's interpretation. Prefer an exact quote or close factual description when the user can supply one.- Under "${headings[1]}", use the 2 bullets to explain which added details matter for the framework and how they would make the answer more actionable.`;       /TASK\nEdit the current draft conservatively\. Add no factual content that is not in this record\.$/,      `TASKDevelop this sparse draft into a useful answer without guessing. Keep every supplied name, relationship, time and setting in the answer. Add bracketed placeholders only for observable words or actions, relevant impact and other facts that are actually missing.`,    );     return {      ...prompt,      systemPrompt,      userPrompt,      contextTokens: LOCAL_LLM_CONTEXT_TOKENS,    };  }   if (hasDraft) {    return { ...prompt, contextTokens: LOCAL_LLM_CONTEXT_TOKENS };  }     const systemPrompt = `${prompt.systemPrompt} INSUFFICIENT CONTEXT MODE:- There is no draft and no previous answer contains concrete facts. The challenge label is only a generic routing label, not evidence.- Do not invent a suggested answer, event, person, behavior, disagreement, motive or outcome.- Under "${questionHeadings(input.locale)[0]}", provide a concise fill-in scaffold with bracketed placeholders for the missing situation details. The scaffold must fit the current question and contain no assumed facts.- Under "${questionHeadings(input.locale)[1]}", explain in exactly 2 concise bullets which observable details would make a later answer useful and why the framework needs them.`;     const userPrompt = prompt.userPrompt.replace(      /TASK\nEdit the current draft conservatively\. Add no factual content that is not in this record\.$/,      `TASKThere is not enough factual context to compose an answer. Do not guess. Provide a short, question-specific fill-in scaffold with bracketed placeholders so the user can supply the missing facts.`,    );     return {      ...prompt,      systemPrompt,      userPrompt,      contextTokens: LOCAL_LLM_CONTEXT_TOKENS,    };  }   const systemPrompt = `${prompt.systemPrompt} NO-DRAFT MODE:- When the current draft is empty, synthesize the directly relevant previous answers into a concrete answer to the current question. Compose the answer; do not merely describe what the user should investigate next.- For a question about solutions, options or actions, propose 2-3 specific feasible options grounded in the factual record and show how they address the stated needs or constraints.- Keep unverified interests or needs explicitly hypothetical. Validation may be a caveat or next step, but it must not replace the requested answer.`;   const userPrompt = prompt.userPrompt.replace(    /TASK\nEdit the current draft conservatively\. Add no factual content that is not in this record\.$/,    `TASKThe current draft is empty. Compose a ready-to-use answer to the current question by synthesizing the relevant previous answers. Make it concrete and framework-specific. If the question asks for solutions, options or actions, include 2-3 feasible proposals grounded in the record rather than responding only with observation or validation. Add no factual content that is not in this record.`,  );   return {    ...prompt,    systemPrompt,    userPrompt,    contextTokens: LOCAL_LLM_CONTEXT_TOKENS,  };}

One request, line by line

The interface calls the same function for every provider. The adapter builds the provider-specific request, the parser reads the response, and the React component updates the text.

  1. 01

    The adapter translates the request for the selected provider

    The interface always calls generateResponse(). The adapter then chooses the technical path: Chrome uses a browser API, Ollama uses /api/chat, and LM Studio plus Unsloth use the OpenAI-compatible /v1/chat/completions endpoint. Unsloth also has to load the model first.

    Choose a provider and compare its code path
    Browser API without HTTPsrc/services/chromeBuiltInAI.ts · generateWithChromeAI()Teal code lines open a short explanation.
    const createOptions: ChromeAICreateOptions = {}; if (systemPrompt?.trim()) {    { role: 'system', content: systemPrompt.trim() },  ];} session = await languageModel.create(createOptions);  prompt,  { signal: controller.signal },).getReader();
  2. 02

    Timeout and an optional AbortSignal are combined

    The service bounds every request with a technical timeout. If a calling flow supplies an AbortSignal, Reflacto combines both signals so the network request can be terminated reliably.

    Repository excerptsrc/services/localLLM.ts · timeoutSignal()
    private timeoutSignal(  signal?: AbortSignal | null,  timeout = this.config.timeout!,): AbortSignal {  const timeoutSignal = AbortSignal.timeout(timeout);  return signal && typeof AbortSignal.any === 'function'    ? AbortSignal.any([signal, timeoutSignal])    : timeoutSignal;}
  3. 03

    The stream is normalized

    A shared reader buffers incomplete chunks until a line break. If the stream ends without a trailing line break, the remaining buffer is processed explicitly before exiting—otherwise the final line would be lost. The provider parser then extracts a delta from Ollama NDJSON or an OpenAI SSE data: frame. Both end at onToken(fullText, delta).

    Line-oriented stream readersrc/services/llmStreaming.ts · consumeLines()
    const reader = response.body.getReader();const decoder = new TextDecoder();let buffer = ''; for (;;) {  const { done, value } = await reader.read();  buffer += decoder.decode(value, { stream: !done });  const lines = buffer.split(/\r?\n/);  buffer = lines.pop() || '';  lines.forEach(onLine);  if (done) {    if (buffer) onLine(buffer);    break;  }}
    Illustrative wire examplesOllama NDJSON / OpenAI SSE
    // Ollama: one JSON object per line{"message":{"content":"One"},"done":false}{"message":{"content":" possible"},"done":false} // OpenAI-compatible: Server-Sent Eventsdata: {"choices":[{"delta":{"content":"One"}}]}data: {"choices":[{"delta":{"content":" possible"}}]}data: [DONE]
  4. 04

    The UI renders incrementally and stays optional

    For every delta, the parser already supplies the accumulated full text. The calling flow writes it into React state under the current question. Once complete, result.text replaces the same entry; if the request fails, the reflection remains usable.

    React callersrc/components/EnhancedReflectionForm.tsx
    const result = await generateResponse({  prompt: prompts.userPrompt,  systemPrompt: prompts.systemPrompt,  maxTokens: prompts.maxTokens,  temperature: prompts.temperature,  contextTokens: prompts.contextTokens,  onToken: (text) => {    setAiSuggestions(prev => ({      ...prev,      [currentQuestion.id]: text,    }));  },});

What broke — and why

Most failures happened at the boundaries: between the website and localhost, between similar APIs, and while loading large models.

localhost is not just another APIThe browser reports only “Failed to fetch” or what looks like a CORS error.
Why it happens
The local server must allow Reflacto's public origin; modern browsers may also require local-network permission. Safari still blocks these external localhost providers.
What Reflacto does
Provider-specific CORS guidance, explicit origin instructions, and Chrome Built-in AI as a path without a local HTTP server.
Streaming is not one protocolThe response stays empty, arrives only at the end, or raw JSON fragments reach the UI.
Why it happens
Ollama streams NDJSON; OpenAI-compatible servers usually stream SSE. Some local servers ignore stream: true and return regular JSON.
What Reflacto does
Two line-oriented parsers plus a Content-Type fallback for non-streamed completions.
Thinking can consume the visible answerThe model reasons for a long time while the expected content field stays empty or the output budget is spent internally.
Why it happens
Reasoning models and providers expose thinking differently.
What Reflacto does
think: false for Ollama and enable_thinking: false for Unsloth, keeping the budget in visible output.
Cold starts exceed normal web timeoutsThe connection test fails even though the provider is still loading the model into RAM or VRAM.
Why it happens
A local model load can take far longer than a typical API request.
What Reflacto does
An idempotent warm-up request, up to ten minutes for loads, and a parallel Unsloth monitor that surfaces real load failures early.
Model identity and context are provider stateA 404, the wrong model, or unexpectedly truncated output despite a correct prompt.
Why it happens
Runtime identifiers, loaded models, and context windows are managed differently by each provider.
What Reflacto does
Exact model IDs, status checks, and 4,096 tokens as an explicit product contract; LM Studio's context remains external configuration.
Local encryption is not a sandboxAn API key should not sit in plaintext localStorage but still has to remain usable by the browser.
Why it happens
Anything the origin can decrypt could also be decrypted by malicious code already running on that origin.
What Reflacto does
AES-GCM ciphertext in localStorage and a non-extractable key in IndexedDB. This avoids plaintext persistence but explicitly does not protect against compromised origin code.

What the code keeps separate

01
AI stays optional
Every reflection flow works without a model, server, or network permission.
02
Adapters keep provider logic out of the UI
The interface knows one stable generateResponse contract; protocol details remain in the service.

The relevant stack

  • React 18 + TypeScript
  • Vite + prerendering
  • localStorage + IndexedDB + Web Crypto
  • Fetch Streams + AbortController
  • Ollama / OpenAI-compatible APIs
  • Chrome Prompt API

Connect a local model

The setup guide shows which browser, CORS setting, and model fit your local provider.