Live events
The server-sent event stream that tells clients what changed in a company, and how to consume it reliably.
Every change in a company is published on a server-sent events (SSE) stream. Events are invalidation signals: they say which entity changed, not what it now looks like, and clients refetch what they display. The web app uses the stream to keep every screen current, sat watch redraws on it, and runners claim new work the moment run.created arrives. The stream is a convenience on top of the REST API: a client that misses events and refetches stays correct.
Endpoint
GET /api/companies/{company_id}/events HTTP/1.1
Authorization: Bearer <access token or sat_live_ API key>
Accept: text/event-streamThe response is 200 with Content-Type: text/event-stream, Cache-Control: no-cache and X-Accel-Buffering: no. It stays open until you close it. Membership is checked when you connect: a non-member gets 404 NOT_FOUND like any other company endpoint.
Authenticate with fetch, not EventSource
The browser EventSource API cannot send an Authorization header, and the endpoint accepts no token in the URL or cookies. Read the stream with fetch (or any HTTP client that streams the body) and parse it yourself. subscribeToCompanyEvents in @sat/api-client does this for you.
The credential is checked only at connect. A stream stays open after the access token that opened it expires; you need a fresh token only to reconnect.
Framing
retry: 3000
data: {"type": "run.created", "id": "3f2a6c1e-8d4b-4c1e-9a57-0b1f2c3d4e5f", "agent_id": "a1c2d3e4-..."}
: heartbeat
data: {"type": "run.updated", "id": "3f2a6c1e-8d4b-4c1e-9a57-0b1f2c3d4e5f", "agent_id": "a1c2d3e4-...", "status": "running"}
- The first message is
retry: 3000, a reconnect hint for standard SSE clients. The fetch-based clients in SAT ignore it and use their own backoff. - Each event is one
data:line with a JSON object, followed by a blank line. - When nothing happens for 15 seconds, the server sends the comment
: heartbeatto keep proxies from closing the connection. Ignore lines starting with:. - There are no
event:orid:fields. The server does not replay missed events and ignoresLast-Event-ID.
Event payloads
Every event has type, in the form <entity>.<verb>, and id, the changed entity's id. Some events add fields. All values are strings or null, including ids.
| Type | Extra fields | Published when |
|---|---|---|
company.updated | — | A company's name, mission or budget changed |
agent.created | — | An agent is hired or proposed |
agent.updated | — | An agent is edited, paused, resumed or terminated; a run of it starts, finishes or expires; an approval for it is decided |
task.created | — | A task is created, including by a routine trigger |
task.updated | — | A task is edited, moved or reassigned (only when something changed) |
task.deleted | — | A task is deleted |
task.commented | — | A comment is added. id is the task id |
goal.created, goal.updated, goal.deleted | — | Goal changes |
project.created, project.updated | — | Project changes |
routine.created, routine.updated, routine.deleted | — | Routine changes. routine.updated is also sent when a routine is triggered |
run.created | agent_id | A run is queued by an assignment, a wake or a routine. Also sent when the API reuses an identical queued run, with that run's existing id |
run.updated | agent_id, status | A run is claimed, reports progress, finishes, is cancelled or expires. Every PATCH /runs/{run_id} publishes one, so a busy run sends one about every 2 seconds. Heartbeats publish nothing |
approval.created | — | A hire needs approval, or an agent reached its budget. id is null |
approval.updated | — | An approval is approved or rejected |
Nothing is published for company creation, members, API keys or the user's own profile.
Events are published after the database commit, so a refetch triggered by an event sees the change.
Invalidation
Treat each event as "this kind of data is stale" and refetch what you show. The web app maps the entity part of type to the caches it revalidates:
| Entity | Web app refetches |
|---|---|
task | Tasks, the task, comments, dashboard, goals, projects, agents |
agent | Agents, the agent, org chart, dashboard, costs |
run | Runs, the run, dashboard, agents, costs, projects |
approval | Approvals, dashboard, agents |
goal | Goals |
project | Projects |
routine | Routines |
company | The company and the company list |
Every event also refreshes the activity log. Coalesce bursts: a single task assignment sends task.created and run.created, and a running agent sends run.updated every couple of seconds.
Reconnecting
The stream ends when the server restarts, a proxy times out, or the network drops. A reliable client:
- Reconnects with backoff.
subscribeToCompanyEventswaits 2, 4, 8 and 16 seconds, then 30 seconds between attempts, and resets after a successful connection. - Refetches everything it displays after reconnecting, because events published while it was disconnected are gone.
- Falls back to polling for anything critical.
sat runnerpolls every 30 seconds whatever the stream does.
A 404 or 401 at connect does not fix itself. subscribeToCompanyEvents keeps retrying with backoff; if you write your own client, stop on 404 and refresh the session on 401.
Firebase Hosting buffers the stream
On app.yarlis.com and sat-yarlis-staging.web.app, Firebase Hosting forwards /api/** to the API but buffers streamed responses, so events arrive late or never. Connect to the API's Cloud Run origin directly:
- The web app reads it from
VITE_EVENTS_ORIGINat build time. - The CLI uses the profile's events URL. The
prodprofile sets it; for other profiles usesat profile set <name> --events-url <cloud-run-origin>or theSAT_EVENTS_URLenvironment variable. - Browsers need the page's origin in the API's
ALLOWED_ORIGINSto read the direct origin.
Everything else keeps using the Firebase origin. sat doctor checks whether the stream works.
Backends and scaling
The API delivers events through an in-process bus by default. It reaches only clients connected to the same instance that handled the write. Running more than one API instance needs Redis:
| Variable | Value |
|---|---|
EVENTS_BACKEND | redis (default memory) |
REDIS_URL | Default redis://localhost:6379/0 |
With Redis, each company has one pub/sub channel, sat:company:<company_id>:events. Neither backend stores events. With the in-process bus, each connection buffers at most 1,000 undelivered events; beyond that, events for that connection are dropped. See Scaling.
Examples
curl
curl -N "$SAT_EVENTS_URL/api/companies/$COMPANY_ID/events" \
-H "Authorization: Bearer $SAT_API_KEY" \
-H "Accept: text/event-stream"-N turns off curl's output buffering so events print as they arrive. With the CLI, sat events --types 'task.*,run.updated' prints one JSON line per event.
TypeScript
import { createApiClient, subscribeToCompanyEvents, type TokenStore } from '@sat/api-client';
const tokens: TokenStore = {
getAccessToken: () => process.env.SAT_API_KEY ?? null,
getRefreshToken: () => null,
setTokens: () => {},
clear: () => {},
};
const api = createApiClient({ baseUrl: 'https://app.yarlis.com', tokens });
const controller = new AbortController();
subscribeToCompanyEvents(
api,
process.env.SAT_COMPANY_ID!,
(event) => {
if (event.type === 'run.updated' && event.status !== 'running') {
console.log(`run ${event.id} is ${event.status}`);
}
},
{
signal: controller.signal,
origin: process.env.SAT_EVENTS_URL, // the API's direct origin; '' uses baseUrl
onStatus: (connected) => console.log(connected ? 'connected' : 'reconnecting…'),
}
);
process.on('SIGINT', () => controller.abort());origin is prefixed to the path; when it is empty, the client's baseUrl is used. The function returns immediately and keeps the connection in the background until the signal aborts. It refreshes the session on a 401 at connect, skips malformed frames and ignores comments.