Skip to content

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.

{
"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.

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.

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.

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.

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.

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 a url_matches criterion and the last URL contains its value: 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 the url_matches value or because the mission has no url_matches criterion. 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.

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).

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.

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).

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).

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.

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.

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.

Leave it out: the API assigns one.

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