The run spec
A run spec is the JSON body of create_run and
validate_run. A run is the target, times the personas, times the missions,
within a budget: one session per persona per mission. Two personas on one mission is two sessions.
Four fields are required: target, personas, missions and budget. Everything else is
optional. A field the spec does not know is refused, so a typo is a 422, never a field quietly
ignored. Check every spec with POST /api/v1/runs/validate before you start it: it runs every check
a start runs, costs nothing and starts nothing.
A full example
Section titled “A full example”{ "target": { "entry_url": "https://staging.example.com", "name": "Example shop, staging", "auth": { "kind": "basic", "secret_ref": "<secret_ref of the staging door>" }, "seed": { "accounts": ["<secret_ref of test account 1>", "<secret_ref of test account 2>"], "reset_hook": "https://staging.example.com/__test/reset", "feature_flags": { "new_checkout": true } } }, "personas": ["dana_first_timer@1", "marcus_fast_skimmer@1"], "missions": [ { "id": "buy_blue_shirt", "name": "Buy a shirt", "goal": "buy one of the blue shirts in your size", "brief": "You have an account here already. You want the shirt delivered this week.", "entry_url": "https://staging.example.com/shop", "success_criteria": [ { "kind": "url_matches", "value": "/order/confirmed" }, { "kind": "text_visible", "value": "Thank you for your order" } ], "watch_list": ["is the delivery date clear before paying"], "verdict_prompt": "Would you buy from this shop again?" } ], "budget": { "steps_per_session": 40, "wall_clock_s_per_session": 600, "max_concurrent_sessions": 2, "total_cost_usd": 5 }, "actor": { "observation_mode": "vision", "screenshot_policy": "on_look", "record_video": true }, "trigger": { "kind": "ci", "actor": "github-actions", "pr": 128, "commit": "9f2c1e7" }, "connectors": { "github": { "repo": "example-org/example-shop", "create_issues": true, "pr_comment": true, "min_severity_for_issue": "high" } }, "providers": { "entries": { "main": { "model": "anthropic:claude-opus-5", "credential_ref": "<secret_ref of your Anthropic key>" } } }}This plans two sessions: Dana and Marcus each try to buy a shirt, each signed in with their own test
account, at most two at a time. Validated with this organisation’s own refs in place of the
placeholders, it answered 200 with warnings: [] and these two sessions:
[ { "session_id": "<run_id>.sess_01_dana_first_timer", "persona_id": "dana_first_timer@1", "mission_id": "buy_blue_shirt", "entry_url": "https://staging.example.com/shop?saram_sid=%3Crun_id%3E.sess_01_dana_first_timer", "account_secret_ref": "<the first account>" }, { "session_id": "<run_id>.sess_02_marcus_fast_skimmer", "persona_id": "marcus_fast_skimmer@1", "mission_id": "buy_blue_shirt", "entry_url": "https://staging.example.com/shop?saram_sid=%3Crun_id%3E.sess_02_marcus_fast_skimmer", "account_secret_ref": "<the second account>" }]Each session’s entry URL carries ?saram_sid=<session id>, so your app can keep the sessions’ data
apart. The sections below take each field in turn.
target (required)
Section titled “target (required)”Where the personas go.
| Field | |
|---|---|
entry_url |
Required. Where every session starts, unless a mission names its own. Saram’s browsers run in Saram’s cloud, so it must be reachable from the internet: a spec naming localhost or a private address is refused. Testing localhost and previews has the way round. |
name |
A label for people. |
auth |
The door in front of the whole deployment (below). |
seed |
Test accounts, the reset hook and feature flags (below). |
Logins: target.auth and target.seed.accounts
Section titled “Logins: target.auth and target.seed.accounts”There are two kinds of login, and they go in two places:
- The target’s door goes in
target.auth: HTTP basic auth on a staging host, a bypass header on a protected preview, a cookie every session shares. It is one credential for the whole run and nothing a persona would be shown. - The persona’s own account goes in
target.seed.accounts: the email and password a persona signs in with through your product’s own sign-in form.
Both are credentials of kind account that you store first with
create_credential. Its value is one JSON object with any of
email, password, inbox, notes, cookies, basic and headers. The spec names the
secret_ref the answer gives, never the login itself.
auth.kind is one of none, basic, cookie, header or login_flow. It describes the door for a
reader and selects nothing: Saram applies whatever the stored entry carries (basic as HTTP basic
auth, headers on every request, cookies on the browser). login_flow is not a door: it says the
persona signs in through a form, with auth.login_url saying where the form is, and the account comes
from seed.accounts.
seed.accounts is a list of refs, handed out one per session in session order. A session past the
end of the list gets no account, and its persona is told it has no login. With two personas and one
mission, two accounts give every session its own.
A protected preview, through a door shows a door for Vercel and Netlify, step by step.
target.seed
Section titled “target.seed”| Field | |
|---|---|
accounts |
Test account refs, one per session (above). |
reset_hook |
A URL Saram POSTs to reset your app’s state: once per run with {"feature_flags": ...}, before each session with that session’s session_id added, and before each reproduction replay. It sits behind the same door, so the POST carries the door’s basic and headers. Any answer but 2xx fails the reset. |
feature_flags |
Names and booleans, passed to the reset hook. |
personas (required)
Section titled “personas (required)”At least one persona, each named by its registry address id@version, such as
dana_first_timer@1. list_personas lists them and
get_persona shows one in full.
missions (required)
Section titled “missions (required)”At least one mission: what each persona is sent to do, in the persona’s own terms.
| Field | |
|---|---|
id |
Required. Lowercase letters, digits and _. Appears in session ids and results. |
goal |
Required. The task as a person would say it: “buy one of the blue shirts”. |
success_criteria |
Required, at least one: what reaching the goal looks like. Each is a kind and a value. Only url_matches is checked by machine: when the persona says it is finished, the session ends with end_reason success_criteria_met if the last URL the browser was on contains the value, and claimed_finished_unverified if it does not, or if the mission has no url_matches criterion. The other four kinds are text for the judge (below). The persona is never shown any of them. |
name |
A label for people. |
entry_url |
Where this mission starts, in place of target.entry_url. |
brief |
What the persona was told before starting. |
watch_list |
Things the judge should look out for, such as “is the new discount widget discoverable”. The persona itself is never asked to critique the product. |
verdict_prompt |
The question about this product the persona answers at the end of its session. Set it when the personas were written for another kind of product; without one, the persona’s own question applies. |
authored_by |
human (the default), doppelganger or planner. |
Success criteria: what is checked
Section titled “Success criteria: what is checked”The persona is given the goal and the brief, never the criteria, and decides for itself when it
is finished. What happens then depends on the criterion’s kind:
kind |
value |
Checked |
|---|---|---|
url_matches |
Part of a URL, such as /order/confirmed. |
By machine, when the persona says it is finished: does the last URL the browser was on contain it? |
text_visible |
Text the page shows once the goal is reached. | Not checked: text for the judge. |
element_visible |
An element on that page, in words or as a selector. | Not checked: text for the judge. |
network_call |
A request the product makes when the goal is reached, such as POST /api/orders. |
Not checked: text for the judge. |
judge_assertion |
A plain-language statement about how the session ended, such as “the persona reached a signed-in page”. | Not checked: text for the judge. |
The judge that reads each session afterwards is shown every criterion as text, <kind>=<value>,
beside the mission’s goal, and nothing decides whether one of the last four was met. A session the
persona says it finished then ends with one of two end_reasons, which
get_run answers for each session:
success_criteria_met: the mission has aurl_matchescriterion and the last URL contains itsvalue: the ending rests on the URL, not on the persona’s word.claimed_finished_unverified: the persona says it reached the goal and nothing confirmed it, either because the last URL does not contain theurl_matchesvalue or because the mission has nourl_matchescriterion. A mission with none always ends this way when the persona finishes.
A session can also end without the persona finishing, such as on patience_exhausted, with an
end_reason of its own. For a verified ending, give every mission a url_matches criterion beside
any of the others, as the full example above does.
budget (required)
Section titled “budget (required)”How far each session may go.
| Field | |
|---|---|
steps_per_session |
Required. The most actions one persona takes: a click, a keystroke, a scroll, a look. |
wall_clock_s_per_session |
The most seconds one session runs. |
tokens_per_session |
The most model tokens one session uses. |
max_concurrent_sessions |
How many sessions run at once. Your plan has its own limit, and a higher number here runs at the plan’s limit (validate warns). Keep it at 1 when the target is a tunnel to your machine. |
total_cost_usd |
A soft cap in dollars: the run pauses when it reaches it, and POST /api/v1/runs/{run_id}/resume releases it (signal_run). |
The step and wall-clock budgets also bound what a run can cost: cost_ceiling in the validate answer
is worked out from them (Usage and billing).
providers
Section titled “providers”Which model, on which provider, with which credential, every model call in the run uses. On the Free
and Pro plans every run needs one. Providers and your own keys has the whole block:
entries, default and roles.
How the persona sees the page. Every field has a default, so most specs leave actor out.
| Field | |
|---|---|
observation_mode |
vision (the default), tree, dom_diff or hybrid. |
screenshot_policy |
on_look (the default): the persona sees a screenshot only when it chooses to look at the page. every_step: a screenshot after every action is also kept in the session’s trace for the judge. The contract also lists decision_points and on_escalation. |
record_video |
true (the default) records each session as a video. |
actor_model, judge_model |
Model ids. A providers block, when present, wins over both. |
trigger
Section titled “trigger”Who or what started the run, recorded with it.
| Field | |
|---|---|
kind |
manual, api or ci. Left out, it becomes api over the MCP server. |
actor |
The user, token or CI job that started it. |
pr, commit |
The pull request number and the commit under test. |
diff_ref |
Where the pull request’s diff can be fetched. Accepted and recorded; nothing reads it yet. |
A run on a ChatGPT login must not be kind: ci: the login serves runs a person starts
(Providers and your own keys).
baseline_run_id
Section titled “baseline_run_id”A run to compare with, typically the last run on your main branch. Each issue then says whether it is
new, persisting or resolved against it (baseline_status).
connectors
Section titled “connectors”What Saram does with the results besides keeping them. Both connectors are part of the Pro plan: on a plan without automation, a spec naming either is refused when it is validated or started.
connectors.github
Section titled “connectors.github”| Field | |
|---|---|
repo |
The repository, owner/name. |
create_issues |
File the run’s issues on GitHub. Default false. |
pr_comment |
Comment on the pull request in trigger.pr. Default false. |
check_run |
Post a check run. Default false. |
min_severity_for_issue |
low, medium (the default), high or critical: the least severe issue that gets filed. |
token_ref |
A fallback. Saram writes as its GitHub App where the App is installed on repo, and only where it is not, with a token you stored as a credential of kind github. |
connectors.webhook
Section titled “connectors.webhook”One signed POST when the run has finished and its artifact is ready: a small JSON summary with the
run id, status, session and issue counts, and links. A run that is cancelled, or fails before it has
an artifact, sends nothing.
| Field | |
|---|---|
url |
Required. https only, on a public address. Redirects are not followed. |
secret_ref |
Required. The signing key, stored as a credential of kind webhook. |
The signature is the Saram-Signature: t=<unix seconds>,v1=<hex> header: HMAC-SHA256 over
<t>.<raw body> with your key. Verify it on the raw bytes you received, and refuse a timestamp
outside your own tolerance. A receiver that answers 5xx, 429 or nothing is retried a few times,
then given up on; the outcome is recorded on the run. connectors.webhook_url is an old field that is
accepted and never read.
Up to 10 files the personas bring with them, such as a receipt to upload or a spreadsheet to import.
Upload each one first with upload_file (25 MiB at most; images, PDF, CSV,
text, JSON, .docx and .xlsx), then name it here:
"files": [ { "ref": "saram-file://<your tenant id>/<file id>", "name": "receipt.pdf", "description": "The receipt for your last order.", "assign": { "personas": ["dana_first_timer@1"] } }]| Field | |
|---|---|
ref |
Required. The ref the upload answered. Only your own organisation’s files may be named. |
name |
Required. What the persona calls it, and the filename your app receives. Unique within the run. |
description |
Required. One sentence the persona is told about it. |
assign |
Which sessions carry it: personas, missions, or both. Left out, every session does. |
run_id
Section titled “run_id”Leave it out: the API assigns one.
What was checked
Section titled “What was checked”| Block | Checked |
|---|---|
| The full example | 2026-10-23, POST /api/v1/runs/validate on api.saram.sh (platform-api 0.9.0) with the organisation’s own refs for the door and the two accounts: 200, warnings: [], the two sessions shown. Its providers block was checked apart, as no Anthropic key was stored for this page: an entry naming a ref that is not the organisation’s is refused with a 422 |
| An inline persona | 2026-10-23, validate: 422, “only registry ids are supported” |
A missing budget.steps_per_session |
2026-10-23, validate: 422 naming the field |
A ChatGPT login with trigger.kind: ci |
2026-10-23, validate: 422 |
files |
Not run: no file was uploaded for this page. A ref to a file of another owner is refused with a 422 (2026-10-23) |
| Fields and defaults | The RunSpec schema in /openapi.json |
Success criteria and end_reason |
Not run: the RunSpecMission schema’s success_criteria in platform-api 0.11.0’s /openapi.json |