Using HTTP routers and files
Use an HTTP router to handle streaming requests, serve static assets, or integrate an HTTP framework. To publish files created by a durable agent, add live file mappings to that agent instead. The router and file semantics page explains how these features interact with code-first endpoints, authentication, and durable execution.
The examples below belong in an existing Golem application. Use a CLI and SDK from the same Golem version. Commands assume a local server on your machine, with the application gateway on port 9006.
Handling streaming requests
Define a router with a unique type name and a literal mount path. This EchoRouter
streams an upload back to the caller at /echo.
It does not collect the upload into memory or calculate its length first.
TypeScript
import { defineHttpRouter } from '@golemcloud/golem-ts-sdk';
export const EchoRouter = defineHttpRouter('EchoRouter')
.mount('/echo')
.implement((request) => new Response(request.body));The Web adapter accepts standard Request and Response objects, so a framework
with a Fetch-compatible handler can supply the function passed to .implement.
Keep its body streaming. For the original, unnormalized HTTP envelope, use
.implementRaw instead; see Working with headers.
Merge this deployment into your application’s manifest:
httpApi:
deployments:
local:
- domain: localhost:9006
scheme: http
agents:
EchoRouter: {}Build, deploy, and send an upload:
golem build -L
golem deploy -L -Y
printf 'stream me' | curl --data-binary @- http://localhost:9006/echoThe response is stream me. For a router with several routes, dispatch on the
request’s method and path, and return 404 for unknown routes. The canonical request
uses the full public path; framework adapters may present mount-relative paths
as noted in their tabs. Routing precedence
explains when a code-first endpoint or file mapping takes priority over the handler.
Working with headers
Keep repeated headers separate, especially Set-Cookie. The raw APIs use lowercase
names and byte-valued fields. For example, add two cookies to a streaming response:
TypeScript
The Web adapter already preserves separate Set-Cookie fields. For byte-valued
or strictly ordered fields, import withRawHeaders from @golemcloud/golem-ts-sdk
and replace the handler with:
.implement((request) => withRawHeaders(new Response(request.body), [
{ name: 'set-cookie', value: new TextEncoder().encode('first=one') },
{ name: 'set-cookie', value: new TextEncoder().encode('second=two') },
]));withRawHeaders replaces all response headers with this list, rather than
adding to Response.headers. Include every header you want emitted.
For canonical methods, paths, queries, and headers without Web API normalization,
replace .implement(...) with a raw handler:
.implementRaw((request) => ({
status: 200,
headers: [
{ name: 'set-cookie', value: new TextEncoder().encode('first=one') },
{ name: 'set-cookie', value: new TextEncoder().encode('second=two') },
],
body: request.body,
}));A Web handler can also read context.rawRequest from its second argument to inspect
the original head. The body still has only one reader.
Let Golem handle transfer framing. Omit Content-Length unless you know the exact
emitted byte count. Release unused input and stop producing when the output consumer
closes. Do not turn stream errors into normal EOF. See the
streaming contract for
bodyless responses and the distinction between EOF and invocation success.
Serving static files
Create a router with file mappings and no handler. This StaticRouter serves an
explicit index at /static and assets below /static/assets/:
TypeScript
import { defineHttpRouter } from '@golemcloud/golem-ts-sdk';
export const StaticRouter = defineHttpRouter('StaticRouter')
.mount('/static')
.static('/', '/public/index.html')
.static('/assets/*', '/public/assets/$1')
.implement();Create index.html and message.txt beside golem.yaml. Provision them as
read-only files under the router’s type name, and add it to the HTTP deployment:
agents:
StaticRouter:
files:
- sourcePath: ./index.html
targetPath: /public/index.html
permissions: read-only
- sourcePath: ./message.txt
targetPath: /public/assets/message.txt
permissions: read-only
httpApi:
deployments:
local:
- domain: localhost:9006
scheme: http
agents:
EchoRouter: {}
StaticRouter: {}Redeploy and check the index, metadata, and a byte range:
golem deploy -L -Y
curl http://localhost:9006/static
curl -I http://localhost:9006/static/assets/message.txt
curl -i -H 'Range: bytes=0-3' http://localhost:9006/static/assets/message.txtUse a nonempty message.txt for the range example. Static responses include a
strong ETag and Cache-Control: no-cache. Send the returned ETag in If-None-Match
to revalidate; an unchanged asset returns 304. See the
file HTTP reference for HEAD,
preconditions, and range edge cases.
You can add the same mappings to a router with a handler: matching GET/HEAD files
are served first, and an absent file falls through to the handler. For an override
directory, declare two /assets/* mappings, first to /overrides/$1, then to
/public/assets/$1. Order matters; see
ordered mappings.
Expose only directories intended for HTTP access. There is no implicit index lookup, directory listing, or symlink support. Keep secrets outside exposed roots and protect private mounts with authentication.
Serving live agent files
Use a normal durable agent when files are created or modified at runtime. Bind its
constructor parameters in the mount and expose only the desired files. This
FileOwner creates /value.txt during initialization and overwrites it on update:
TypeScript
import { defineAgent, http, method } from '@golemcloud/golem-ts-sdk';
import * as fs from 'node:fs';
import { z } from 'zod';
export const FileOwner = defineAgent({
name: 'FileOwner',
id: { name: z.string() },
http: http.mount('/files/{name}', {
exposeFiles: [{ route: '/value', path: '/value.txt' }],
}),
methods: { update: method({ input: {}, returns: z.string() }) },
});
export const FileOwnerImpl = FileOwner.implement({
init({ id }) {
fs.writeFileSync('/value.txt', `initial:${id.name}`);
return {};
},
methods: {
update() {
fs.writeFileSync('/value.txt', 'updated');
return 'updated';
},
},
});Add FileOwner: {} beside the routers in the deployment’s agents map, then:
golem deploy -L -Y
curl http://localhost:9006/files/alice/value
golem agent invoke -L 'FileOwner("alice")' update
curl http://localhost:9006/files/alice/valueThe first read returns initial:alice; the second returns updated. Reads use
Cache-Control: no-store with no ETag. No exported method is called to serve the
file, but the first read can initialize the agent. A slow download can delay other
work on that agent; see live-file lifecycle.
Calling an agent from a router
Call ordinary agents through the same clients used for agent-to-agent communication. For example, inside an async handler, update the file owner before constructing the response:
TypeScript
await FileOwner.client.get({ name: 'from-router' }).update();For cross-component calls, declare the dependency and generate its client as described in Calling an agent in another component. A router’s cancellation does not roll back completed agent calls. Client retries are new requests and can repeat effects; see cancellation and recovery.
Publishing OpenAPI
Add an OpenAPI provider to describe your router’s operations. For EchoRouter,
describe POST / relative to its mount; Golem publishes it as POST /echo.
The provider must return OpenAPI 3.1.0, not an OpenAPI 3.0 or YAML document.
TypeScript
Insert this before .implement(...); the SDK serializes the returned object:
.openApi(() => ({
openapi: '3.1.0',
info: { title: 'Echo API', version: '1' },
paths: {
'/': {
post: {
operationId: 'echo',
responses: { '200': { description: 'Streamed request bytes' } },
},
},
},
}))Keep providers finite and deterministic. Use unique component names and operation IDs across the domain, and local references that resolve within the provider’s own document. See OpenAPI generation for the supported subset, security merging, conflicts, caching, and size limits.
After redeployment, retrieve both formats:
golem deploy -L -Y
curl http://localhost:9006/openapi.json
curl http://localhost:9006/openapi.yamlTo place them under /docs, add openapiEndpoint: /docs beside domain and scheme
in the HTTP deployment. The URLs become /docs/openapi.json and /docs/openapi.yaml.
Set scheme to match the public origin. Built-in local environments default to
http, cloud environments to https; custom-server environments must set it explicitly.
OpenAPI endpoints are public even when a router requires authentication. Do not include secret values, private examples, or requester-specific information. Apply a separate ingress policy if the description must be private.
Protecting mounts
Configure authentication and CORS on the router mount, not its handler method. Use the same security scheme configuration as code-first endpoints. The router declarations accept these options:
TypeScript
.mount('/echo', { auth: true, cors: ['https://app.example.com'] })Protect each child mount explicitly: it does not inherit authentication from a parent. Test denial before checking file existence, and test browser preflight separately from plain OPTIONS. See authentication and failures for host behavior and status codes.
Configuring self-hosted deployments
These settings are for platform operators. Applications use worker-service’s
apps gateway (custom_request_port, default 9006), not its management REST or
gRPC port.
Configuring shared storage
Static files are read directly by worker-service. Configure registry, executors, and every serving worker-service to share the initial-file blob store and namespace. For a local/shared-volume topology:
[blob_storage]
type = "LocalFileSystem"
[blob_storage.config]
root = "/blob_storage"Mount the same volume into all services; identical paths in isolated containers
are not shared storage. The equivalent environment variables are
GOLEM__BLOB_STORAGE__TYPE=LocalFileSystem and
GOLEM__BLOB_STORAGE__CONFIG__ROOT=/blob_storage. The
PostgreSQL compose example
shows the shared volume wiring.
For S3, configure worker-service with the same initial_agent_files_bucket,
object_prefix, region, endpoint, and path-style settings as the other services.
Use the complete service configuration for the other required buckets. Grant
worker-service read access, including ranged reads, and supply credentials through
your deployment’s secret mechanism. InMemory storage is not shared across processes;
worker-service does not support KVStoreSqlite.
Configuring ingress
Use an ingress with an HTTP/2 cleartext upstream (h2c prior knowledge), preserve
per-stream resets, and disable request/response buffering for streaming routes.
The gateway also accepts HTTP/1, but does not support Upgrade: h2c.
To accept public-origin headers from a TLS-terminating proxy, list its direct peer IP in worker-service configuration:
[route_resolver]
trusted_ingress_addresses = ["10.0.0.10"]The default list is empty. Configure that proxy to remove client forwarding fields
and write exactly one valid X-Forwarded-Proto and one X-Forwarded-Host. Both are
required; comma-separated proxy chains and Forwarded are not alternative origin
sources. Keep the listener private to the intended ingress.
Test disconnect/reset through the actual proxy, including while waiting for a response head. A client timeout alone does not prove upstream cancellation. Keep HTTP/2 enabled: some pending live-file requests over HTTP/1 detect disconnect only at the exchange deadline.
Setting timeouts and resource budgets
Tune worker-service’s session settings to your workload. The defaults are:
[http_session]
exchange_timeout = "5m"
retry_deadline = "10s"
retry_delay = "100ms"
max_resume_attempts = 4
cleanup_timeout = "2s"
retained_input_bytes = 4194304
retained_input_frames = 32
event_queue_frames = 2
command_queue_frames = 16
max_event_bytes = 17825792Use exchange_timeout to bound the whole handler or live-file exchange, including
initialization, queueing, and streaming. There is no separate executor file-read
deadline. Expiry returns 504 before commitment or aborts an already-started response.
Recovery uses the same surviving invocation, with four reattachments allowed over
the exchange’s lifetime; it cannot replace a lost executor invocation.
Use ingress concurrency limits to control live-file load: there is no dedicated
per-agent read queue cap or executor-wide outstanding-read limit. Slow readers can
delay an agent’s other work. durable_streams.load does not configure these reads.
The byte/frame values bound encoded session buffering, not total upload size.
Input encoding includes schema and frame overhead. Queued output is bounded by
event_queue_frames × max_event_bytes, not all per-request allocations.
OpenAPI generation uses separate limits
and is not bounded by exchange_timeout. Route lookup caching
(route_resolver.router_cache_ttl = "10m", capacity 1024, eviction period 1 minute)
is also separate from the OpenAPI document cache.
Troubleshooting
- Unexpected handler or 404: check concrete code-first routes, the selected mount, and declaration order. Only absent files fall through; permission, initialization, and storage failures do not.
- Static file returns 500: confirm it is provisioned read-only and that worker-service can read the indexed blob from shared storage.
- Live read stalls: check agent initialization and preceding work, then test cancellation through the ingress and review the exchange deadline.
- OpenAPI returns 502: check provider JSON, size limits, local references, and conflicting paths/components/operation IDs. A provider failure does not disable ordinary router traffic.
Use status codes, safe error categories, and router/deployment identities in diagnostics. Do not log HTTP bodies, provider documents, secrets, or internal paths.
Complete examples are available in the repository for TypeScript , Rust , Scala , MoonBit , and Effect .