Repository navigation
Frontend SSE and Realtime
Canonical source:
docs/05-frontend/sse-and-realtime.md— this page is rendered from it on every publish; edit the source.
Server-Sent Events, polling patterns, TanStack Query refetchInterval, and real-time UI.
ClawAI uses two patterns for real-time data:
| Pattern | Use Case | Mechanism |
|---|---|---|
| SSE (Server-Sent Events) | Streaming AI responses, fallback attempts, image/file generation progress | fetch() + ReadableStream |
| Polling | Waiting for completed AI messages, general data freshness | TanStack Query refetchInterval |
The SSE utility is at src/utilities/sse.utility.ts. It uses fetch() instead of the native EventSource API because EventSource cannot set custom headers (needed for JWT authentication).
// src/utilities/sse.utility.ts
export function connectSse(url: string, callbacks: SseCallbacks): SseConnection {
const controller = new AbortController();
void readSseStream(url, controller.signal, callbacks);
return { close: () => controller.abort() };
}
async function readSseStream(
url: string,
signal: AbortSignal,
callbacks: SseCallbacks,
): Promise<void> {
const token = getAccessToken();
const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal,
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('data:')) {
const data = trimmed.slice(5).trim();
if (data.length > 0) {
callbacks.onMessage(data);
}
}
}
}
}- No EventSource API -- cannot set Authorization headers
- No JWT in URL query params -- tokens would leak in server logs and browser history
- AbortController -- allows clean connection teardown on component unmount
- Buffer handling -- SSE messages may arrive across chunk boundaries
- Automatic reconnection -- not implemented; the polling fallback handles recovery
The useChatStream hook manages the SSE connection for a chat thread:
// src/hooks/chat/use-chat-stream.ts
export function useChatStream(threadId: string, isActive: boolean) {
const [fallbackAttempts, setFallbackAttempts] = useState<FallbackAttemptInfo[]>([]);
const [streamError, setStreamError] = useState<string | null>(null);
const connectionRef = useRef<SseConnection | null>(null);
useEffect(() => {
if (!isActive || !threadId) return;
const url = `${API_BASE_URL}/chat-messages/stream/${threadId}`;
const connection = connectSse(url, {
onMessage: (data: string) => {
const parsed = JSON.parse(data) as StreamEvent;
if (parsed.type === StreamEventType.FALLBACK_ATTEMPT) {
setFallbackAttempts((prev) => [...prev, attempt]);
}
if (parsed.type === StreamEventType.ERROR) {
setStreamError(parsed.error ?? 'All providers failed');
}
},
onError: () => {
// Falls back to polling automatically
},
});
connectionRef.current = connection;
return () => connection.close();
}, [threadId, isActive]);
return { fallbackAttempts, streamError, resetStream };
}enum StreamEventType {
FALLBACK_ATTEMPT = 'fallback_attempt', // Provider failed, trying next
ERROR = 'error', // All providers failed
COMPLETION = 'completion', // Message completed
}The useImageGenerationListener hook connects to the image generation SSE endpoint:
SSE URL: ${API_BASE_URL}/images/${imageId}/progress
It receives progress events during image generation and updates the UI in real-time.
The useFileGenerationListener hook connects to the file generation SSE endpoint:
SSE URL: ${API_BASE_URL}/file-generations/${generationId}/progress
The global query config polls every 10 seconds:
defaultOptions: {
queries: {
refetchInterval: 10 * 1000, // 10 second polling for all queries
},
},When waiting for an AI response, increase polling frequency:
useQuery({
queryKey: queryKeys.threads.messages(threadId),
queryFn: () => chatRepository.listMessages(threadId),
refetchInterval: isWaitingForResponse ? 1000 : 10000, // 1s when waiting, 10s otherwise
});Polling has a maximum duration to prevent infinite loops:
- Max polls: 90 iterations at 1-second interval (3 minutes total)
-
Condition check: Polling stops when
lastMessage.role === ASSISTANT - Error messages: If all providers fail, an error ASSISTANT message is stored in the DB, which satisfies the polling stop condition
The frontend uses both SSE and polling simultaneously:
- SSE (fast path): Receives events in real-time (fallback attempts, errors, completion)
- Polling (reliable path): Checks for new messages every 1 second while waiting
This dual approach ensures:
- Users see fallback attempts in real-time (via SSE)
- Messages are never missed (polling always works even if SSE fails)
- The "AI is thinking..." indicator stops correctly
Shown while waiting for an AI response:
function ThinkingIndicator({
fallbackAttempts,
streamError,
}: ThinkingIndicatorProps): ReactElement {
return (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{streamError ? (
<span className="text-destructive">{streamError}</span>
) : fallbackAttempts.length > 0 ? (
<FallbackAttemptDisplay attempts={fallbackAttempts} />
) : (
<span>{t('chat.thinking')}</span>
)}
</div>
);
}- Thinking -- default animated spinner with "AI is thinking..."
- Fallback attempt -- shows which provider failed and what is being tried next
- Error -- shows error message when all providers failed
After a message is completed, routing decision details are available:
function RoutingTransparency({ decision }: RoutingTransparencyProps): ReactElement {
return (
<Collapsible>
<CollapsibleTrigger>
<Badge>
{decision.selectedProvider} / {decision.selectedModel}
</Badge>
<span>Confidence: {decision.confidence}%</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div>Privacy: {decision.privacyClass}</div>
<div>Cost: {decision.costClass}</div>
<div>Reasons: {decision.reasonTags.join(', ')}</div>
</CollapsibleContent>
</Collapsible>
);
}For SSE to work correctly through the reverse proxy, Nginx requires special configuration:
location /api/v1/chat-messages/stream {
proxy_http_version 1.1; # Required for persistent connections
proxy_set_header Connection ""; # Remove Connection: close
proxy_read_timeout 86400; # 24-hour timeout
proxy_buffering off; # Send events immediately
proxy_cache off; # No caching
}The same configuration applies to image generation SSE (/api/v1/images/) and file generation SSE (/api/v1/file-generations/).
-
Never use EventSource API -- it cannot set Authorization headers
-
Never pass JWT in URL query params -- leaks in logs, history, Referer headers
-
Always store error messages as ASSISTANT records -- without this, polling for completed messages runs forever
-
Always handle both SSE error events AND polling -- SSE may fail silently
-
Polling must have a max limit (3 minutes) -- never allow infinite polling
-
Frontend handles dual paths: SSE error (fast) + polling finding error message (fallback)
-
SSE connections are not refreshable -- if the token expires during an SSE connection, the connection must be closed and re-established after token refresh
-
A run this page just started must NOT ask for replay (2026-09-10). The server's replay buffer holds the previous run's terminal
DONEuntil the backend clears it, which happens when the new run is accepted — but the client opens the stream before the POST has left the browser. A replaying connection therefore receives the oldDONE, concludes the run is finished, drops the waiting flag, and thereby aborts the connection it just opened; the recovery effect then re-arms and the cycle repeats. Every turn is one request that returns200and dies withnet::ERR_ABORTED.useChatStream(threadId, isActive, replayPastEvents)— passfalsefor an explicit send (it has missed nothing) andtrueonly when recovering a run that was already in flight when the page loaded. That is what replay is for. -
A clean close is not a completion (2026-09-10). The legacy chat stream observable never completes on
DONE; only the client closes it. So the things that close it cleanly server-side are an ownership rejection and a service restart — and a restart is precisely when reconnecting matters. Treating any clean close as terminal meant one transient blip permanently downgraded the thread to REST polling for the life of the page, with no error and nothing user-visible.connectSsenow asks the consumer viashouldReconnectAfterClose, because only the consumer knows whether it actually saw a terminal event. The default is to reconnect. -
The reported CORS errors on this route were not CORS (2026-09-10). CORS is configured in exactly one place (
claw-chat-service/src/main.ts), nginx adds noAccess-Controlheader anywhere, and the standard deployment is same-origin so CORS does not apply. A "blocked by CORS policy" logged against a request DevTools also shows as200plusnet::ERR_ABORTEDis Chrome mislabelling a connection cut while its headers were being consumed. Fix the aborts, not the headers. There is a real CORS trap, but only when browsing the dev frontend on plain-HTTPlocalhost:3000, an origin absent fromCORS_ORIGINS.
ClawAI Wiki · source-controlled under wiki/ · generated/mirrored from the current repository knowledge layer.
ClawAI Wiki
Product
- Product-Vision
- Flagship-Features
- Business-Overview
- Requirements-Register
- Drift-Log
- Runtime-Managed-Connector-Providers
- Threads
- ADR-Index
Architecture
- System-at-a-Glance
- Architecture-Map
- System-Architecture
- Message-Flow
- End-to-End-Data-Flow
- Routing-Engine
- Event-Bus
- Security-Architecture
Platform
- Backend-Services
- Frontend-Architecture
- Shared-Packages
- Database-Reference
- API-Reference
- Provider-Catalog
- Memory-and-Context-Architecture
- File-Support-Architecture
- Research-and-Web-Crawling
Runtime & Ops
- Docker-Guide
- Nginx-Reference
- CI-CD-Pipeline
- Logging-and-Observability
- Operational-Runbooks
- Troubleshooting
Quality
Engineering OS
- AI-Native-Engineering-OS
- Generated-Manifests
- Rules-Catalog
- Skills-Catalog
- Context-Layer
- Engineering-Memory
- Tooling-and-Automation
- Scripts-and-Operations
- ESLint-and-Architecture-Enforcement
- Extended-Work-Skills
Agents
Reference