Server-Sent Events skill for Codex
Live HTTP streams that no proxy can buffer. Codex writes SSE endpoints that flush every event, send heartbeat comments, replay missed events from Last-Event-ID and release subscriptions when the client disconnects. It knows which hops buffer streams (gzip middleware, nginx, ingress, CDNs) and ships a standard-library probe that parses the stream like a browser and flags bursts, wrong headers and silent connections.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- server-sent-events Installs to ~/.agents/skills/server-sent-events/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install server-sent-events The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project server-sent-events Installs to .agents/skills/server-sent-events/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/server-sent-events.zipunzip -o server-sent-events.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) server-sent-events PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $server-sent-events, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Implements Server-Sent Events (text/event-stream) endpoints and clients correctly - event framing with id, event, data and retry fields, heartbeats, Last-Event-ID resume, disconnect cleanup, and the nginx, CDN and compression settings that silently buffer streams. Covers Node, Express, FastAPI, Django, Go and Hono servers plus EventSource and fetch-based clients, with a probe script that detects buffering. Use when adding live notifications, progress updates or token streaming over HTTP, or when SSE events arrive late, in bursts, or never.
Use it when
- The server pushes notifications, progress or feed updates to browsers
- SSE works locally but events arrive late or in bursts behind a proxy
- Clients must resume a stream after a network drop without missing events
- A stream needs bearer-token auth that EventSource cannot send
Use something else when
- The client sends frequent messages back over the same channel: use websockets-realtime
- The stream is LLM token output rendered in a chat UI: use llm-streaming
How to check the result
Run scripts/sse_probe.py directly against the app and again through the proxy; both show events arriving at their real intervals, heartbeats present, and replay starting after the sent Last-Event-ID.
The mistake it prevents
Leaving gzip compression or nginx proxy buffering on for the stream route, so every event waits in a buffer until it fills or the connection closes.
Done well means: the first event reaches the browser within milliseconds of being
written, nothing between server and client buffers the stream, the server notices a
closed client and frees its resources, and a reconnecting client resumes from
Last-Event-ID without gaps or duplicates.
When SSE fits
| Situation | Choice |
|---|---|
| Server-to-client updates, client actions go over normal HTTP | SSE |
| Need custom headers or a POST body to start the stream (auth tokens, LLM prompts) | SSE format consumed with fetch + a stream parser, not EventSource |
| Frequent client-to-server messages, binary frames | WebSocket: use $websockets-realtime |
| Updates every few minutes, mobile battery matters | Polling with ETag / If-None-Match |
Inspect first
- Read
AGENTS.md, then find the existing stream code:rg -n "text/event-stream|EventSource|StreamingResponse|streamSSE|Flusher" . - List every hop: app server, compression middleware, reverse proxy (nginx, Caddy, Traefik), CDN (Cloudflare, CloudFront), platform (Vercel, Cloud Run, Heroku router). Each one can buffer or time out the stream.
- Check the HTTP version clients use. On HTTP/1.1 browsers allow only ~6 connections per origin, so a few open tabs with SSE exhaust it; HTTP/2 multiplexes streams.
Wire format
retry: 5000
id: 4182
event: order.updated
data: {"orderId":"o_19","status":"shipped"}
: heartbeat
- Each event ends with a blank line (
\n\n). Without it, nothing dispatches. - Multi-line payloads use one
data:line per line; the client joins them with\n. Simplest rule: send single-line JSON. event:sets the type; clients listen withaddEventListener("order.updated", ...). Events without it fireonmessage.id:becomeslastEventId; the browser sends it back as theLast-Event-IDheader on reconnect. Use a monotonic sequence or a stream offset, not a random UUID.retry:sets the reconnect delay in milliseconds.- Lines starting with
:are comments. Send one every 15-30 s as a heartbeat to keep proxies and load balancers from closing an idle stream.
Required response headers
HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
X-Accel-Buffering: no
Do not send Connection: keep-alive on HTTP/2 (it is a forbidden connection-specific
header there). X-Accel-Buffering: no disables nginx buffering for this response only.
Server workflow
- Authenticate before writing headers; return 401/403 as a normal response.
- Write headers and flush immediately, plus an initial comment or
retry:line so proxies see bytes at once. - If
Last-Event-IDis present, replay events after that id from a bounded history (Redis Stream, DB table). If it is too old, send an event telling the client to refetch full state. - Subscribe to the source (pub/sub, queue, generator) and write each event, flushing after each one.
- Send a heartbeat comment on a timer.
- On client disconnect, unsubscribe, clear timers, cancel upstream work (DB cursors, LLM calls). Leaks here are the main SSE production incident.
- To end the stream for good, have the client close it; a server-side close just
triggers a reconnect. Responding with 204 tells
EventSourceto stop reconnecting.
Node / Express
app.get("/events", requireUser, async (req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
});
res.write("retry: 5000\n\n");
const send = (e: { id: number; type: string; data: unknown }) =>
res.write(`id: ${e.id}\nevent: ${e.type}\ndata: ${JSON.stringify(e.data)}\n\n`);
const lastId = Number(req.get("Last-Event-ID") ?? 0);
for (const e of await history.after(req.user.id, lastId)) send(e);
const unsubscribe = bus.subscribe(req.user.id, send);
const hb = setInterval(() => res.write(": hb\n\n"), 20_000);
req.on("close", () => { clearInterval(hb); unsubscribe(); });
});
If the compression middleware runs on this route, either exclude text/event-stream
from it or call res.flush() after each write; otherwise gzip holds events in its buffer.
FastAPI
from fastapi import Request
from fastapi.responses import StreamingResponse
@app.get("/events")
async def events(request: Request, user: CurrentUser):
async def gen():
yield "retry: 5000\n\n"
async for e in bus.subscribe(user.id):
if await request.is_disconnected():
break
yield f"id: {e.id}\nevent: {e.type}\ndata: {e.json}\n\n"
return StreamingResponse(gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
The sse-starlette package adds heartbeats (ping=) and disconnect handling if the
project already uses it. Never call blocking I/O inside the generator.
Other servers
- Go: set headers, then
rc := http.NewResponseController(w)andrc.Flush()after each write; watchr.Context().Done()for disconnects. Clear the serverWriteTimeoutfor this route withrc.SetWriteDeadline(time.Time{}). - Hono:
streamSSE(c, async (stream) => { await stream.writeSSE({ id, event, data }) })fromhono/streaming; usestream.onAbortfor cleanup. - Django:
StreamingHttpResponsewith an async generator under ASGI. Under WSGI each stream pins a worker thread for its whole lifetime; do not ship that at scale.
Client
const es = new EventSource("/events", { withCredentials: true });
es.addEventListener("order.updated", (e) => apply(JSON.parse(e.data)));
es.onerror = () => { if (es.readyState === EventSource.CLOSED) showOffline(); };
EventSourcereconnects automatically and sendsLast-Event-ID; do not add your own reconnect loop on top.- It only does GET and cannot set headers, so auth is cookie-based. For bearer tokens or
POST bodies, read
fetch(...).bodywith a parser (for exampleeventsource-parser) and implement reconnect with backoff andLast-Event-IDyourself. - A non-200 status or wrong
Content-Typefails the connection permanently; the browser does not retry. CheckreadyStateinonerror. - Close the stream when the view unmounts (
es.close()), or tabs keep connections open.
Proxies and platforms
location /events {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
gzip off;
proxy_read_timeout 1h;
}
- Cloudflare closes responses that send no bytes for about 100 s: heartbeats fix it.
- Serverless functions end the stream at their max duration; design clients to resume.
- Kubernetes ingress-nginx: set
nginx.ingress.kubernetes.io/proxy-buffering: "off"and a longerproxy-read-timeouton the ingress.
Verification
Run the bundled probe against a local or staging endpoint (staging needs network access from the sandbox):
python3 scripts/sse_probe.py http://localhost:3000/events --events 5 --timeout 30
python3 scripts/sse_probe.py https://staging.example.com/events \
--header "Cookie: session=..." --last-event-id 4180
It checks status and headers, parses events exactly as a browser would, prints the delay before each event, and flags bursts after a long wait (buffering), compression, missing heartbeats and wrong content types. Exit 0 means healthy, 1 a protocol or header problem, 2 a connection failure or silence until timeout.
Compare a probe straight to the app port against one through the proxy: if only the
proxied run bursts, the proxy is buffering. Also test: kill the client and confirm the
server’s subscriber count drops; reconnect with --last-event-id and confirm replay
starts at the next id.
Gotchas
\ninside adata:value breaks framing; JSON-encode or split into multipledata:lines.- Express
res.writereturnsfalseunder backpressure. For high-volume streams wait fordrainor drop/coalesce events for slow clients. - Each open stream holds a socket and a subscription. Count them in metrics and cap them per user.
- Response timeouts on the app server (Node
server.requestTimeout, gunicorn--timeoutfor sync workers) kill long streams; exempt or raise them for SSE routes. - Corporate antivirus proxies sometimes buffer whole responses; heartbeats every 15 s and HTTPS make this rarer but not impossible. Polling fallback is the escape hatch.
Output template
## SSE endpoint: <path>
- Auth: <cookie | fetch + bearer>
- Event types: <list with payload shape>
- Ids and resume: <sequence source, history retention>
- Heartbeat: every <N> s
- Disconnect cleanup: <what is released>
- Proxy/CDN changes: <nginx/ingress/CDN settings>
- Probe result: direct <ok>, via proxy <ok> What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Server-Sent Events"
short_description: "Live HTTP streams that no proxy can buffer"
default_prompt: "$server-sent-events Add a server-sent events endpoint for order status updates with resume support" scripts/sse_probe.py
#!/usr/bin/env python3
"""Probe a Server-Sent Events endpoint and report what a browser EventSource would see.
Checks status, Content-Type, caching and compression headers, then parses events
per the HTML spec (event/data/id/retry fields, comment lines, blank-line dispatch)
and measures time to headers, time to first event and the gaps between events.
Large gaps followed by bursts usually mean a proxy or compression layer is buffering.
Usage:
python3 sse_probe.py URL [--events N] [--timeout SECONDS]
[--header 'Name: value'] [--last-event-id ID]
Exit codes: 0 ok, 1 protocol or header problem, 2 connection error or timeout.
Standard library only (Python 3.10+).
"""
from __future__ import annotations
import argparse
import socket
import sys
import time
import urllib.error
import urllib.request
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Probe an SSE endpoint.")
p.add_argument("url")
p.add_argument("--events", type=int, default=5, help="stop after N events (default 5)")
p.add_argument("--timeout", type=float, default=30.0,
help="seconds to wait for each read (default 30)")
p.add_argument("--header", action="append", default=[],
help="extra request header, repeatable: 'Authorization: Bearer x'")
p.add_argument("--last-event-id", help="send Last-Event-ID to test resume")
return p.parse_args()
def main() -> int:
a = parse_args()
headers = {"Accept": "text/event-stream", "Cache-Control": "no-cache"}
for h in a.header:
name, _, value = h.partition(":")
if not value:
print(f"bad --header {h!r}, expected 'Name: value'", file=sys.stderr)
return 2
headers[name.strip()] = value.strip()
if a.last_event_id is not None:
headers["Last-Event-ID"] = a.last_event_id
req = urllib.request.Request(a.url, headers=headers)
problems: list[str] = []
t0 = time.monotonic()
try:
resp = urllib.request.urlopen(req, timeout=a.timeout)
except urllib.error.HTTPError as e:
note = " (204 tells EventSource to stop reconnecting)" if e.code == 204 else ""
print(f"FAIL status {e.code}{note}; EventSource only accepts 200")
return 1
except (urllib.error.URLError, socket.timeout, OSError) as e:
print(f"FAIL connection: {e}")
return 2
t_headers = time.monotonic() - t0
print(f"status {resp.status}")
print(f"headers in {t_headers * 1000:.0f} ms")
ctype = resp.headers.get("Content-Type", "")
print(f"content-type {ctype or '(missing)'}")
if resp.status != 200:
problems.append(f"status {resp.status}, expected 200")
if not ctype.lower().startswith("text/event-stream"):
print("FAIL Content-Type is not text/event-stream; EventSource fails the connection "
"and does not reconnect")
resp.close()
return 1
cache = resp.headers.get("Cache-Control", "")
print(f"cache-control {cache or '(missing)'}")
if "no-cache" not in cache and "no-store" not in cache:
problems.append("Cache-Control lacks no-cache; intermediaries may buffer or cache the stream")
enc = resp.headers.get("Content-Encoding")
if enc:
problems.append(f"Content-Encoding {enc}: compression often buffers events unless flushed per event")
for name in ("Transfer-Encoding", "X-Accel-Buffering", "Connection"):
if resp.headers.get(name):
print(f"{name.lower():<13} {resp.headers.get(name)}")
events: list[dict] = []
cur: dict = {"event": "message", "data": [], "id": None}
last_t = time.monotonic()
comments = 0
timed_out = False
try:
while len(events) < a.events:
raw = resp.readline()
if not raw:
problems.append("server closed the stream; EventSource will reconnect after retry delay")
break
line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
if line == "":
if cur["data"]:
now = time.monotonic()
cur["gap_ms"] = (now - last_t) * 1000
cur["at_ms"] = (now - t0) * 1000
last_t = now
events.append(cur)
data = "\n".join(cur["data"])
shown = data if len(data) <= 80 else data[:77] + "..."
print(f"event #{len(events):<3} +{cur['gap_ms']:7.0f} ms "
f"type={cur['event']} id={cur['id']} data={shown!r}")
cur = {"event": "message", "data": [], "id": cur["id"]}
continue
if line.startswith(":"):
comments += 1
continue
field, sep, value = line.partition(":")
if sep and value.startswith(" "):
value = value[1:]
if field == "data":
cur["data"].append(value)
elif field == "event":
cur["event"] = value or "message"
elif field == "id":
if "\0" not in value:
cur["id"] = value
elif field == "retry":
print(f"retry {value} ms" if value.isdigit() else f"retry ignored: {value!r}")
else:
msg = f"unknown field {field!r} (ignored by browsers)"
if msg not in problems:
problems.append(msg)
except (socket.timeout, TimeoutError):
timed_out = True
problems.append(f"no data for {a.timeout:.0f} s; add a ':' heartbeat comment every 15-30 s "
"or check proxy buffering")
finally:
resp.close()
print(f"comments {comments} heartbeat/comment lines")
if events:
print(f"first event {events[0]['at_ms']:.0f} ms after request")
gaps = [e["gap_ms"] for e in events[1:]]
if len(gaps) >= 3:
fast = sum(1 for g in gaps if g < 5)
if fast >= len(gaps) - 1 and events[0]["at_ms"] > 2000:
problems.append("events arrived in one burst after a long wait: likely buffering "
"(proxy_buffering, compression, or missing flush)")
else:
problems.append("no events received")
if problems:
print("\nPROBLEMS")
for p in problems:
print(f"- {p}")
return 2 if timed_out and not events else 1
print("\nOK")
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.