Smart Agent Teams

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-stream

The 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 : heartbeat to keep proxies from closing the connection. Ignore lines starting with :.
  • There are no event: or id: fields. The server does not replay missed events and ignores Last-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.

TypeExtra fieldsPublished 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.createdagent_idA 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.updatedagent_id, statusA 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:

EntityWeb app refetches
taskTasks, the task, comments, dashboard, goals, projects, agents
agentAgents, the agent, org chart, dashboard, costs
runRuns, the run, dashboard, agents, costs, projects
approvalApprovals, dashboard, agents
goalGoals
projectProjects
routineRoutines
companyThe 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:

  1. Reconnects with backoff. subscribeToCompanyEvents waits 2, 4, 8 and 16 seconds, then 30 seconds between attempts, and resets after a successful connection.
  2. Refetches everything it displays after reconnecting, because events published while it was disconnected are gone.
  3. Falls back to polling for anything critical. sat runner polls 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_ORIGIN at build time.
  • The CLI uses the profile's events URL. The prod profile sets it; for other profiles use sat profile set <name> --events-url <cloud-run-origin> or the SAT_EVENTS_URL environment variable.
  • Browsers need the page's origin in the API's ALLOWED_ORIGINS to 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:

VariableValue
EVENTS_BACKENDredis (default memory)
REDIS_URLDefault 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

watch-runs.ts
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.

On this page