Artifacts

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 fileProject artifact
Written bycreate_artifact — every deliverablesave_artifact_to_project — only when the user asks to keep it
EndpointGET …/threads/{threadId}/workspace/{filename}POST …/presigned-url
List them withGET …/threads/{threadId}/workspaceNothing — keep the id from the artifact SSE frame
You get backThe bytes, in one requestJSON with a url — two steps: get the URL, then fetch it
Addressed byFilename, scoped to the threadNumeric artifact id
Stored inThe thread's Git repoObject storage
Announced byThe create_artifact tool_resultAn artifact SSE frame
LifetimeEphemeral to the threadVersioned, kept with the project

There is no endpoint that lists a project's artifacts. An artifact id reaches you exactly one way: the artifact SSE frame the turn emits when save_artifact_to_project runs. Keep it — that is the id you hand to the presigned-URL endpoint later. The frames are persisted, so GET …/result replays them if you did not watch the stream.

Most turns promote nothing. If you are looking for what the agent just made and no artifact frame arrived, you want the workspace endpoint — the path is workspace rather than artifacts for 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.

modeEffect
previewServes the file inline, so HTML, Markdown, SVG and images render in your UI. Default on the workspace endpoint.
downloadForces 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.

🔗

Learn more