Skip to Content
How-To GuidesRustDurable Streams in Rust

Durable Streams in Rust

Durable Streams is an HTTP protocol around an invocation’s typed stream slots. It is not generic agent-to-agent AgentStream RPC: AgentStream<T> declares the method value, while the Durable Streams route creates stable session and slot URLs that external clients can append to, resume, tail, cancel, and fork.

Expose a Stream-Bearing Method

Mount the agent, annotate the method as an endpoint, and deploy it through httpApi:

use golem_rust::agentic::AgentStream; use golem_rust::{agent_definition, endpoint}; #[agent_definition(mount = "/pipes/{name}")] pub trait Pipe { fn new(name: String) -> Self; #[endpoint( post = "/uppercase", durable_streams( input("input", name = "inbox"), output("$result", name = "outbox"), allow_external_writes = true, allow_stream_delete = true, allow_invocation_delete = true, max_concurrent_readers_per_stream = 8, max_append_requests_per_second_per_stream = 25, ) )] fn uppercase(&self, input: AgentStream<String>) -> AgentStream<String>; }

Slots are top-level stream parameters or outputs. $result selects a direct stream result or a non-stream scalar result. A record output made entirely of direct streams creates one slot per field instead. Public name values replace canonical names in URLs. Only direct AgentStream<u8> slots may override content_type; other slots use JSON.

The route family is:

<base> PUT creates a generated session <base>/invocations/<session> PUT/GET/HEAD/DELETE session <base>/invocations/<session>/streams/<slot> PUT/GET/HEAD/POST/DELETE slot <base>/forks/<fork>/invocations/<session>/streams/<slot>

Create or ensure the session before using slots when method arguments are not all in path/query. Input slots accept POST only when external writes are enabled. Output slots are read-only.

HTTP Reads and Writes

JSON POST bodies are batches: a non-array is one message and an array is many messages. To append one array-valued message, wrap it in an outer array. Byte slots accept raw bytes. Set Stream-Closed: true to atomically append-and-close; an empty body is valid only for close-only.

For replay-safe writes, send Producer-Id, Producer-Epoch, and Producer-Seq together. Retry an uncertain POST with the same tuple and body. Accepted producer writes return the acknowledged producer headers plus Stream-Next-Offset; offsets are opaque strings, never integers to compute.

Read with GET ...?offset=-1; use offset=now to resolve the current tail once. Continue with the returned Stream-Next-Offset and, for long-poll, Stream-Cursor. live=long-poll returns 204 when temporarily empty; live=sse uses SSE control events. Empty/up-to-date responses are not EOF. Stream-Closed: true at an up-to-date response is EOF. Treat 404, 410, cancellation, decode, and transport failures as errors, not clean closure.

DELETE on a slot cooperatively cancels and tombstones it. DELETE on a session cancels open slots but does not interrupt arbitrary agent code. Route policy can disable either operation. Reader, append, body, and fork limits can produce 429/413; honor Retry-After and do not retry oversized data unchanged.

Fork creation uses PUT on a fork slot with Stream-Forked-From and optional Stream-Fork-Offset, Stream-Fork-Sub-Offset, initial body, and closure. The source must have the same route/base, session ID, and public slot name as the target, either at the origin or in another fork. This is not a Golem worker fork: ordinary Golem forks copy external checkpoints and producer identity and can collide unless a deliberately independent producer is created.

Consume an External Compatible URL

Use the host-backed SDK; do not implement HTTP/SSE parsing yourself:

use golem_rust::durable_streams::{ DurableStreamWriter, ExternalDurableStream, ReadOptions, WriteOptions, }; async fn copy(source: String, target: String) -> Result<Vec<String>, Box<dyn std::error::Error>> { let mut reader = ExternalDurableStream::<String>::json(source, ReadOptions::default()); let mut values = Vec::new(); while let Some(value) = reader.next().await? { values.push(value); } let mut writer = DurableStreamWriter::new( target, "application/json", WriteOptions::default(), )?; writer.append_json(&values, true).await?; Ok(values) }

Use ExternalDurableStream::<u8>::bytes and append_bytes for byte sequences. Append boundaries are not preserved. ReadOptions controls opaque checkpoint/cursor, catch-up then long-poll/SSE, deadline, idle delay, and retries. into_agent_stream() transfers a reader into an ordinary lazy AgentStream; do not use the original afterward.

Writers retain one immutable pending request: producer ID, epoch, sequence, encoded payload, and close flag. If append is cancelled or fails, call retry_pending() before supplying different data. Only an exact acknowledgement advances sequence. next_offset: None is still a valid acknowledgement. Dropping a writer cannot undo a possibly committed write.

Timeout, transport, rate-limit, and unavailable failures use bounded exponential backoff and durable timers; protocol, fencing, conflict, gone, and payload-too-large errors do not. Replay returns recorded host results and reconstructs buffers, checkpoints, retry state, and pending writes without repeating completed HTTP effects. Do not wrap these calls in custom durability or atomic scopes.

For bearer auth, pass a host secret capability in ReadOptions::auth or WriteOptions::auth as Arc<schema::wit::wire::Secret>. For configured Secret<String>, call handle() and transfer the returned GuestSecretHandle with take(); never 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.

Build and deploy after adding the httpApi deployment:

golem build golem deploy --yes