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.

  • Backend & APIs
  • $server-sent-events
  • 221 lines
  • 3 files
  • ~2,250 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- server-sent-events

Installs to ~/.agents/skills/server-sent-events/ for every project on this machine.

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

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.

~/.agents/skills/server-sent-events/SKILL.md

--- name: server-sent-events description: Implements Server-Sent Events (text/event-stream) endpoints and clients correctly - event framing with id, event, data and retry fields, heartbeats,… license: MIT ---

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

SituationChoice
Server-to-client updates, client actions go over normal HTTPSSE
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 framesWebSocket: use $websockets-realtime
Updates every few minutes, mobile battery mattersPolling 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 with addEventListener("order.updated", ...). Events without it fire onmessage.
  • id: becomes lastEventId; the browser sends it back as the Last-Event-ID header 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

  1. Authenticate before writing headers; return 401/403 as a normal response.
  2. Write headers and flush immediately, plus an initial comment or retry: line so proxies see bytes at once.
  3. If Last-Event-ID is 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.
  4. Subscribe to the source (pub/sub, queue, generator) and write each event, flushing after each one.
  5. Send a heartbeat comment on a timer.
  6. On client disconnect, unsubscribe, clear timers, cancel upstream work (DB cursors, LLM calls). Leaks here are the main SSE production incident.
  7. To end the stream for good, have the client close it; a server-side close just triggers a reconnect. Responding with 204 tells EventSource to 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) and rc.Flush() after each write; watch r.Context().Done() for disconnects. Clear the server WriteTimeout for this route with rc.SetWriteDeadline(time.Time{}).
  • Hono: streamSSE(c, async (stream) => { await stream.writeSSE({ id, event, data }) }) from hono/streaming; use stream.onAbort for cleanup.
  • Django: StreamingHttpResponse with 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(); };
  • EventSource reconnects automatically and sends Last-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(...).body with a parser (for example eventsource-parser) and implement reconnect with backoff and Last-Event-ID yourself.
  • A non-200 status or wrong Content-Type fails the connection permanently; the browser does not retry. Check readyState in onerror.
  • 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 longer proxy-read-timeout on 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

  • \n inside a data: value breaks framing; JSON-encode or split into multiple data: lines.
  • Express res.write returns false under backpressure. For high-volume streams wait for drain or 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 --timeout for 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.yamlgenerated from this page

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.py159 lines, 6.4 KB
#!/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.

esc