Skip to content

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"
  }
}
  • q matches the artifact's extracted text (web search syntax) or its name.
  • kind is a list; labels must all match; a label with an empty value matches any.
  • since is inclusive and until exclusive, both on created_at, ISO 8601 with an offset. A scheduled flow can pass its own trigger_event.payload.window.from / .to.
  • limit is at most 50; pass next_cursor back as cursor for 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 artifact row 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}/activity returns the same artifact rows with an artifact summary in metadata.artifact: id, kind, name, content_type, size_bytes, labels and producer. The full descriptor (sha256, availability, legal_hold, parent_artifact_id and so on) comes from GET /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/artifacts searches 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"
  • 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, and availability becomes evicted. When even eviction cannot make room the deposit is refused with storage_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_hold is 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 with availability: 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.