Fetch the files, charts and documents a turn produced — from the thread workspace, or from the project library.
When the agent produces a deliverable — a chart, an HTML report, a spreadsheet — it writes it somewhere you can fetch it. There are two somewheres, and picking the wrong one is the most common mistake on this API.
Workspace files vs. project artifacts
Every file the agent creates starts as a workspace file: it belongs to the thread, is addressed by filename, and lives in the thread's Git repo. That is what create_artifact produces, and it is most of what you will want.
A workspace file becomes a project artifact only when the agent calls save_artifact_to_project — which it does when the user asks it to keep or pin the file. Then it gains a numeric id and a version, joins the project library, and can be handed to a browser as a presigned URL.
| Workspace file | Project artifact | |
|---|---|---|
| Written by | create_artifact — every deliverable | save_artifact_to_project — only when the user asks to keep it |
| Endpoint | GET …/threads/{threadId}/workspace/{filename} | POST …/presigned-url |
| List them with | GET …/threads/{threadId}/workspace | Nothing — keep the id from the artifact SSE frame |
| You get back | The bytes, in one request | JSON with a url — two steps: get the URL, then fetch it |
| Addressed by | Filename, scoped to the thread | Numeric artifact id |
| Stored in | The thread's Git repo | Object storage |
| Announced by | The create_artifact tool_result | An artifact SSE frame |
| Lifetime | Ephemeral to the thread | Versioned, kept with the project |
There is no endpoint that lists a project's artifacts. An artifact id reaches you exactly one way: the
artifactSSE frame the turn emits whensave_artifact_to_projectruns. Keep it — that is the id you hand to the presigned-URL endpoint later. The frames are persisted, soGET …/resultreplays them if you did not watch the stream.Most turns promote nothing. If you are looking for what the agent just made and no
artifactframe arrived, you want the workspace endpoint — the path isworkspacerather thanartifactsfor exactly that reason.To see what a thread actually holds, ask it directly:
GET …/threads/{threadId}/workspace.
Basic Usage
Reading what the turn just produced
The create_artifact tool result carries the filename. It deliberately does not hand back a URL, so a client is never left holding a stale link:
{
"block_id": 7,
"id": "toolu_01D",
"name": "create_artifact",
"output": "{\"filename\":\"q3-revenue.html\",\"status\":\"success\"}"
}Combine it with the threadId from the turn's init frame:
curl --request GET \
--url 'https://cloud.getwren.ai/api/v2/projects/1/threads/5/workspace/q3-revenue.html' \
--header 'Authorization: Bearer <API_KEY>'The response body is the file:
<!doctype html>
<html>
<head><title>Q3 revenue</title></head>
<body>...</body>
</html>mode defaults to preview (serve inline). Pass ?mode=download to add a Content-Disposition: attachment header.
Fetching a promoted artifact
When the user asked the agent to keep a file, it is promoted and an artifact frame announces it:
event: artifact
data: {"artifactId":42,"kind":"chart","filename":"customers-by-state.png","name":"Customers by state","contentType":"image/png"}That frame has no URL either. Mint one:
{
"mode": "preview"
}{
"artifactId": 42,
"name": "Customers by state",
"kind": "chart",
"versionNo": 1,
"contentType": "image/png",
"mode": "preview",
"url": "https://storage.getwren.ai/artifacts/42/1?X-Amz-Expires=900&X-Amz-Signature=..."
}Point an <img>, <iframe>, or download link at url. It needs no API key and no browser session — but it expires, so mint it when the user asks rather than storing it.
mode | Effect |
|---|---|
preview | Serves the file inline, so HTML, Markdown, SVG and images render in your UI. Default on the workspace endpoint. |
download | Forces an attachment download. Default on the presigned-URL endpoint — it mints a link for a browser to follow, while the workspace endpoint is normally read by your own code. |
Wiring it up end to end
Collect filenames from create_artifact tool results as the turn streams, then read each one back:
// 1. Run a turn, keeping the thread id and every filename the agent wrote.
let threadId;
const filenames = [];
for await (const frame of streamAgentAsk({
projectId: 1,
question: 'Build me an HTML summary of Q3 revenue by product line.',
})) {
if (frame.event === 'init') threadId = frame.data.threadId;
if (frame.event === 'tool_result' && frame.data.name === 'create_artifact') {
filenames.push(JSON.parse(frame.data.output).filename);
}
// Only fires for files the user asked to keep — see below.
if (frame.event === 'artifact') promoted.push(frame.data);
}
// 2. Read the bytes straight back. One request each, no URL step.
const files = await Promise.all(
filenames.map(async (filename) => ({
filename,
body: await getText(
`/v2/projects/1/threads/${threadId}/workspace/${encodeURIComponent(filename)}`,
),
})),
);
// 3. Render them.
files.forEach(({ filename, body }) => renderArtifact(filename, body));Anything the user asked to keep is also a project artifact, where the presigned-URL flow applies — that is the path to use when you want a link a browser can follow without your API key.
If you did not collect the filenames
Watching the stream is the cheapest route, but it is not the only one. GET …/threads/{threadId}/workspace lists what a thread holds, so a client that reconnected, polled for the result instead of streaming, or ran the turn from a script can still find its files:
const { files } = await getJson(`/v2/projects/1/threads/${threadId}/workspace`);
// [{ filename: 'q3-revenue.html', contentType: 'text/html; charset=utf-8', sizeBytes: 18422 }]Each entry carries the same contentType the read endpoint will serve, so you can decide what to render — or skip — before fetching any bytes.
Security headers
Workspace responses always carry X-Content-Type-Options: nosniff. Renderable documents — HTML, SVG, XML — additionally carry Content-Security-Policy: sandbox and Referrer-Policy: no-referrer, so artifact markup the agent generated stays on an opaque origin. If you embed one in an <iframe>, it is already sandboxed for you.
Keeping track of artifact ids
There is no endpoint that lists a project's artifacts. The artifact SSE frame is where an id comes from, and storing it is your side of the contract:
{
"artifactId": 42,
"kind": "single_file",
"filename": "customers-by-state.png",
"name": "Customers by state",
"contentType": "image/png"
}Persist artifactId against whatever your product calls this thing — a report, a saved answer, a pinned chart — and mint a presigned URL whenever you need to show it. Presigned URLs always serve the artifact's latest version; versionNo in the mint response tells you which one you got.
If you did not watch the stream, GET …/result replays the turn's frames, artifact among them.
