Session artifacts¶
Editions: OSS, Cloud, Enterprise. Unless stated otherwise, everything on this page ships in OSS.
An artifact is a file an agent produced or captured during a runtime session:
a screenshot, a call transcript, a summary document, a generated report, a
Playwright trace. Preloop stores it encrypted on the session, records an
artifact row on the session timeline, and serves the bytes back to anyone in
the account who may read runtime sessions.
An artifact is evidence of what the agent said it produced. Storing one is not an approval and does not prove the content is correct.
Kinds and caps¶
Every artifact has a kind. The kind fixes the media types it accepts and the largest size it may have. For most media types the first bytes are compared with the format signature, so a file that is not the declared type is refused.
| Kind | Media types | Default cap | Setting |
|---|---|---|---|
screenshot |
image/png, image/jpeg, image/webp |
2 MiB | RUNTIME_SESSION_SCREENSHOT_MAX_BYTES |
recording |
video/webm, video/mp4 |
512 MiB | RUNTIME_SESSION_RECORDING_MAX_BYTES |
screencast |
video/webm, video/mp4 |
64 MiB | RUNTIME_SESSION_SCREENCAST_MAX_BYTES |
audio |
audio/mpeg, audio/wav, audio/ogg, audio/webm, audio/mp4, audio/flac |
25 MiB | RUNTIME_SESSION_AUDIO_MAX_BYTES |
transcript |
text/plain, text/vtt, application/x-subrip, application/json |
5 MiB | RUNTIME_SESSION_TRANSCRIPT_MAX_BYTES |
document |
text/plain, text/markdown, application/json, application/pdf |
10 MiB | RUNTIME_SESSION_DOCUMENT_MAX_BYTES |
generated_file |
any type except native executables (ELF, PE, Mach-O) | 10 MiB | RUNTIME_SESSION_GENERATED_FILE_MAX_BYTES |
trace |
application/zip (for example a Playwright trace.zip) |
25 MiB | RUNTIME_SESSION_TRACE_MAX_BYTES |
When a deposit names no kind, Preloop infers one from the content: an image is
a screenshot, audio is audio, video is a recording, text/vtt and
application/x-subrip are a transcript, other text is a document, and
anything else is a generated_file. If the inferred kind does not accept the
media type (for example image/gif or text/csv), the deposit is stored as a
generated_file instead of being refused. Audio is the exception: it stays
audio, so the account's audio setting still applies.
A request body may be at most the largest kind cap plus 1 MiB. Larger bodies are refused before they are parsed.
Labels¶
Labels are a small JSON object you attach at deposit time and filter by later.
At most 16 keys; each key matches ^[a-z][a-z0-9_.-]{0,62}$; each value is a
string of up to 128 characters, except tags, which is a list of up to 16
such strings. These keys are reserved and have a documented meaning:
| Key | Meaning |
|---|---|
site |
Physical or logical site the artifact belongs to, for example a plant. |
tenant_ref |
Your own customer or tenant reference. |
consent_basis |
Why recording this person is allowed, for example contract. |
retention_class |
Retention bucket name, enforced by the Enterprise records policy. |
tags |
Free-form list of short strings. |
Any other key that follows the rules is stored as given. A label that breaks a
rule refuses the whole deposit with artifact_labels_invalid.
Depositing an artifact¶
All three paths store through the same service, write the same timeline row and return the same error codes. The session always comes from the credential: an agent can only deposit on its own session.
Get a session-bound key¶
The examples below use a runtime session token. A console user (or a launcher acting for one) mints it:
curl -X POST "$PRELOOP_URL/api/v1/auth/runtime-sessions/token" \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"session_source_type": "claude_code",
"session_source_id": "docs-demo-4",
"expires_in_minutes": 60,
"allowed_mcp_tools": ["deposit_artifact"]
}'
{"runtime_session_id":"a30cd80e-...","token":"flow_vJlp...","expires_at":"2026-10-01T17:07:22Z","session_source_type":"claude_code","session_source_id":"docs-demo-4","session_reference":null}
Use runtime_session_id as $SESSION_ID and token as $AGENT_KEY. A
managed agent key that is pinned to a session works the same way.
1. MCP tool deposit_artifact¶
deposit_artifact is a built-in MCP tool, off by default. Turn it on for
the account on the Tools page, or list it in a flow's allowed_mcp_tools.
A session token keeps only the tools the account had enabled when it was
minted, so mint (or re-mint) the token after enabling the tool and ask for it
in allowed_mcp_tools as above.
Claude Code:
claude mcp add --transport http preloop https://preloop.example.com/mcp/v1 \
--header "Authorization: Bearer $AGENT_KEY"
opencode, in opencode.json (project) or ~/.config/opencode/opencode.json:
{
"mcp": {
"preloop": {
"type": "remote",
"url": "https://preloop.example.com/mcp/v1",
"headers": { "Authorization": "Bearer {env:AGENT_KEY}" }
}
}
}
Then ask the agent to save its work, or call the tool directly. The arguments
are one MCP ContentBlock plus a name, an optional kind and labels:
{
"name": "deposit_artifact",
"arguments": {
"name": "notes.md",
"kind": "document",
"labels": {"site": "heilbronn"},
"content": {"type": "text", "text": "# Notes\nRestarted line 3."}
}
}
content.type is text, image (data, mimeType), audio (data,
mimeType), resource (resource.uri, resource.mimeType, and text or
base64 blob), or resource_link to an artifact of the same session, which
stores a copy with new labels as a child of the linked one. The result has one
resource_link content block pointing at the stored bytes and the full
artifact descriptor as structuredContent:
{"content":[{"type":"resource_link","name":"notes.md","uri":"http://localhost:18090/api/v1/runtime-sessions/a30cd80e-.../artifacts/88b4b89d-...","mimeType":"text/plain","size":25,"_meta":{"preloop.dev/artifact":{"artifact_id":"88b4b89d-...","kind":"document","labels":{"site":"heilbronn"},"sha256":"129f22f5...","producer":"deposit_mcp"}}}],"structuredContent":{"id":"88b4b89d-...","kind":"document","availability":"available","...":"..."}}
Refusals are tool errors whose text starts with the error code.
2. REST¶
POST /api/v1/runtime-sessions/{runtime_session_id}/artifacts with the agent
key. The response is 201 with the artifact descriptor.
JSON, with the content as an MCP ContentBlock:
curl -X POST "$PRELOOP_URL/api/v1/runtime-sessions/$SESSION_ID/artifacts" \
-H "Authorization: Bearer $AGENT_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: call-42-vtt" \
-d "{
\"name\": \"call.vtt\",
\"labels\": {\"consent_basis\": \"contract\"},
\"content\": {
\"type\": \"resource\",
\"resource\": {
\"uri\": \"file:///call.vtt\",
\"mimeType\": \"text/vtt\",
\"blob\": \"$(base64 < call.vtt | tr -d '\n')\"
}
}
}"
{"id":"5898d690-...","runtime_session_id":"5e25eed1-...","activity_id":"ea2154a4-...","kind":"transcript","name":"call.vtt","content_type":"text/vtt","size_bytes":67,"sha256":"dfd59948...","labels":{"consent_basis":"contract"},"producer":"deposit_api","agent_id":"7deec1ef-...","tool_name":null,"parent_artifact_id":null,"text_status":"none","availability":"available","legal_hold":false,"created_at":"2026-10-01T16:06:48.135281Z","content_block":{"type":"resource_link","uri":"/api/v1/runtime-sessions/5e25eed1-.../artifacts/5898d690-...","name":"call.vtt","mimeType":"text/vtt","size":67,"_meta":{"preloop.dev/artifact":{"artifact_id":"5898d690-...","kind":"transcript","labels":{"consent_basis":"contract"},"sha256":"dfd59948...","producer":"deposit_api"}}}}
The optional Idempotency-Key header makes a retry safe: the same key on the
same session returns the original artifact with 201 and the header
Idempotent-Replayed: true, and stores nothing new.
A plain text body is the shortest form:
curl -X POST "$PRELOOP_URL/api/v1/runtime-sessions/$SESSION_ID/artifacts" \
-H "Authorization: Bearer $AGENT_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "call-summary.md", "kind": "document",
"labels": {"site": "heilbronn", "tags": ["shift-a"]},
"content": {"type": "text", "text": "# Summary\n\nLine 3 stopped at 06:10."}}'
Multipart, for files on disk. The file part carries the bytes and its media
type; the optional metadata part is the same fields as JSON, without
content. name defaults to the file name:
curl -X POST "$PRELOOP_URL/api/v1/runtime-sessions/$SESSION_ID/artifacts" \
-H "Authorization: Bearer $AGENT_KEY" \
-F "file=@call.vtt;type=text/vtt" \
-F 'metadata={"kind":"transcript","labels":{"site":"heilbronn"}}'
Optional fields in both encodings: activity_id (a timeline row of this
session the artifact illustrates), parent_artifact_id (an artifact of this
account it derives from, for example a summary of a transcript) and
tool_name.
List a session's artifacts, newest first, with the agent key or a console user token:
curl "$PRELOOP_URL/api/v1/runtime-sessions/$SESSION_ID/artifacts?kind=transcript&label=site:heilbronn&limit=50" \
-H "Authorization: Bearer $USER_TOKEN"
{"items":[{"id":"65cd256c-...","kind":"transcript","name":"call.vtt","labels":{"site":"heilbronn"},"availability":"available","...":"..."}],"next_cursor":null}
label is repeatable (key:value), limit is 1 to 200 (default 50), and
next_cursor goes into cursor for the next page.
Read the bytes with the content_block.uri:
curl "$PRELOOP_URL/api/v1/runtime-sessions/$SESSION_ID/artifacts/$ARTIFACT_ID" \
-H "Authorization: Bearer $USER_TOKEN"
The response has the stored media type and Cache-Control: private,
max-age=300. It is 410 with {"availability": "evicted"} or
{"availability": "expired"} once the bytes are gone.
The full request and response schemas are in the API reference
(openapi.yaml, operation deposit_runtime_session_artifact).
3. CLI¶
preloop artifacts put standup.vtt --session <id> --label site=nord
some-tool | preloop artifacts put - --session <id> --content-type text/plain --name notes.txt
preloop artifacts ls --session <id> --kind transcript --since 7d
preloop artifacts get <artifact-id> --session <id> -o standup.vtt
put streams the multipart call above and prints the artifact id and a
console link. --json prints the descriptor unchanged. See
CLI: Artifacts for every flag.
Reading artifacts from an agent¶
Two built-in MCP tools, both off by default, let an agent find and read
artifacts: search_artifacts and get_artifact. Enable them on the Tools
page or list them in a flow's allowed_mcp_tools.
{
"name": "search_artifacts",
"arguments": {
"kind": ["transcript"],
"labels": {"site": "heilbronn"},
"since": "2026-10-04T08:00:00+00:00",
"until": "2026-10-04T09:00:00+00:00"
}
}
qmatches the artifact's extracted text (web search syntax) or its name.kindis a list;labelsmust all match; a label with an empty value matches any.sinceis inclusive anduntilexclusive, both oncreated_at, ISO 8601 with an offset. A scheduled flow can pass its owntrigger_event.payload.window.from/.to.limitis at most 50; passnext_cursorback ascursorfor the next page.
The answer is structuredContent.items (each artifact's descriptor plus
excerpt, a short fragment of its text with matches in **bold**), the same
JSON as the first text block (MCP clients that show only text, such as
OpenCode, read that), then one resource_link block per artifact.
get_artifact {artifact_id, max_bytes?} returns the descriptor as a leading
text block, then the content: an
EmbeddedResource with text for text kinds (the first max_bytes, default
64 KiB, with _meta["preloop.dev/artifact"].truncated set when cut), inline
bytes for binaries up to 1 MiB, and a resource_link beyond that.
Scope. By default both tools see only artifacts of sessions run by the
calling agent identity, across all its runs. For a flow the identity is the
flow: a run sees the artifacts of every run of the same flow, not of other
flows. scope: "account" reads every
artifact of the account and needs the artifact_search.account_scope grant
for that agent (granted through the Enterprise Edition governance settings).
Without the grant the call is refused with account_scope_not_granted, naming
the grant; it is never quietly narrowed. get_artifact answers an id outside
the caller's scope with artifact_not_found. Every call, answered or refused,
is written to the audit log with the agent as the actor.
Where artifacts appear¶
- Session timeline. Every deposit writes an
artifactrow on the session timeline, in time order between the model turns and tool calls around it. The console renders these rows with a kind icon, the name, labels, a text excerpt or thumbnail, and a header count with a filter per kind (#1083). A session without artifacts says so in the header and links to this page. - Activity API.
GET /api/v1/runtime-sessions/{id}/activityreturns the sameartifactrows with an artifact summary inmetadata.artifact:id,kind,name,content_type,size_bytes,labelsandproducer. The full descriptor (sha256,availability,legal_hold,parent_artifact_idand so on) comes fromGET /api/v1/runtime-sessions/{id}/artifacts. - Artifacts page. Audit > Artifacts (
/console/artifacts) lists the artifacts of every session with search, kind, site, label, agent, tool, date and legal hold filters, and a gallery for screenshots. Filters live in the URL, so a filtered view can be shared. A row opens its session on the artifact's timeline row. - Storage card. Settings > Account shows the session artifact storage used per kind against the account budget. Browse artifacts and each kind's name open the Artifacts page, filtered to that kind.
- Account search.
GET /api/v1/artifactssearches every session of the account; see Searching across sessions.
Browser step screenshots are artifacts of kind screenshot too; see
Browser steps for how the console shows them.
Searching across sessions¶
GET /api/v1/artifacts lists the account's artifacts from every session,
newest first. It needs a user with view_runtime_sessions. All parameters are optional and combine with AND:
| Parameter | Meaning |
|---|---|
q |
Full text over the artifact's indexed text (transcripts, documents, and the name, labels and tool of every kind), or a substring of the name. |
kind |
Repeatable; any of the listed kinds. |
label |
Repeatable key:value; every value must match (site:a&label=site:b matches nothing). tags:x matches one tag of the list. |
agent_id, tool_name, producer, runtime_session_id |
Exact match. |
from, to |
ISO 8601 on created_at; from inclusive, to exclusive. |
held |
true for artifacts under legal hold, false for the rest. |
availability |
available, evicted or expired. |
limit, cursor |
Same paging as the per-session list. |
Each item is the session list descriptor plus session_title and
agent_name. With q, a matching text chunk adds excerpt (text, already
redacted when it was indexed, and highlights, a list of [start, end)
character offsets of the hits) and, for transcripts, cue_start in seconds.
Results stay in time order when q is set, so a cursor stays valid while new
artifacts arrive.
facets.kind and facets.site count kinds and labels.site values over the
whole filter (not one page). Past 10000 matching rows the counts cover the
newest 10000 and facets_truncated is true.
curl -s -H "Authorization: Bearer $PRELOOP_TOKEN" \
"$PRELOOP_URL/api/v1/artifacts?q=damaged%20pallet&kind=transcript&label=site:nord&from=2026-09-27T00:00:00Z"
Retention, legal hold, budget and eviction¶
- Budget. Each account has one plaintext budget for all session artifacts,
RUNTIME_SESSION_ARTIFACT_ACCOUNT_MAX_BYTES(5 GiB by default; no per-account override yet). A deposit that does not fit evicts the oldest unheld artifacts first: recordings, then the other media kinds (screenshots, screencasts, audio, generated files, traces), then transcripts and documents last, because they are small and are what a reviewer searches. An evicted artifact keeps its row, name, labels and sha256; only the bytes go, andavailabilitybecomesevicted. When even eviction cannot make room the deposit is refused withstorage_budget_exhausted. - Screenshots per session. Browser step screenshots are also bounded per
session (
RUNTIME_SESSION_SCREENSHOTS_PER_SESSION_MAX, 500). - Legal hold. An artifact copies the legal hold flag of its session when it
is stored, and
legal_holdis in the descriptor. Held artifacts are never evicted and never purged. - Retention. Artifacts belong to their session. When the retention purge
(off by default,
RETENTION_PURGE_ENABLED) removes a session, its unheld artifacts go with it. Bytes removed by a retention policy answer 410 withavailability: expired.
Audio is off by default¶
Audio of people is personal data in most jurisdictions. Every audio deposit,
and any deposit whose media type is audio/* whatever its declared kind, is
refused with 409 artifact_audio_storage_disabled unless an account admin
opts in. Transcripts are stored either way: store the transcript with a
consent_basis label.
To opt in, open Settings > Account > Session artifact storage and turn on
Store raw audio, or call
PUT /api/v1/account/session-artifacts/settings with
{"audio_storage_enabled": true}. The change needs the manage_policies
permission and writes an artifact_settings_updated audit row with who
changed which field from what to what, and when. A request that changes
nothing writes no row.
audio_retention_days (default 30, at most the runtime-session retention)
bounds how long raw audio is kept. The artifact janitor expires older audio:
its bytes are dropped, the row stays, and the byte route answers
410 {"availability": "expired"}. Audio under a legal hold is kept.
Standards mapping¶
Preloop does not invent a content shape. Ingest and tool results are MCP
ContentBlocks; export maps to A2A and OpenTelemetry GenAI. Preloop fields
that a standard has no slot for travel in its extension point. The mapping
code is backend/preloop/services/artifact_shapes.py.
| Preloop | MCP ContentBlock (spec 2026-07-28) |
A2A v1.0.1 | OTel GenAI parts (Development) |
|---|---|---|---|
| text content | TextContent {type:"text", text} |
Part {text} |
BlobPart with modality: document |
| image bytes | ImageContent {type:"image", data, mimeType} |
Part {raw, filename, media_type} |
BlobPart {type:"blob", mime_type, modality:"image", content} |
| audio bytes | AudioContent {type:"audio", data, mimeType} |
Part {raw, filename, media_type} |
BlobPart with modality: audio |
| any file bytes | EmbeddedResource {type:"resource", resource:{uri, mimeType, text or blob}} |
Part {raw, filename, media_type} |
BlobPart |
| stored artifact by reference | ResourceLink {type:"resource_link", uri, name, mimeType, size} |
Part {url, filename, media_type} |
UriPart {type:"uri", mime_type, modality, uri} (preferred for export) |
| JSON segments | TextContent or EmbeddedResource with application/json |
Part {data} |
BlobPart with modality: document |
| artifact envelope | one block | Artifact {artifact_id, name, parts[], metadata} |
one part |
artifact_id, kind, labels, sha256, producer |
_meta["preloop.dev/artifact"] |
metadata["preloop.dev/artifact"] |
span attributes preloop.artifact.kind, preloop.artifact.labels |
Kind to OTel modality: screenshot is image; recording and screencast
are video; audio is audio; transcript, document, generated_file
and trace are document. Converting an artifact to any of the three shapes
and back keeps its media type, byte sha256, name, kind and labels.
Error codes¶
The REST API returns {"detail": "<code>"}; the MCP tool returns a tool error
whose text starts with the code.
| HTTP | Code | Meaning |
|---|---|---|
| 400 | invalid_content_length |
The Content-Length header is not a number. |
| 401 | (authentication error) | No bearer, or an unknown one. |
| 403 | runtime_session_binding_mismatch |
The key is bound to a different session than the path names. |
| 404 | runtime_session_not_found |
The session is not in the caller's account. |
| 409 | artifact_audio_storage_disabled |
Audio deposits are off for the account. |
| 413 | artifact_too_large |
Larger than the kind cap, or the body is over the request limit. |
| 415 | artifact_media_type_invalid |
The media type is not allowed for the kind. |
| 415 | artifact_content_mismatch |
The bytes are not the declared type, or a generated_file is an executable. |
| 422 | artifact_kind_invalid |
Unknown kind. |
| 422 | artifact_labels_invalid |
A label breaks the rules above. |
| 422 | artifact_name_invalid |
Missing or too long name (1 to 255 characters). |
| 422 | artifact_content_required |
No bytes: a multipart call without file, or a REST resource_link. |
| 422 | artifact_block_type_unsupported |
The content block type is not one of the accepted MCP types. |
| 422 | artifact_block_invalid_base64 |
data or blob is not valid base64. |
| 422 | artifact_request_invalid |
The JSON or the metadata part does not match the schema. |
| 422 | artifact_activity_invalid |
activity_id is not a row of this session. |
| 422 | artifact_parent_invalid |
parent_artifact_id is not an artifact of this account. |
| 422 | artifact_idempotency_key_invalid |
The Idempotency-Key header is empty or too long. |
| 422 | artifact_label_filter_invalid, artifact_cursor_invalid, artifact_limit_invalid |
Bad list query. |
| 422 | artifact_availability_invalid, artifact_date_range_invalid, artifact_query_too_long |
Bad search query (from not before to, q over 500 characters). |
| 422 | artifact_agent_id_invalid, artifact_runtime_session_id_invalid |
A search id filter is not a UUID. |
| 507 | storage_budget_exhausted |
The account budget cannot fit the artifact even after eviction. |
| (MCP) | artifact_no_session |
The MCP credential is not bound to a runtime session. |
| (MCP) | artifact_link_outside_session |
A resource_link names an artifact of another session. |
| (MCP) 410 | artifact_unavailable |
A resource_link names an artifact whose bytes were evicted or deleted. |