A free token allowance is a limit, not a progress bar. The failure you want to catch early in a browser-based AI tool is not the first request, but the moment a long-running loop has spent the whole pool while the screen still says “streaming.” The fix is to put a small boundary guard in front of fetch and make that boundary visible to a keyboard or screen-reader user at the three transitions that matter: before a send is allowed, after usage is recorded, and after a rate limit resets.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. The availability details used below—free model access, a free server route, and an advertised 30 million token allowance—are operator-supplied and worth rechecking against the current project page before you build anything durable on top of them. The technique itself is also transferable to any quota-based endpoint.
The painful version of this failure starts when you paste a long prompt into a small test harness and press Enter. The response streams, but the usage object is hidden inside JSON that the UI never reads. After a few runs, the server starts returning 429, the free server route stops being generous, and the person using the tool has no idea whether the budget is exhausted or the request simply failed. That ambiguity is worse when the user cannot see a small token counter buried under the transcript. A screen reader will announce “send” and then hear silence, or it will announce every incremental stream update but never the boundary that already closed.
A guard belongs around the request path rather than inside the chat bubble. I model only four phases: idle, running, blocked, and retry_after. The guard does not predict the exact vendor tokenizer. It estimates the pending spend before the request leaves the page, then corrects that estimate with the actual usage.total_tokens returned by the model. The boundary is therefore a preflight decision, not a post-hoc log line. For a keyboard user, focus remains on the prompt input when a send is rejected. For a screen-reader user, the status region announces the blocked condition assertively.
function makeBudgetGuard({ limit, live }) {
let spent = 0;
let pending = 0;
function say(message, priority = 'polite') {
live.textContent = '';
window.setTimeout(() => {
live.textContent = message;
live.setAttribute('aria-live', priority);
}, 50);
}
function estimate(prompt) {
if (!prompt) return 0;
return Math.max(1, Math.ceil(prompt.length / 4));
}
function canSend(prompt) {
pending = estimate(prompt);
const projected = spent + pending;
if (projected > limit) {
say(`Send blocked. Estimated ${pending} tokens would take the total to ${projected}, above the ${limit} token boundary. Reduce the prompt and try again.`, 'assertive');
return false;
}
say(`Preparing to send. ${pending} tokens estimated; ${limit - projected} remain.`, 'polite');
return true;
}
function record(usage = {}) {
const measured = usage.total_tokens ?? usage.completion_tokens ?? 0;
if (measured <= 0) return;
spent += measured;
say(`Recorded ${measured} tokens. New total is ${spent}; ${Math.max(0, limit - spent)} remain.`, 'polite');
}
function retryAfter(seconds) {
say(`Rate limited. Next send should wait ${seconds} seconds.`, 'assertive');
window.setTimeout(() => say('The guarded send is clear to try again.', 'polite'), seconds * 1000);
}
return { canSend, record, retryAfter, spent: () => spent, pending: () => pending };
}
The estimate here is deliberately crude: roughly one token for every four characters. That is not tokenizer truth, and you should say so in the interface rather than presenting it as precise accounting. After the response arrives, the reported usage replaces the estimate. The important property is that the guard can block a send before it happens, not that it guesses perfectly. If you want greater accuracy, you can replace the character heuristic with a local tokenizer or simply run fewer preflight checks and rely on the first response to inform the next decision.
Pair the guard with a status region that is already in the DOM before the first announcement. The region should not be created lazily on the first error because some screen readers do not reliably announce content inside a newly inserted live region. I keep it as a role='status' element paired with an input and button. Polite progress updates stream through it, while blocked and rate-limited transitions switch to assertive so they interrupt the current reading.
<input id='prompt' type='text' placeholder='Paste a prompt long enough to move the budget' />
<button id='send' type='button'>Send</button>
<div id='budget-status' role='status' aria-live='polite'></div>
const status = document.getElementById('budget-status');
const prompt = document.getElementById('prompt');
const sendButton = document.getElementById('send');
const guard = makeBudgetGuard({ limit: 30_000_000, live: status });
const controller = new AbortController();
sendButton.addEventListener('click', async () => {
const text = prompt.value.trim();
if (!guard.canSend(text)) return;
sendButton.disabled = true;
try {
const response = await fetch('/agent/complete', {
method: 'POST',
signal: controller.signal,
body: JSON.stringify({ prompt: text })
});
if (response.status === 429) {
const retryAfterSeconds = Number(response.headers.get('retry-after') || 8);
guard.retryAfter(retryAfterSeconds);
return;
}
const data = await response.json();
guard.record(data.usage);
} catch (error) {
if (error.name === 'AbortError') {
status.textContent = 'Send cancelled.';
return;
}
status.textContent = 'The request failed before usage could be recorded. The token total has not changed.';
} finally {
sendButton.disabled = false;
}
});
I use the free server route as a place to host the same boundary as a small read model rather than keeping the total only inside a single browser tab. That matters because a browser reload can lose the accumulated spent value unless you persist it. A tiny server endpoint that stores the current total and returns it after each request gives every client the same boundary. It does not make the server data durable by itself: if the free server restarts or the process is recycled, the in-memory total can reset, so treat persistence as a separate requirement before you claim any conformance.
app.get('/budget', (req, res) => {
res.json({ spent, limit, remaining: limit - spent, pending });
});
The QA pass should test the transitions that actually fail, not just the happy path. I run three probes. First, with a keyboard, tab to the input and send a prompt that is too long; focus must remain on the input, the send button must not activate, and the status region must announce the blocked reason without requiring a mouse hover. Second, with the screen reader running, allow a short prompt and confirm that the polite progress message does not read over the model stream every token. Third, force a 429 and confirm that the assertive retry message announces the wait once, while repeated Enter presses do not create a pile of overlapping announcements. I test that combination in at least two browsers because aria-live can behave differently with rapid DOM replacement.
This approach is useful when a token pool can be burned by loops, when the interface has no visible spend meter, or when the person operating it cannot infer rate-limit state from a raw network tab. It is less useful for one-off prompts, for systems that already have a trusted vendor usage endpoint, and for any budget where the cost must be exact before spending. In that stricter case, the client-side estimate is a nice UX signal but it cannot be the source of truth. Keep the boundary in the UI, but let the authoritative accounting stay with the service that reports actual token counts after each response.
The guard is deliberately small enough to rehearse against any quota-based endpoint. If you already have MonkeyCode's free model access and free server route, that combination is a cheap sandbox for exercising the failure path before you wire it into an interface that spends real budget. Add the guard early, announce the boundary, and do not let a generous free allocation turn into a silent loop that stops without saying why.
Top comments (0)