Progress streaming (SSE)
A workflow streams live progress over Server-Sent Events.
streamEvents(workflowId) opens the stream and yields typed events you iterate
as an async iterable. Reach for it when you want sub-second feedback — driving a
progress bar, or watching a long-running video operation. When 2-second polling
granularity is fine, run() (which polls under the hood) is the simpler path.
Consuming the stream
- TypeScript
- PHP
- Python
- Rust
for await (const event of await client.streamEvents(workflowId)) {
switch (event.event) {
case 'operation.progress':
console.log(`Progress: ${event.data.progress}%`);
break;
case 'workflow.completed':
case 'workflow.failed':
case 'workflow.partially_failed':
// Terminal — break out of the loop.
return;
}
}
use Gisl\Sdk\GislSseEvent;
foreach ($client->streamEvents($workflowId) as $event) {
/** @var GislSseEvent $event */
switch ($event->event) {
case 'operation.progress':
printf("Progress: %s%%\n", $event->data['progress'] ?? 0);
break;
case 'workflow.completed':
case 'workflow.partially_failed':
case 'workflow.failed':
// Terminal — stop streaming.
break 2;
}
}
Coming soon — the Python SDK docs land with its content ticket.
Coming soon — the Rust SDK docs land with its content ticket.
The stream returns as soon as the server closes it — which it does on a terminal status. Event tags are dot-delimited; match them as plain strings.
Event tags
| Tag | Terminal? | Meaning |
|---|---|---|
operation.progress | no | incremental progress (0–100) for one operation |
operation.completed | no | one operation finished |
operation.failed | no | one operation failed |
job.completed / job.failed | no | a job finished |
workflow.completed | yes | the whole workflow succeeded |
workflow.partially_failed | yes | finished with some failures |
workflow.failed | yes | the whole workflow failed |
The three workflow.* events are the terminal signals — break out of the loop
on any of them.
Malformed frames
A frame whose data: body fails to parse is skipped — the stream stays
resilient so a long-running consumer never breaks on one garbled server frame.
The skip is silent by default; pass an onParseError callback to observe it. It
receives a { raw, event, error } diagnostic identical in shape across both
SDKs. This is a low-level hook — the ergonomic run() path always skips
silently — and throwing from it aborts the stream.
- TypeScript
- PHP
- Python
- Rust
for await (const event of await client.streamEvents(workflowId, {
onParseError: ({ raw, event: name, error }) => {
console.warn(`dropped malformed "${name}" frame: ${error}`, raw);
},
})) {
// ...
}
use Gisl\Sdk\{GislSseEvent, GislSseParseFailure};
foreach ($client->streamEvents(
$workflowId,
capability: null,
onParseError: function (GislSseParseFailure $failure): void {
error_log(sprintf('dropped malformed "%s" frame: %s', $failure->event, $failure->error));
},
) as $event) {
/** @var GislSseEvent $event */
}
Coming soon — the Python SDK docs land with its content ticket.
Coming soon — the Rust SDK docs land with its content ticket.
If a terminal frame arrives malformed it is skipped like any other, so the
stream ends without a recognised terminal event and run() falls back to
polling getWorkflowStatus — bounded latency, no hang.
Forcing polling in run() (useSSE)
The ergonomic run() streams progress over SSE first and silently falls back to
polling getWorkflowStatus if the stream can't be opened or drops. That default
(useSSE: true) suits almost everyone. To skip SSE and poll from the start —
e.g. behind a proxy that buffers or blocks SSE — opt out per call. It works the
same on the operation-first builders and all five file-first run surfaces
(file(...), files(...), and the merge / archive / watermark recipes).
- TypeScript
- PHP
- Python
- Rust
// operation-first
await client.compress('./big.mp4', { quality: 70 }).run({ useSSE: false });
// file-first (same options bag)
await client.file('big-video.mp4').compress(OptimizeFor.Balanced).run({ useSSE: false });
use Gisl\Sdk\Ergonomic\RunOptions;
// operation-first
$client->compress('./big.mp4', ['quality' => 70])
->run(new RunOptions(maxWait: '10m', useSSE: false));
// file-first (useSSE named argument, default true)
$client->file('big-video.mp4')
->compress(OptimizeFor::Balanced)
->run(maxWait: '10m', useSSE: false);
Coming soon — the Python SDK docs land with its content ticket.
Coming soon — the Rust SDK docs land with its content ticket.
When to use SSE vs polling
Use SSE (streamEvents) when | Use polling (run) when |
|---|---|
| You need sub-second feedback | 2-second granularity is fine |
| You're driving a progress UI | You're running a cron / batch job |
| You expect long-running workflows | You expect quick turnaround |
A connection can drop for benign reasons (proxy timeouts, laptop sleep).
The SDK opens one stream. There is no retry loop and no Last-Event-ID
resumption — it ignores the id: and retry: fields entirely. Events
published while you were disconnected are lost and cannot be replayed.
Any reconnect loop is yours to write, and after it exits you must cross-check the terminal state with a workflow-status call — the gap is unrecoverable from the stream alone.
Webhooks are a third option — they push terminal events to your server without holding a connection open, and they do not have this failure mode.