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} | GET …/artifacts then POST …/presigned-url |
| List them with | GET …/threads/{threadId}/workspace | GET …/artifacts |
| 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 |
A turn that produced a file usually leaves the project library empty.
GET /artifactsreturning nothing is not a broken deployment — it means nothing was promoted. If you are looking for what the agent just made, you almost certainly want the workspace endpoint.The path is
workspacerather thanartifactsfor exactly this reason: they are the filesGET /artifactsdoes not return.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 also appears in the project library, 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.
Listing the project library
{
"artifacts": [
{
"id": 42,
"name": "Customers by state",
"kind": "chart",
"description": "Top 5 states by customer count",
"contentType": "image/png",
"createdAt": "2026-08-02T09:14:31.000Z",
"updatedAt": "2026-08-02T09:14:31.000Z"
}
]
}This lists promoted artifacts only. Presigned URLs always serve the artifact's latest version — versionNo in the mint response tells you which one you got.
Project-scoped, not user-scoped. An API key carries no user, so this returns every artifact in the project. Deciding which of them a given end user may see is your responsibility.
