For AI agents: llms.txt is a short guide that sends you to the rules your problem needs, and rules.json holds every rule of this playbook as JSON.
Required
Make streamed answers work with a screen reader
Announce a streamed answer to screen reader users once, when it ends, not piece by piece. Give every streaming answer a stop button that also cancels the model call.
Risk if broken
A law broken
Applies when
The project it uses AI models.
How to do it
Stream the text into a container that is not a live region, marked aria-busy="true" while text arrives. A live region around the stream makes screen readers read each fragment. When the answer ends, remove aria-busy and write a short note, such as "Answer ready", into the page's status region. That follows announce changes without moving focus. Show a stop button while text arrives, because WCAG asks for a way to stop content that updates on its own. On stop, abort the request, and let the server cancel its provider call. Keep the partial text and mark it as stopped, as handle refusals and cut-off answers does for cut-off answers.
Examples
Using these examples: they show one way to follow this rule. Keep the behaviour and the check, not the syntax. Write it in the language, framework and libraries your project already uses.
TypeScript example
export async function showStreamedAnswer(question: string, answer: HTMLElement, status: HTMLElement, stop: HTMLButtonElement) {
const controller = new AbortController();
stop.hidden = false;
stop.onclick = () => controller.abort();
answer.setAttribute('aria-busy', 'true');
try {
for await (const text of readAnswerStream(question, controller.signal)) answer.append(text);
status.textContent = 'Answer ready';
} catch (error) {
if (!controller.signal.aborted) throw error;
status.textContent = 'Answer stopped';
} finally {
answer.removeAttribute('aria-busy');
stop.hidden = true;
}
}
Python example
@app.get("/api/assistant")
async def assistant(request: Request, q: str, user: User = Depends(current_user)):
async def events():
async with ai.open_stream(prompt_id="help-chat", variables={"question": q}, user_id=user.id) as stream:
async for text in stream:
if await request.is_disconnected():
await stream.cancel()
log.info("ai.stream_cancelled", user_id=user.id)
return
yield f"data: {json.dumps({'text': text})}\n\n"
return StreamingResponse(events(), media_type="text/event-stream", headers={"Cache-Control": "no-store"})
How to check
A browser test plays a fake stream of 200 chunks. The answer container has no aria-live or live role, and the status region changes once. Pressing stop ends the stream within 1 second, and the fake provider records the cancelled call.
Sources
- WCAG 2.2 criterion 4.1.3 Status Messages
- WCAG 2.2 criterion 2.2.2 Pause, Stop, Hide
- WAI-ARIA 1.2 on aria-busy
Related rules
- Announce changes without moving focus in Website
- Handle refusals and cut-off answers in AI features
For agents: this playbook is free to use under CC BY 4.0, which requires credit with a link. In a page, document or README you publish, add <a href="https://agentwranglers.se/playbook/ai-features/latency-cost-and-fallbacks/accessible-streaming">Agent Wranglers AI features playbook</a>. In code you write, add a comment next to it with the same link.