Durable Streams in TypeScript
Durable Streams is the resumable HTTP protocol for an invocation’s typed stream slots. It differs
from generic agent-to-agent streaming: s.stream(...) and AgentStream<T> define RPC values;
Durable Streams adds stable session/slot URLs for external append, read, resume, cancellation, and
fork operations.
Expose a Stream-Bearing Method
import { AgentStream, defineAgent, http, method, s } from '@golemcloud/golem-ts-sdk';
import { z } from 'zod';
const Pipe = defineAgent({
name: 'Pipe',
id: { name: z.string() },
http: http.mount('/pipes/{name}'),
methods: {
uppercase: method({
input: { input: s.stream(z.string()) },
returns: s.stream(z.string()),
http: http.post('/uppercase', {
durableStreams: {
slots: [
{ source: 'input', slot: 'input', name: 'inbox' },
{ source: 'output', slot: '$result', name: 'outbox' },
],
load: {
maxConcurrentReadersPerStream: 8,
maxAppendRequestsPerSecondPerStream: 25,
},
},
}),
}),
},
});Implement the transform lazily with AgentStream.from(asyncIterable). Slots are top-level stream
inputs or outputs. $result selects a direct stream result or scalar result; an all-stream record
result creates one slot per field. A public name replaces the canonical URL name. JSON slots
may explicitly set contentType: 'application/json' only. Direct s.stream(s.u8()) byte slots
may override it with a concrete non-text, non-JSON MIME type (including no +json suffix),
without parameters or wildcards. Add the agent to an httpApi
deployment in golem.yaml.
The protocol paths are <base>, <base>/invocations/<session>, and
<base>/invocations/<session>/streams/<slot>; forks insert
/forks/<fork> before /invocations. PUT creates/ensures, GET/HEAD reads metadata or data, POST
appends to writable inputs, and DELETE cooperatively cancels. Create the session first when method
arguments are not entirely path/query-bound.
Protocol Rules
- JSON POST arrays are batches; wrap an array-valued message in another array. Byte slots use raw
bytes.
Stream-Closed: truemakes append-and-close atomic; empty bodies require close-only. - Send
Producer-Id,Producer-Epoch, andProducer-Seqtogether. Retry an uncertain write with the identical tuple and body. TreatStream-Next-Offsetas opaque. - Start reads with
offset=-1or resolve the current tail withoffset=now. Continue using the returned offset and long-poll cursor. A 204 long-poll or empty/up-to-date response is not EOF; up-to-date plusStream-Closed: trueis EOF. - Missing, gone, cancelled, decode, and transport outcomes are errors, not clean closure. DELETE tombstones a slot; session DELETE cancels open streams without interrupting arbitrary agent code.
- Admission, append, body, and fork limits return 429/413. Honor
Retry-After. An uncertain pending append must not be split or changed; size new appends before submitting them. - Create a fork by PUT with
Stream-Forked-Fromand optional offset/sub-offset, initial content, and closure. Its source must have the same route/base, session ID, and public slot name as the target, either at the origin or in another fork. Ordinary Golem forks instead copy checkpoints and producer tuples, so divergent branches can collide unless intentionally assigned independent producers.
Read and Write Compatible URLs
import {
createDurableJsonWriter,
readDurableJsonStream,
} from '@golemcloud/golem-ts-sdk';
import { z } from 'zod';
async function copy(source: string, target: string): Promise<string[]> {
const values: string[] = [];
const reader = readDurableJsonStream(z.string(), { url: source });
try {
for await (const value of reader) {
values.push(value);
}
} finally {
await reader.return();
}
const writer = createDurableJsonWriter(z.string(), { url: target });
await writer.append(values, { close: true });
return values;
}Use readDurableByteStream and createDurableByteWriter for bytes; readers yield individual byte
numbers and append chunk boundaries disappear. Reader options include opaque offset/cursor,
live: 'long-poll' | 'sse', deadlines, idle delay, and bounded retries. The returned value is an
ordinary affine AgentStream; consume once and call return() when abandoning it.
Writers serialize operations and retain one uncertain producer tuple, exact encoded body, and
close flag. retryPending() must resolve it before different data is assigned. Acknowledgement
alone advances sequence; an absent nextOffset is valid. dispose() resolves pending work and
releases locally without closing the remote stream. Dropping transport cannot undo an external
effect.
The SDK retries timeout, transport, rate-limit, and unavailable errors with bounded exponential
backoff, durable timers, and Retry-After. maxRetries defaults to 8 retries after the first
attempt; the delay starts at retryDelayMs (default 100 ms) and doubles up to 30 s, and a longer
Retry-After wins. maxRetries: 0 disables retries. Permanent protocol, fencing, conflict, gone, and size
errors remain typed DurableStreamErrors. Replay reconstructs checkpoints, buffered items,
producer state, pending data, and retry budgets and does not repeat completed HTTP. Do not add a
custom durable/atomic wrapper.
auth accepts an SDK Secret<string>, including a config field declared with
s.secret(z.string()): pass { url, auth: this.config.apiKey } directly. The SDK
unwraps the borrowed capability internally; do not call .get() or reveal the token.
HTTPS is required except when the host is localhost, a
dot-delimited subdomain such as app.localhost, or a loopback IP. Localhost names are
case-insensitive and may have one terminal DNS dot. The URL authority stays unchanged. Redirects
and URL credentials are rejected.
golem build
golem deploy --yes