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.
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.
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.
src/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.
src/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.
- 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 pathBrowser API without HTTP src/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(); - 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 excerpt src/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;} - 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 reader src/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 examples Ollama 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] - 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 caller src/components/EnhancedReflectionForm.tsxconst 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.

