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, image/gif |
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
audio transcription agent preset shows
the whole loop. 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. |
Recordings synchronized to the timeline¶
A finished recording or screencast can carry media_timing, separate from
labels, in JSON or multipart deposit metadata and in the deposit_artifact
MCP tool. Existing clients can omit it. For example:
{
"kind": "recording",
"name": "capture.webm",
"media_timing": {
"started_at": "2026-10-01T10:00:00Z",
"duration_ms": 10000,
"clock": "agent",
"step_offsets": [
{"source_step_id": "run-1:4", "offset_ms": 6000}
]
}
}
started_at is the timezone-qualified wall clock at video t=0, not the
upload time. clock is agent or server. Duration is optional. Exact step
offsets are nonnegative integer milliseconds, with at most 5000 unique
source_step_id entries. Timing on another artifact kind is rejected with
422. Offsets beyond the declared or decoded duration remain unmapped; they
never clamp to the beginning or end of the recording.
A producer that learns timing later can send that timing object as the body
of PATCH /api/v1/runtime-sessions/{id}/artifacts/{artifact_id}/media-timing.
It requires a credential bound to that session or manage_agents. Identical
retries are no-ops; changes atomically append artifact.timing.updated to the
audit trail. Updating timing leaves the content hash, retention and legal
hold unchanged.
GET /api/v1/runtime-sessions/{id}/media-sync has the artifact read permission
and account boundary. It returns one entry per recording with artifact_id,
skew_ms, mappings and unmapped_activity_ids. Each mapping names its
activity_id, offset_ms, rule and approximate status. Model-call mappings
also include api_usage_id for correlation with gateway turns. The server
resolves these in this order:
- Exact producer step offset.
- Step
occurred_atminus recordingstarted_at, when both use the agent clock. - Server receipt/model/tool time minus recording start, subtracting the median
recorded_at - occurred_atwhen converting to an agent clock.
Browser-step batches receive a server-set recorded_at. Historical rows get
it from their database creation time; missing historical producer times are
not invented. With fewer than three paired step times, skew correction uses
zero and the agent-clock server-time mapping is marked approximate. Server
clock recordings do not subtract producer skew. Negative offsets, including
clock jumps, remain unmapped. The signed period evidence export includes the
same resolver's output in artifacts/media-sync.json and includes
media_timing in each artifact descriptor.
In Sessions, a sticky, collapsible player appears above the conversation.
Choose a recording by name, producer and start time. Play from here seeks
the selected recording from a browser step, model turn or identified tool
call. [ and ] move to the previous/next mapped browser step, except while
typing. Playback highlights the current step and the step strip, and follows
the timeline only after five seconds without user scrolling. Multiple
recordings keep independent mappings; overlapping captures are not merged.
The console's syncedRecordingPlayer feature option defaults to on and can be
disabled by an embedding host during rollout.
Recordings without timing remain plain players with This recording has no timing data. Unmapped steps say Not in this recording; an attempted out-of-range seek says No recording for this moment. Finished artifacts can appear while a session is still live; this is not live streaming. Evicted or expired recordings retain their existing unavailability marker.
GIF screencasts use a plain image fallback in this player, with Seek not supported for GIF in this browser and no seek controls. Their exact timing metadata is still stored and exported. Use MP4/WebM when interactive seeking is required. Content is fetched only when requested, through the existing authorized artifact route.
ffmpeg capture recipe¶
For an X11 display on a local Linux host, record the producer wall clock at
capture launch and deposit the completed file. The clock must be the same as
the occurred_at values reported by the browser-step producer. This recipe
captures no audio. It does not start managed capture or connect to a remote VM.
export PRELOOP_DISABLE_TELEMETRY=true
python - <<'PY'
import datetime
import json
import subprocess
started = datetime.datetime.now(datetime.timezone.utc).isoformat()
subprocess.run([
"ffmpeg", "-y", "-use_wallclock_as_timestamps", "1",
"-f", "x11grab", "-framerate", "10", "-i", ":99",
"-t", "10", "-an", "-c:v", "libvpx-vp9", "capture.webm",
], check=True)
with open("capture-metadata.json", "w") as stream:
json.dump({"kind": "recording", "name": "capture.webm", "media_timing": {
"started_at": started, "clock": "agent"
}}, stream)
PY
curl --fail --request POST \
"$PRELOOP_URL/api/v1/runtime-sessions/$PRELOOP_RUNTIME_SESSION_ID/artifacts" \
--header "Authorization: Bearer $PRELOOP_AGENT_KEY" \
--header 'Idempotency-Key: capture-example-1' \
--form 'file=@capture.webm;type=video/webm' \
--form 'metadata=<capture-metadata.json'
Measure capture startup delay against a visible local action before relying
on subsecond accuracy. For a slow ffmpeg startup, instrument the first frame
and use that wall time as started_at, then PATCH the timing. No VM accuracy
claim follows from the fixture tests. Captured pixels may contain secrets,
OTPs or other windows: text redaction does not redact the screen. Choose the
capture region and retention policy accordingly.
Optional scoped readers¶
An installed access-control plugin can narrow artifact reads with exact label and kind allow-lists. Core applies these restrictions before pagination and facets, on direct reads, and on artifact text in the session-search corpus, so excluded artifacts do not appear in search and return the same not-found response as missing IDs. Existing own-session and account boundaries still apply. Without an installed policy, access is unchanged. Grant management and account enablement belong to the plugin.
The internal resolver returns None only for an unassigned subject, an empty
list to deny all, or a list of intersecting scopes. Each scope must have both
nonempty labels and kinds, for example
{"labels": {"site": ["north"]}, "kinds": ["transcript"]}. Label keys intersect;
values within one key are alternatives. Values must be exact, case-sensitive
scalar strings. Array labels such as tags, wildcards, kind-only and label-only
scopes are not supported. Malformed scopes deny all.