Free model access and a free server do not save you from the oldest browser failure: a refresh or crash that erases the conversation and spends tokens on a second attempt behind your user's back. The fix is not a fancier loading spinner; it is a tiny resumable state machine that treats the tab as unreliable.
The recent interest in watermarked model output and agent tool gatekeepers is really a question about observable behavior, and an interface that says 'streaming' while a refresh destroys the transcript is just as opaque.
A free hosted chat can feel like a public kitchen with a very generous ticket. The model streams food quickly, the server keeps the door open, and everyone is welcome. But if the only copy of the order lives in the waiter's pocket, one dropped tray means the meal disappears and the kitchen has already used the ingredients. In a browser, the dropped tray is a tab refresh, a mobile browser eviction, or a laptop sleep.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode's project is described by its operator as an open source option with free model access and a free server. The operator states that the current allowance is 30 million free tokens. I am not going to invent model names, quotas, hardware, or permanence beyond that, because the useful part of this article does not depend on those details. What matters is that you can host a small HTML file on the free server and point it at a streaming model endpoint without first building a whole backend.
The demo below is a single file that keeps the conversation in localStorage, announces each state change in a polite live region, and handles one difficult case that many free-tier demos ignore: the user refreshes while the model is still streaming.
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<title>Resumable streaming chat</title>
</head>
<body>
<main>
<h1>Resumable chat</h1>
<p id='status' aria-live='polite'></p>
<div id='messages' aria-label='Conversation'></div>
<form id='composer'>
<label for='prompt'>Your message</label>
<textarea id='prompt' required></textarea>
<button type='submit'>Send</button>
<button type='button' id='cancel'>Cancel</button>
</form>
</main>
<script>
const MODEL_URL = 'https://your-monkeycode-endpoint.example/v1/stream';
const status = document.getElementById('status');
const messages = document.getElementById('messages');
const prompt = document.getElementById('prompt');
const cancel = document.getElementById('cancel');
const STORE_KEY = 'babychat:session';
let state = 'idle';
let controller = null;
const say = (text) => {
status.textContent = '';
requestAnimationFrame(() => { status.textContent = text; });
};
function render(conversation) {
messages.textContent = '';
for (const item of conversation) {
const p = document.createElement('p');
p.textContent = `${item.role}: ${item.text}`;
messages.append(p);
}
}
function save(conversation, inflight) {
localStorage.setItem(STORE_KEY, JSON.stringify({ conversation, inflight }));
}
function load() {
try {
const raw = localStorage.getItem(STORE_KEY);
if (!raw) return { conversation: [], inflight: null };
return JSON.parse(raw);
} catch {
return { conversation: [], inflight: null };
}
}
function restore() {
const saved = load();
if (!saved.conversation.length) return;
render(saved.conversation);
if (saved.inflight) {
say('Restored an interrupted response. Retry if you want to continue.');
} else {
say(`Restored ${saved.conversation.length} previous messages.`);
}
}
async function streamResponse(conversation) {
controller = new AbortController();
state = 'sending';
say('Sending message to model.');
const response = await fetch(MODEL_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ conversation }),
signal: controller.signal
});
if (!response.ok || !response.body) throw new Error(`HTTP ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let partial = '';
conversation.push({ role: 'assistant', text: '' });
state = 'streaming';
say('Model is responding.');
while (true) {
const { done, value } = await reader.read();
if (done) break;
partial += decoder.decode(value, { stream: true });
conversation[conversation.length - 1].text = partial;
render(conversation);
save(conversation, { role: 'assistant', text: partial });
}
state = 'idle';
save(conversation, null);
say('Response complete.');
}
document.getElementById('composer').addEventListener('submit', async (event) => {
event.preventDefault();
const text = prompt.value.trim();
if (!text) return;
prompt.value = '';
const conversation = load().conversation;
conversation.push({ role: 'user', text });
render(conversation);
save(conversation, { role: 'user', text });
try {
await streamResponse(conversation);
} catch (error) {
if (error.name === 'AbortError') {
state = 'cancelled';
save(conversation, conversation[conversation.length - 1]);
say('Cancelled. The partial response is still visible.');
return;
}
state = 'error';
save(conversation, conversation[conversation.length - 1]);
say(`Error: ${error.message}. Retry with the visible conversation.`);
} finally {
controller = null;
}
});
cancel.addEventListener('click', () => {
controller?.abort();
cancel.disabled = true;
setTimeout(() => { cancel.disabled = false; }, 200);
});
window.addEventListener('beforeunload', () => {
const conversation = load().conversation;
if (state === 'sending' || state === 'streaming') {
save(conversation, conversation[conversation.length - 1]);
}
});
restore();
</script>
</body>
</html>
The critical design choice is not the fetch call; it is that every state change has a text announcement and a stable conversation record. When the user sends a message, the assistant message object exists before the first token arrives, so the page always has a place to write partial text. When the response fails or is cancelled, the saved record keeps that partial text instead of throwing it away. That turns an invisible token spend into a visible, retryable state.
A polite live region is enough for progress updates because the user should keep typing and reading without focus being stolen. If you add an Escape key handler, keep the same rule: a cancellation announcement is polite, while a blocking error may deserve focus. The form stays keyboard-operable, and the visible conversation is built from plain DOM elements rather than an unlabeled list or canvas.
Here is the basic QA matrix to use with a browser and a screen reader.
| Scenario | Expected behavior |
| Refresh during streaming | User prompt and assistant partial remain visible; the live region says an interrupted response was restored. |
| Cancel button while streaming | Fetch aborts, partial remains, no completion message is announced. |
| Network drops mid-response | Error state announces the failure and leaves the partial for manual retry. |
| Screen reader running | Progress changes are announced once per state transition, not once per token. |
Limitations of this approach are easy to state. localStorage is limited to roughly five megabytes and is tied to one browser, so it cannot replace server-side session storage for a real product. The free server may restart, and the operator-supplied token allowance can be consumed by the original attempt even when the user never sees the completion. If the model endpoint changes its stream events or refuses partial state, you still need to adapt the parser. This harness deliberately avoids automatic retry; any retry you add should send the retained conversation only after the user approves it, because the partial assistant turn has already consumed tokens.
You should not use this as a production chat system for authenticated, regulated, or multi-user synchronized data, and it will not solve rate-limit or abuse control on the server. It is a browser-side probe for the exact failure that makes a generous free allowance feel unreliable.
If you have access to the free server, host the file and interrupt a response on purpose; the conversation should survive the refresh and the live region should tell you exactly what happened.
Top comments (0)