Live events
Triform pushes live events as things happen — element created, run progressed, run completed, state changed. The UI updates in real time, and your own code can subscribe to the same stream.
This is the real-time, watching lens. A run is the execution itself — its inputs, status, output, and cost. Live events are the notifications that execution emits as it moves. The two are separate concerns: you read a run to see what happened; you subscribe to live events to watch it happen.
The real-time model
Every operation a circle runs emits a lifecycle stream without anyone writing
emit code. The contract is fixed: one Started before the work dispatches, exactly
one terminal at the end, and zero or more Progress events in between. Each
kind carries a different payload because each answers a different question:
| Kind | Carries | Answers |
|---|---|---|
started | input | what was asked, and by whom |
progress | progress | how far along (op-specific JSON) |
completed | output | the result |
failed | error | why it failed |
cancelled | — | that it was stopped |
Every event also carries an invocation_id (stable across the whole lifecycle of a
single operation, so progress and the terminal correlate back to their start), an
actor describing who triggered it (user / agent / system / external), and
a trace_id that matches the operation’s span in
observability.
The live stream and the trace history are two views of one event.
The terminal is the load-bearing one — unique per invocation, so a watcher always
knows exactly when an operation is done and how it ended. It is emitted after the
underlying state commits, never before, so a watcher never acts on a result a
concurrent reader cannot yet see. Progress, by contrast, is best-effort: human-
meaningful checkpoints, not a per-iteration firehose, and a dropped one never
changes the outcome the terminal reports.
How the UI stays coherent
When the portal receives a Completed event it does one of two things, decided per
operation by generated policy — never by hand-matching on element type:
- Apply — for operations whose output is the element’s full new state, the portal writes that output straight into its cache. No refetch, no flicker.
- Invalidate — otherwise, the portal drops its cached copy and lets the next
reader refetch. This is the safe default, and
Failed/Cancelledalways invalidate.
The same discipline that protects the cache is what you honor when authoring an operation: if you declare that an operation’s output replaces the element, that output must be the element’s complete shape, never a partial or a child object — otherwise the cache silently corrupts. When in doubt, let the portal invalidate.
Subscribing
The authenticated stream is one Server-Sent Events endpoint, scoped to a single circle:
GET /api/events/{circle}?subjects=<pattern,pattern>
Accept: text/event-stream
Subjects on the wire are {element_type}.{op_name}.{kind} — for example
automation.execute.completed or linkedin.ingest-search-url.progress. Patterns
are NATS-style: * matches one segment, > matches any tail, and commas separate
multiple patterns. Subscribe to exactly the slice you care about:
subjects=automation.*.completed every successful automation operation
subjects=*.update.completed every successful update across the circle
subjects=sales-board.> every event on sales-board
Subscribing requires an authenticated caller with access to that circle (or platform-admin) — the stream is filtered to the circle’s events and never leaks another circle’s activity. The reference consumer is the portal itself, which drives every live update in the workbench from this one endpoint.
The public token route
For sharing a live view outside the circle — a branded public face, an embedded widget — there is a separate, narrower door:
GET /api/public/events/{circle}?t=<share-token>
This stream is gated by a share token rather than a session, minted per element
for a specific embed purpose (POST /api/{circle}/_share/mint). The public stream
is default-deny: events are filtered before they leave the server, so a public
viewer sees only what the token authorizes and only the fields the public face is
allowed to expose. Where the authenticated stream trusts the caller and streams the
full event, the token stream trusts nothing and strips the payload down first — and
when a payload has been stripped that way, the portal’s reconciler treats it as
invalidate-only rather than applying a partial state. Same event vocabulary, far
tighter blast radius.
Related
- Concept: runs — what the events are about
- Reference:
/docs/api— the event payload schemas - Element:
websocket,platform-trigger