Agent Wranglers

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

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.