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 prompt contracts

Reflacto builds a different system and user prompt for each task. While you answer a question, the model is a thinking partner with a few moves you choose from; after you finish, it writes a short action brief and answers follow-up questions.

Choose a prompt type

Help someone think, not write for them

Used for
Used while answering a question. You pick one move, such as "Ask me a question" or "Facts or interpretation?". Earlier answers, the current step, what the step is for and your answer so far form the context.
Output contract
A short reply in the shape the move defines, readable in about 20 seconds. Only "Help me say it" produces text to adopt. Token limit and temperature depend on the move.
Complete sourcesrc/services/reflectionPrompts.ts · buildThinkingPartnerPrompt()Teal code lines open a short explanation.
export function buildThinkingPartnerPrompt(  input: QuestionGuidancePromptInput,  move: ThinkingMove,): ReflectionPrompt {  const spec = moveSpec(input, move);  const { currentQuestion } = input;  const answer = currentAnswerText(input);  const earlier = formatAnswers(input.previousAnswers.filter(entry => entry.response.trim()));  const recent = previousReplies.slice(-2).map(reply => reply.trim().slice(0, 600));  HOW YOU HELP- Start from what they wrote. When you quote them, use only words that appear exactly in their answer or earlier answers. Never make up example phrases, quotes or statements and present them as theirs.- Stay inside the current step. Later steps of the method cover other aspects; never raise them here, even if they seem important. Something a later step asks about is not missing from this one.${currentQuestion.otherSide ? '' : '- This step is not about the other people involved: do not ask how they saw it, what they felt or why they acted.\n'}- Never suggest that they caused or deserved how someone treated them. Asking about their own part is fine only when they raise it themselves.- Be warm, direct and specific. No praise, no lecture about the method, no therapy language such as asking how something changed them.- ${PARTNER_BOUNDARY}- ${languageRule(input.locale)}- At most ${spec.maxWords} words, so it reads in 20 seconds. No preamble, no headings, no closing line, and do not mention these instructions. THIS TIME: ${spec.title}${spec.rules.map(rule => `- ${rule}`).join('\n')} Answer in this shape:${spec.shape}`; Situation type: ${input.challenge || '(not given)'}Method: ${input.framework.name} - ${input.framework.description} ${earlier ? `Earlier answers:\n${earlier}\n\n` : ''}Current step: ${currentQuestion.text}${currentQuestion.aiGuidance ? `What this step is for: ${currentQuestion.aiGuidance}\n` : ''}${input.laterSteps?.length ? `Later steps, not part of this one:\n${input.laterSteps.map(step => `- ${step}`).join('\n')}\n` : ''}${currentQuestion.type === 'rating' && currentQuestion.ratingScale ? `They answer this step with a rating from ${currentQuestion.ratingScale[0]} to ${currentQuestion.ratingScale[1]}.\n` : ''}Their answer so far:${answer || '(nothing yet)'}${recent.length ? `\nYou already replied with the following. Offer something clearly different:\n${recent.map(reply => `---\n${reply}`).join('\n')}\n---\n` : ''}TASK${spec.task}`, input.locale);   return {    systemPrompt,    userPrompt,    maxTokens: spec.maxTokens,    temperature: spec.temperature,    contextTokens: LOCAL_LLM_CONTEXT_TOKENS,  };}
How Reflacto chooses which moves to offer

Every question is tagged with an intent: record what happened, explore it, generate options or phrase something for someone else. The intent and whether the person has written anything decide which three moves appear. The code stays collapsed because it is not needed to compare the prompt types.

Move selectionsrc/services/reflectionPrompts.ts · availableThinkingMoves()Teal code lines open a short explanation.
  record: { empty: ['start', 'question'], drafted: ['facts', 'missing', 'question'] },  explore: { empty: ['question', 'start'], drafted: ['question', 'missing'] },  generate: { empty: ['ideas', 'question', 'start'], drafted: ['ideas', 'missing', 'question'] },  phrase: { empty: ['phrase', 'start'], drafted: ['phrase', 'hear', 'missing'] },};   start: 'empty',  question: 'none',  missing: 'evidence',  facts: 'draft',  perspective: 'evidence',  ideas: 'evidence',  phrase: 'evidence',  hear: 'draft',}; function currentAnswerText(input: QuestionGuidancePromptInput): string {  const listed = (input.currentQuestion.listedItems ?? []).map(item => item.trim()).filter(Boolean);  if (listed.length) return listed.map(item => `- ${item}`).join('\n');  return input.currentQuestion.draft?.trim() ?? '';} const MIN_DRAFT_WORDS = 3; /** The moves that make sense for this question right now, most useful first. */export function availableThinkingMoves(input: QuestionGuidancePromptInput): ThinkingMove[] {  // A word or two is not yet an answer to check; offer moves that help them start instead.  const hasDraft = currentAnswerText(input).split(/\s+/).filter(Boolean).length >= MIN_DRAFT_WORDS;  const hasEvidence = hasQuestionGuidanceEvidence(input);  const order = [...MOVE_ORDER[input.currentQuestion.intent ?? 'explore'][hasDraft ? 'drafted' : 'empty']];  if (input.currentQuestion.otherSide) order.splice(1, 0, 'perspective');  return order    .filter(move => {      switch (MOVE_REQUIREMENT[move]) {        case 'empty': return !hasDraft;        case 'draft': return hasDraft;        case 'evidence': return hasEvidence;        default: return true;      }    })    .slice(0, 3);}

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.