Browser steps¶
Editions: OSS, Cloud, Enterprise. Unless stated otherwise, everything on this page ships in OSS.
An agent that drives a browser can attach what it did to its runtime session. Each step is an observation: the action the agent reports, the URL or target it names, and the reasoning it gives. A stored step is not an approval, a dispatch, or proof that the browser reached that state.
A step may carry a screenshot. It is stored encrypted as a
session artifact of kind screenshot. The console fetches it
from the artifact byte route and shows it as a thumbnail on the step row (see
In the console). A step without one has
screenshot: null in its stored metadata and renders as a row without a
thumbnail.
Sending steps¶
Authenticate with the agent key, the same bearer the model gateway accepts. Post one batch of 1 to 200 steps:
curl -X POST \
"https://preloop.example.com/api/v1/runtime-sessions/$SESSION_ID/browser-steps" \
-H "Authorization: Bearer $AGENT_KEY" \
-H "Content-Type: application/json" \
-d '{
"steps": [
{
"source": "playwright_mcp",
"source_step_id": "step-1",
"step_index": 0,
"action": "navigate",
"url": "https://app.example.com/inbox",
"reasoning": "Open the inbox the task named.",
"status": "success"
}
]
}'
source is api (the default), browser_use, skyvern, or
playwright_mcp. source_step_id is the idempotency key together with
the session and source: posting the same key again returns the original
row and counts it as a duplicate. A batch may mix new steps, duplicates,
and rows that are refused individually.
The response is:
rejected entries are {"index": 0, "error": "extra_too_large"}. extra
is refused when json.dumps(extra) is larger than 4096 bytes
(extra_too_large) or cannot be encoded as JSON (extra_not_json). A
screenshot is refused as screenshot_too_large, screenshot_invalid or
storage_budget_exhausted (see below). The other rows in the batch are
still stored. More than 200 steps, or an
empty batch, is a 422 for the whole request.
Finding the session id¶
You do not need to mint a session token to learn $SESSION_ID. Pick a
session key for the run, send it to the model gateway as
X-Preloop-Session-Id, and resolve the same key once:
curl -G "https://preloop.example.com/api/v1/runtime-sessions/resolve" \
-H "Authorization: Bearer $AGENT_KEY" \
--data-urlencode "client_session_id=$PRELOOP_SESSION_ID" \
-d create=true
# {"runtime_session_id": "...", "created": true}
create=true creates the session exactly as the gateway's first request
would, so the steps and the model turns share one session whichever comes
first. Without it, an unknown key is a 404, as is a key from another account.
Every gateway response for a session-attributed request also carries the id
as X-Preloop-Runtime-Session-Id. In Browser Use,
PRELOOP_RUNTIME_SESSION_ID, when set, takes precedence over resolving
PRELOOP_SESSION_ID. In Skyvern, pass --session to supply the runtime
session id directly instead of using --client-session-id. Resolve
authenticates exactly like the model gateway, so a key pinned to a session
that has ended is refused there.
Step post errors¶
A missing or unknown bearer is 401. A session that belongs to another account is 404. When the key is pinned to a runtime session and the path names a different one, the response is 403. A session that has already ended is accepted, including a key pinned to that session, so an adapter can flush after the run. The model gateway still rejects that key for inference.
Browser Use¶
The preloop-browser-use package in
runtime-plugins/browser-use-preloop
is a Browser Use on_step_end callback. It posts each step with its
screenshot as source: browser_use, in batches, and never stops the agent
when Preloop is unreachable:
from preloop_browser_use import PreloopBrowserUseReporter
# PRELOOP_URL, PRELOOP_API_KEY and PRELOOP_SESSION_ID (the key your LLM
# client sends the gateway as X-Preloop-Session-Id) are enough.
reporter = PreloopBrowserUseReporter.from_env()
history = await reporter.run(agent, gif=True)
Install preloop-browser-use[recording] to enable the optional GIF export.
run(agent) keeps the existing steps-only behavior; run(agent, gif=True) or
finish(agent) deposits a completed timestamped GIF as a screencast.
finish(agent, gif=False) only flushes steps. For a completed MP4/WebM, use
finish(agent, recording_path="capture.webm", media_timing=timing) with its
actual capture timebase. Upload failures are logged and never fail the run.
The adapter does not guess timing for arbitrary external files.
The stock Browser Use GIF has fixed frame durations, so the optional exporter uses per-step timestamps to preserve elapsed time instead. It adds no task or reasoning text overlays. Missing timing produces a plain GIF without synchronization metadata. Excessive decoded image size can omit the GIF while keeping the reported steps.
Setup, the field mapping and the supported versions are in its README.
Skyvern¶
The preloop-skyvern package in
runtime-plugins/skyvern-preloop
imports a finished Skyvern task: each step becomes a browser step with
source: skyvern and its screenshot, the HAR and Playwright trace become
trace artifacts and the video a recording artifact. Run it once per
task, or from Skyvern's task webhook:
preloop-skyvern-import --task tsk_123 --client-session-id "$PRELOOP_SESSION_ID"
# or, with a known runtime session id:
preloop-skyvern-import --task tsk_123 --session "$SESSION_ID"
Re-importing the same task stores nothing new. Webhook setup and the field mapping are in its README.
Skyvern recordings carry media_timing with the task creation time (or first
step creation time) as t=0 and exact offsets from step creation timestamps.
Missing timestamps leave that recording untimed.
Screenshots¶
Add a screenshot object to a step:
{
"source": "playwright_mcp",
"source_step_id": "step-2",
"step_index": 1,
"action": "screenshot",
"screenshot": {
"content_type": "image/png",
"data_base64": "iVBORw0KGgo..."
}
}
content_type is image/png, image/jpeg or image/webp. The row is
refused, and nothing is stored for it, when:
- the decoded image is larger than
RUNTIME_SESSION_SCREENSHOT_MAX_BYTES(2 MiB by default):screenshot_too_large; data_base64is not valid base64, is empty, or the bytes are not the declared image type:screenshot_invalid;- the account's session-artifact budget
(
RUNTIME_SESSION_ARTIFACT_ACCOUNT_MAX_BYTES) cannot fit the image even after evicting older unheld artifacts:storage_budget_exhausted.
A repeated step (same source_step_id) is a duplicate and does not store
a second image.
The stored step's metadata.screenshot names the artifact:
{
"artifact_id": "7d0c...",
"availability": "available",
"content_type": "image/png",
"size_bytes": 48213
}
Each session keeps at most RUNTIME_SESSION_SCREENSHOTS_PER_SESSION_MAX
(500 by default) available screenshots. Past that, the oldest by step
time lose their image bytes. The artifact row and the step's metadata
stay, and availability becomes evicted. Screenshots in a session under
legal hold are never evicted, so a held session can keep more than the
bound.
Reading a screenshot¶
A console user with the view_runtime_sessions permission reads the bytes
with:
The response is the image with its stored media type and
Cache-Control: private, max-age=300. It is 404 when the session or the
artifact is not in the caller's account, or the artifact belongs to
another session, and 410 with {"availability": "evicted"} or
{"availability": "expired"} when the bytes are gone.
Playwright MCP through the firewall¶
An agent that drives a browser through a Playwright MCP server
(@playwright/mcp) registered in Preloop needs no adapter. When the MCP
firewall proxies one of its browser_* tools on a runtime session, the
call is recorded as a tool_call activity as before and, in addition, as
a browser_step with source: "playwright_mcp". The step's
source_step_id is the tool call's correlation id, so the two rows join
and a repeated derivation is a duplicate rather than a second step.
step_index continues from the highest index already stored on the
session, whichever source wrote it.
The mapping follows @playwright/mcp@0.0.82, the package pinned in the
browser environment profile:
| Tool | Action | Copied into the step |
|---|---|---|
browser_navigate, browser_navigate_back |
navigate |
url |
browser_click |
click |
element (or ref) as target |
browser_type |
type |
element/ref as target; extra.typed_chars |
browser_press_key |
type |
nothing |
browser_select_option |
select |
element/ref as target |
browser_hover |
other |
element/ref as target |
browser_take_screenshot |
screenshot |
the returned image, as the screenshot |
browser_snapshot |
extract |
nothing |
browser_wait_for |
wait |
nothing |
browser_close |
done |
nothing |
extra.tool names the tool that was called. The typed text, the pressed
key and the selected values are never stored; browser_type records
only the length of what was typed. A call the firewall refused (policy,
approval, kill switch) never reached the browser and derives no step. A
failed call derives a step with status: "failed".
The image browser_take_screenshot returns is stored as the step's
screenshot under the same size, type and budget rules as an image posted
to the API. An image the rules refuse is dropped and the step is kept.
What the agent receives from the tool does not change.
Playwright MCP decides whether a tool result carries an image at all. Started
with --image-responses omit, it returns no ImageContent, so the firewall
has nothing to store: every derived step, including browser_take_screenshot,
is recorded with screenshot: null and the console shows the step without a
thumbnail and without a strip image. Keep the default (allow) when you want
screenshots in Preloop.
Set MCP_PLAYWRIGHT_DERIVE_BROWSER_STEPS=false to record those calls as
plain tool_call rows only. Other browser MCP servers are not derived;
post their steps through the API above.
In the console¶
Open the session in Sessions. Every browser step renders inline in the Conversation and Requests views, in time order between the model turns and tool calls around it: an action icon, the action and URL, the target, the step index and the agent's reasoning (collapsed). A step with a screenshot shows a thumbnail; click it for the full-size viewer, where the arrow keys page through the session's steps and Escape closes it.
When a session has browser steps, a strip above the timeline lists the most
recent 200 entries; a +N button folds the older ones behind it. Clicking
that button reveals the previous 200 at a time, so a long session stays
responsive while every step remains reachable. Click an entry to scroll the
timeline to that step.
The console shows exactly what was stored: steps with screenshot: null
(for example from --image-responses omit, or a refused image) have no
thumbnail. It does not show the image the agent saw if that image was never
stored.
A screenshot that was evicted by a storage bound or expired under the retention policy shows a grey placeholder with the reason and a link to the session artifact storage card under Settings > Account. The step itself stays.
For finished videos, the docked player synchronizes with browser steps,
model calls and tool calls. Choose a recording, click Play from here, or
use [ / ] to navigate mapped steps. Playback highlights the step strip;
recent manual scrolling pauses automatic timeline following for five seconds.
GIFs display with a seek-unsupported notice; MP4/WebM support seeking.
See Recording timing and the ffmpeg recipe
for clock correction, absent timing, multiple captures and the deposit schema.
What is stored¶
Each accepted step is a browser_step activity on the session. It shows
up on the activity timeline next to tool calls, ordered by timestamp, and
its reasoning is searchable with session search. URL query secrets, and
credential-shaped text in the target and reasoning, are masked before the
row is stored.