Artifacts & Pullable Output
Persist stdout/stderr and capture files a job wrote to /workspace.
Real-time output is streamed over soketi. But sometimes you also want a pullable
copy of a run — for clients that weren't subscribed, for batch jobs, or to grab files
the program produced (a rendered chart, a generated CSV). That's what
collectOutput and the artifact store are for.
Pullable stdout/stderr
Set collectOutput: true on the execute request:
const job = await client.execute({
language: 'python',
collectOutput: true,
files: [{ name: 'main.py', content: "print('done')\n" }],
});The worker then accumulates the same bytes it streams to soketi and persists a
RunResult to Redis with a TTL (RUN_RESULT_TTL, default 600s). Pull it after the
job ends:
const out = await client.getOutput(job.jobId);
// {
// exitCode: 0, signal: null, timedOut: false, idleTimedOut: false,
// truncated: false, durationMs: 312,
// stdout: "done\n", stderr: "",
// artifacts: [...], artifactsTruncated: false
// }getOutput returns 404 (a NotFoundError) if the job had no collected output, the
id is unknown, or the TTL elapsed. The single 404 is deliberate — a caller can't probe
which job ids exist.
File artifacts
A job can also leave files in /workspace. After the process exits — but before
the container is torn down — the worker captures top-level files from /workspace,
uploads them to S3-compatible storage, and includes presigned URLs in the RunResult:
"artifacts": [
{
"name": "plot.png",
"mimeType": "image/png",
"bytes": 20481,
"url": "https://<bucket>/artifacts/<jobId>/plot.png?X-Amz-..."
}
],
"artifactsTruncated": falseThe url is a presigned GET URL — it needs no bearer token, so you can hand it
straight to a browser. Input files, the compile-output binary, and internal markers
are excluded from capture automatically.
Browser-reachable URLs (split-horizon)
Presigned URLs are SigV4-signed, and the signature binds the host — so the URL
must be signed with the same host the client (browser or backend) will fetch from. In
most deployments the worker connects to object storage at the same address clients use,
and there's nothing to do. But in a split-horizon setup the worker reaches storage
at an internal address (e.g. http://minio:9000) while clients must fetch from a
different public one. Set ARTIFACT_S3_PUBLIC_ENDPOINT to the client-facing endpoint:
the worker still connects and uploads via AWS_ENDPOINT_URL_S3 (override
ARTIFACT_S3_ENDPOINT), but signs presigned URLs against the public endpoint. Signing
is pure local crypto, so there's no extra network round-trip. Left empty (the default),
presigned URLs use the connect endpoint.
# Worker uploads internally, clients fetch from the public host:
AWS_ENDPOINT_URL_S3=http://minio:9000
ARTIFACT_S3_PUBLIC_ENDPOINT=http://127.0.0.1:9000For local dev, prefer 127.0.0.1 over localhost. Cookies are scoped by host and
ignore the port, so artifacts served from localhost inherit the browser's localhost
cookies — a large Cookie header makes MinIO reject the request with
MetadataTooLarge. 127.0.0.1 is a separate cookie jar. In production, set this to
your public S3/CDN domain when it differs from the worker's internal endpoint.
Artifact capture is opt-in infrastructure: it's active only when the worker is
configured with object storage (BUCKET_NAME + S3 credentials). With no storage
configured, file artifacts are simply disabled — pullable stdout/stderr still work.
Artifact limits
Two per-request limits (with manifest/defaults) bound capture:
| Field | Default | Meaning |
|---|---|---|
maxArtifacts | 20 | Max number of files captured. |
maxArtifactBytes | 4 MiB | Max size per captured file. |
When more files exist than maxArtifacts, artifactsTruncated is true.
Storage & retention
The worker uses an S3-compatible store (MinIO in dev, Tigris/S3/R2 in prod). On boot
it ensures the bucket exists and installs a lifecycle expiry rule on the
artifacts/ prefix, so objects are cleaned up automatically. Three independent TTLs
govern retention:
| Env var | Default | Controls |
|---|---|---|
RUN_RESULT_TTL | 600s | How long the RunResult lives in Redis. |
PRESIGNED_URL_TTL | 24h | How long a presigned artifact URL stays valid. |
ARTIFACT_S3_OBJECT_TTL | 3 days | When the object is deleted from the bucket. |
The object TTL must be ≥ the presigned-URL TTL (the worker validates this at startup) — otherwise a URL could outlive the file it points to. Treat artifacts as ephemeral: if you need them durably, copy them to your own storage from the presigned URL before they expire.
See Configuration for the
full set of storage variables, and try it end to end with make artifacts-e2e.