# Saram documentation > Reference for the Saram HTTP API and MCP server, readable by people and by agents. Saram runs synthetic personas through your product in a real browser. It reports the defects that reproduce, and what the personas thought, with the evidence behind each. - [Connect your coding agent](/connect-agent/) - [HTTP API reference](/api/) - [MCP tools](/mcp/tools/) - [Docs for agents](/agents/) ## What is here - **[Quickstart](/quickstart/)**: from a dashboard API key to a first run over HTTP, and reading its issues, verdicts and session video. - **[Connect your coding agent](/connect-agent/)**: Claude Code, Cursor or Codex on the Saram MCP server, by signing in or with an API key, then a first run from validating its spec to reading what it found. - **Guides**: [the run spec](/run-spec/), field by field; [issues vs persona feedback](/issues-and-feedback/), and what to file as a ticket; [providers and your own keys](/providers/), for who bills the model calls; and [usage and billing](/billing/), for the plans, the balance and the usage report. - **[HTTP API reference](/api/)**: every public operation of the Saram API at `https://api.saram.sh`, with request and response schemas and a client to try requests. Requests authenticate with `Authorization: Bearer `, where the credential is a Saram API key (`sk_saram_...`) or a signed-in person's access token. - **[MCP tools](/mcp/tools/)**: the tools of the Saram MCP server at `https://api.saram.sh/mcp`, what the server tells a connecting agent, and each tool's arguments. - **[Docs for agents](/agents/)**: every page here as Markdown, and the `llms.txt` index. ## What a run gives you A run produces two kinds of output, and they are not interchangeable: - **Issues** are defects, gated on reproduction. An issue that reproduced without the persona layer is safe to act on by itself. - **Verdicts and opportunities** are persona feedback: subjective, linked to evidence, and scored by agreement across personas. They are advisory, and are attributed to the persona rather than stated as fact. --- # Docs for agents > How an agent reads these docs - llms.txt, a Markdown copy of every page and operation, and the raw API and MCP descriptions. Everything on this site is also published as plain Markdown, so an agent never has to parse HTML or run the reference's JavaScript. ## Start at llms.txt [`/llms.txt`](/llms.txt) is the index, in the [llms.txt](https://llmstxt.org/) format. It links: - the Markdown copy of every page on this site; - one Markdown page per HTTP API operation; - [`/llms-full.txt`](/llms-full.txt), which is all of the above in one file. ## Markdown copies | Page | Markdown | | --- | --- | | A page such as `/agents/` | `/agents.md` | | The home page | [`/index.md`](/index.md) | | The whole HTTP API reference | [`/api/index.md`](/api/index.md) | | One operation | `/api/.md`, for example [`/api/who_am_i.md`](/api/who_am_i.md) | | The MCP tools | [`/mcp/tools.md`](/mcp/tools.md) | A request for a page's own address that asks for Markdown gets the Markdown copy, so an agent does not need to know the `.md` path: ```sh curl -H 'Accept: text/markdown' https://docs.saram.sh/agents/ ``` In the [HTTP API reference](/api/), each operation also has a **Copy as Markdown** button. ## Machine-readable descriptions - [`/openapi.json`](/openapi.json) is the OpenAPI description the reference is built from. - [`/mcp-tools.json`](/mcp-tools.json) lists the MCP server's tools with their input and output schemas. Both are the exact files of the published API release this site was built from. ## Connect over MCP The Saram MCP server is at `https://api.saram.sh/mcp`. [Connect your coding agent](/connect-agent/) has the exact setup for Claude Code, Cursor and Codex, and the [tools page](/mcp/tools/) quotes the instructions the server gives a connecting agent. :::caution[Starting a run is billable] A run takes minutes: a cohort of personas drives real browsers through the product, with real model calls. Read the [server's instructions](/mcp/tools/#server-instructions) before starting one, and check a spec with `validate_spec` first, which is free and starts nothing. ::: --- # Usage and billing > How Saram bills a run - persona steps weighed as Usage, the Free and Pro plans, the prepaid balance, top-ups, auto-refill and the monthly budget - and how to read your usage over the API. Saram bills the steps your personas take. Everything else a run does is included: the browsers, the judge, and every reproduction replay. Model calls are on your own provider and are billed by them, not by Saram ([Providers and your own keys](/providers/)). You manage billing on the dashboard under **Settings > Billing**, and see what you used under **Settings > Usage**. Billing is for your organisation's owners and admins; over the API, a billing route needs a key with the `keys:admin` scope. ## The billed unit: steps, weighed as Usage A **step** is one action a persona takes: a click, a keystroke, a scroll, a look at the page. Only the personas' steps are billed. A reproduction replay's steps, and a [retest](/api/retest_issue.md)'s, never are. On the Free and Pro plans, each step is weighed by how long it took, and the result is a unit called **Usage**: ```text Usage of one step = 0.5 + 0.1 x (the step's seconds, counted up to 30) ``` A typical 5-second step is 1 Usage, and no step is more than 3.5, however long it stalls. A run's bill is its Usage times your plan's price per Usage. A session that fails on Saram's side is refunded in full. The usage report says whose failure ended each session: `none`, `saram`, `provider` or `target`. ## What each plan includes | | Free | Pro | | --- | --- | --- | | Monthly fee | $0 | $20, before tax | | Credit included each month | $1 | $10 | | Price per Usage | $0.02 | $0.016 | | Sessions of one run at the same time | 2 | 8 | | Evidence kept (videos, screenshots, step traces) | 14 days | 90 days | | People in the organisation | 2 | 10 | | Automation: GitHub issues and pull request comments, the run-finished webhook, the CI action | No | Yes | The figures are Saram's plan list on 2026-10-23. [`billing_overview`](/api/billing_overview.md) (`GET /api/v1/billing`) answers your own: `monthly_fee_usd`, `included_credit_usd`, `usage_price_usd` and `limits`. - **Retention.** Evidence is kept for its days counted from the run's creation. A run keeps the longer of what your plan kept when it ran and what it keeps now, and says the date as `evidence_expires_at`. The run's findings are kept after its evidence is gone. - **Automation.** On Free, a spec naming `connectors.github` or `connectors.webhook` is refused when it is validated or started, never run without it. The API and the MCP server are open on every plan. - **Upgrading and cancelling.** Upgrade on the dashboard. You are on Pro once the first invoice is paid. Cancelling ends Pro when the month already paid for ends, and the organisation then moves to Free; that month's included credit stays until it expires. Some organisations are on an older plan that bills each persona step at a fixed price, invoiced after the month. See [Reading your usage](#reading-your-usage) for how those read. ## The prepaid balance On Free and Pro you pay for Usage before you use it. The balance has two parts: - **Included** credit comes with your plan each month and **expires at the end of the month**. - **Purchased** credit comes from top-ups and **does not expire**. When a run starts, it **holds** the most it can cost: its ceiling, worked out from the spec's budgets. Each session that finishes is then charged what it actually used, and what is left of the hold goes back. So `GET /api/v1/billing` reads: | Field | | | --- | --- | | `included_usd`, `purchased_usd` | The two parts of the balance | | `balance_usd` | Both together | | `held_usd` | What runs in flight have reserved | | `available_usd` | What a new run can hold against: the balance less the holds | **A run starts only if `available_usd` covers its ceiling.** If it does not, `POST /api/v1/runs` is a `402` naming the ceiling and the balance, and no run starts. A run that has started has its ceiling held, so it finishes. Validate tells you before you try: its `usage_ceiling` answers the run's most Usage, its price, `available_usd`, whether it is `covered`, and `steps_per_session_that_fit`, the largest `budget.steps_per_session` your balance would cover. `enforced: false` there means the balance is not yet being enforced for your organisation. The ceiling is counted with every step at its most, 3.5 Usage, so a normal run bills well under it. To lower a ceiling, lower `budget.steps_per_session` or set `budget.wall_clock_s_per_session`. ## Top-ups A top-up buys purchased credit: whole dollars, from $10 to $500, as a one-off invoice for "Saram usage credit, N x $1.00", with tax on top where it applies. Top up on the dashboard: it needs a card on file, and the payment is made in your browser on Stripe's form. Saram never sees the card. Credit lands once Stripe says the invoice is paid, never on the browser's word. An invoice left unpaid for a day is voided. The last twenty top-ups are in `top_ups` on `GET /api/v1/billing`. Invoices and receipts are in Stripe's customer portal, linked from the dashboard ([`billing_portal`](/api/billing_portal.md)). ## Auto-refill and the monthly budget With **auto-refill** on, whenever `available_usd` falls below your threshold, Saram charges your card your chosen amount ($10 to $500) and adds it as purchased credit. Turning it on needs a threshold, an amount and a card on file. If the balance is already under the threshold, the first refill starts at once. A declined charge, or one your bank wants you to authenticate, turns auto-refill off. The **monthly budget** is the most Saram may charge your card in one calendar month, top-ups made by hand and by auto-refill together. No budget means no limit. When the month's charges reach it: - A top-up that would pass it is refused with a `409`. - A refill that is due is skipped, not charged. `auto_refill.last_skipped` says so, so the dashboard can show that auto-refill is on but the month's budget is spent. The next month's budget has room again. - Runs go on while the balance covers them, and stop starting, with a `402`, once it does not. Set both on the dashboard, or with [`billing_auto_refill`](/api/billing_auto_refill.md). `budget_used_usd` on `GET /api/v1/billing` is what this month's top-ups have used of the budget. ## Reading your usage [`get_usage`](/api/get_usage.md) answers one calendar month for your organisation alone: ```sh curl -s "https://api.saram.sh/api/v1/usage?period=2026-10" -H "Authorization: Bearer $API_KEY" ``` `period` is `YYYY-MM` in UTC, and left out it is this month. A month you ran nothing in answers zeros, not a `404`. The answer has: | Field | | | --- | --- | | `usage` | **Free and Pro: what Saram charged.** The month's Usage and its cost (`usage`, `usage_usd`, net of refunds), `refunded_usd`, and the same by run, by persona and per refund | | `provider` | Your model provider's estimated bill for the month, from your own keys. Shown apart and never added to Saram's charge | | `totals`, `meters` | What was measured: `steps`, `sessions`, `browser_seconds`, and model spend on Saram's credential (`model_usd_hosted`) and on yours (`model_usd_byo`). These are reports, not charges | | `runs` | Each run metered in the month, oldest first | | `billable_usd`, `billed_steps`, `step_price_usd`, `monthly_cap_usd` | **Older plans that bill steps only.** On those, `billable_usd` is the month's bill, and a new run is refused with a `402` once it reaches `monthly_cap_usd` | [`get_usage_csv`](/api/get_usage_csv.md) is the same month as a file, the one an invoice is written from: ```sh curl -s "https://api.saram.sh/api/v1/usage.csv?period=2026-10" \ -H "Authorization: Bearer $API_KEY" -o usage-2026-10.csv ``` ```text period,tenant,run_id,session_id,persona_id,persona_label,caller,occurred_at,steps,sessions,browser_seconds,model_usd_hosted,model_usd_byo,persona_steps,persona_seconds,usage,fault,usage_usd,refunded_usd,billable_usd ``` Each run has a row, followed by one row per session of that run, then a `TOTAL` row. A run's row carries the run's meters, `steps` to `model_usd_byo`, and `billable_usd`. A session's row carries its `persona_steps`, its `persona_seconds` (the time its steps took, without the session's setup and teardown), its `usage`, `fault`, `usage_usd` and `refunded_usd`. Each leaves the other's columns empty, so every column sums to its total. A session that failed on Saram's side reads `fault` `saram`, `usage_usd` `0.0000`, and its whole charge in `refunded_usd`. `usage_usd` and `refunded_usd` are empty, not zero, where the balance was not charged for the session: on an older plan, or while the session is not yet settled. Below the `TOTAL` row, after one empty line, is a second table: the credit that came into your balance or lapsed from it that month, oldest first. Its header is there in a month with no credit too, so a script that splits the file at the empty line always finds it: ```text period,tenant,kind,bucket,amount_usd,created_at,stripe_ref ``` | Column | | | --- | --- | | `kind` | `grant` (the month's included credit), `top_up`, `expiry` (included credit left at the end of the month, in the month whose credit lapsed) or `adjustment` (a correction Saram made) | | `bucket` | `included` or `purchased`, the part of the balance it moved ([The prepaid balance](#the-prepaid-balance)) | | `amount_usd` | Signed, to four places: a grant and a top-up are positive, an expiry is negative | | `stripe_ref` | The Stripe invoice behind a top-up or a Pro grant, empty on a row nobody paid Stripe for | The credit rows less the month's `usage_usd` are how far the month moved your balance. What runs charged and refunded is in the session rows above, not here. Each run also says what it used. `GET /api/v1/runs/{run_id}` carries its `usage`, `usage_usd` (what it was charged, refunds taken off) and `refunded_usd`. They rise while the run is open and are final once `usage_state` is `settled` or `refunded`. `cost_usd` beside them is model spend, not Saram's charge. ## What was checked | Block | Checked | | --- | --- | | The fees, included credit, session, retention, member and automation limits | The descriptions of `BillingOverview` and `PlanLimits` in platform-api 0.9.0 ([`/openapi.json`](/openapi.json)), and Saram's plan list | | The price per Usage and the Usage formula | Saram's plan list (0.5, 0.1 a second, 30 seconds at most); the reference's `CostCeiling` ("a typical 5-second step is about 1 Usage, against the 3.5 a step can reach") | | `GET /api/v1/billing`, `/usage` | 2026-10-23 against `api.saram.sh`, on an organisation on an older plan that bills steps: the fields shown; its figures are left out | | `GET /api/v1/usage.csv` | 2026-10-25 against `api.saram.sh` (platform-api 0.14.0), for 2026-09 and 2026-10, on an organisation on an older plan that bills steps: both headers shown, the run, session and `TOTAL` rows, the empty line, and the credit table's header with no credit rows under it; its figures are left out. The credit columns' values are from the reference's `get_usage_csv` | | The `402`, the `409` and the top-up and auto-refill bounds | The reference's `create_run`, `billing_top_up`, `TopUpWrite` and `AutoRefill`; **not run**, as each is a charge or a refused run | --- # Connect your coding agent > Connect Claude Code, Cursor or Codex to the Saram MCP server, by signing in or with an API key, then check a run spec, start a run and read what it found. Saram's MCP server is at: ``` https://api.saram.sh/mcp ``` There are two ways in, and both reach the same [tools](/mcp/tools/): - **Sign in.** The agent opens your browser, you sign in to Saram, and the tools act as you, in your organisation. Nothing is pasted anywhere and no key is stored in a file. Use this on your own machine. - **An API key.** For CI and for agents with no browser. The tools act for the key's organisation. No tool accepts or returns a credential. The tool list is public and a tool call is not: a client connects and lists the tools before you sign in, and the first call asks for the sign-in. The [last section](#what-was-measured) says which block on this page was run, when, and with which version of each client. ## Before you start - **For signing in: a Saram account.** Sign up at [dashboard.saram.sh](https://dashboard.saram.sh). Signing up creates your organisation; you see its runs and no others. - **For a key: the key in your shell.** Mint one on the dashboard under **Settings > API keys** (`/settings/keys`); it is shown once. Put it in your environment, never in a file you commit: ```sh export API_KEY= ``` In PowerShell, `$env:API_KEY = ""`. A client with no credential gets the tool list, each tool with its description and schema, the same as the [MCP tools](/mcp/tools/) page: ```sh curl -si -X POST https://api.saram.sh/mcp -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` ```text wrap HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 {"jsonrpc":"2.0","id":1,"result":{"tools":[{"annotations":{"readOnlyHint":true},"description":"List this deployment's runs, newest first, …","name":"list_runs",…},…]}} ``` A tool call with no credential is told where to sign in, which is all a client needs to find the sign-in by itself: ```sh curl -si -X POST https://api.saram.sh/mcp -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call", "params":{"name":"list_runs","arguments":{"limit":1}}}' ``` ```text wrap HTTP/1.1 401 Unauthorized www-authenticate: Bearer resource_metadata="https://api.saram.sh/.well-known/oauth-protected-resource/mcp" {"detail":"Unauthorized"} ``` The metadata names Saram's sign-in (WorkOS AuthKit), which offers dynamic client registration and PKCE. Claude Code, Codex and Cursor use both without being told, so no client id goes in any config below. ## Sign in ### Claude Code ```sh claude mcp add --scope user --transport http saram https://api.saram.sh/mcp claude mcp list ``` ``` Added HTTP MCP server saram with URL: https://api.saram.sh/mcp to user config Checking MCP server health… saram: https://api.saram.sh/mcp (HTTP) - ✔ Connected ``` `Connected` means Claude Code reached the server and listed its tools; you have not signed in yet. Start `claude` and ask for a tool, such as the prompt under [Check the connection](#check-the-connection). The first call answers: ```text wrap MCP server "saram" needs you to sign in again (run /mcp to re-authenticate) ``` Run `/mcp`, choose `saram` and authenticate. Your browser opens Saram's sign-in; sign in, and the tools act as you. The config holds the URL and no credential. ### Codex ```sh codex mcp add saram --url https://api.saram.sh/mcp ``` ```text wrap Added global MCP server 'saram'. Detected OAuth support. Starting OAuth flow… Authorize `saram` by opening this URL in your browser: https://…/oauth2/authorize?response_type=code&…&resource=https%3A%2F%2Fapi.saram.sh%2Fmcp Successfully logged in. ``` ```sh codex mcp list ``` ``` Name Url Bearer Token Env Var Status Auth saram https://api.saram.sh/mcp - enabled OAuth ``` Codex starts the sign-in as soon as the server is added and opens the link in your browser. If the browser is already signed in to Saram, it finishes by itself. Until it finishes, `codex mcp list` shows `Auth` as `Not logged in`. `codex mcp login saram` signs in again and `codex mcp logout saram` signs out. `~/.codex/config.toml` holds only the URL: ```toml [mcp_servers.saram] url = "https://api.saram.sh/mcp" ``` ### Cursor Cursor has no command for this: the server is a file, `.cursor/mcp.json` in a project or `~/.cursor/mcp.json` for every project. ```json { "mcpServers": { "saram": { "url": "https://api.saram.sh/mcp" } } } ``` Then finish the sign-in from Cursor's MCP settings. **Not run for this page:** the file is in the shape [Cursor documents](https://cursor.com/docs/context/mcp), and no Cursor session was driven with it. ## With an API key ### Claude Code The simplest form is one command: ```sh claude mcp add --scope user --transport http saram https://api.saram.sh/mcp \ --header "Authorization: Bearer $API_KEY" ``` ``` Added HTTP MCP server saram with URL: https://api.saram.sh/mcp to user config Headers: { "Authorization": "[REDACTED]" } ``` `claude mcp list` then reports `saram` as `Connected`. Your shell expands the variable, so the key itself is written, in plain text, into `~/.claude.json`. To keep the key out of every file, put the server in the project's `.mcp.json` and name the variable instead. Claude Code expands `${API_KEY}` when it starts, so this file can be committed: ```json { "mcpServers": { "saram": { "type": "http", "url": "https://api.saram.sh/mcp", "headers": { "Authorization": "Bearer ${API_KEY}" } } } } ``` Claude Code asks you to approve a project's servers the first time it starts there (`claude mcp list` says `Pending approval` until you do). With the variable unset, it warns `Missing environment variables: API_KEY`. ### Codex ```sh codex mcp add saram --url https://api.saram.sh/mcp \ --bearer-token-env-var API_KEY codex mcp list ``` ``` Added global MCP server 'saram'. Name Url Bearer Token Env Var Status Auth saram https://api.saram.sh/mcp API_KEY enabled Bearer token ``` With a variable named, Codex does not start a sign-in, and `~/.codex/config.toml` holds the variable's name, not the key: ```toml [mcp_servers.saram] url = "https://api.saram.sh/mcp" bearer_token_env_var = "API_KEY" ``` ### Cursor ```json { "mcpServers": { "saram": { "url": "https://api.saram.sh/mcp", "headers": { "Authorization": "Bearer ${env:API_KEY}" } } } } ``` Cursor reads `${env:NAME}` inside `headers`, so the file names the variable and never holds the key. **Not run for this page,** as for signing in. ## Check the connection Ask the agent: > Call the saram MCP server's `list_runs` tool with limit 1 and print the run_id and status. It answers your organisation's newest run, or an empty list in a new organisation. Either way the tools are connected: a credential problem is an error, not an empty list ([When it does not connect](#when-it-does-not-connect)). ## Testing localhost and previews Saram's browsers run in our cloud, not on your machine. To test localhost, open a tunnel. A quick tunnel's URL is public while it is open, so close it when the run ends. A protected preview works through a bypass secret you upload, and Saram sends it only to your preview's own site. A spec that names your machine is refused before anything starts: ```text wrap target.entry_url names 'localhost', a cluster or local name: a run may not point at a private or cluster address unless Saram has marked the tenant trusted for it ``` ### Localhost, through a quick tunnel A [Cloudflare quick tunnel](https://developers.cloudflare.com/tunnel/get-started/quick-tunnels/) needs no account: ```sh wrap # 1. install once winget install Cloudflare.cloudflared # Windows brew install cloudflared # macOS # 2. open the tunnel while your dev server runs; keep this terminal open for the whole run cloudflared tunnel --url http://localhost:3000 # -> prints https://.trycloudflare.com # 3. the spec names that URL (entry_url, and the reset hook if you have one); validate, then start ``` ```text wrap Your quick Tunnel has been created! Visit it at (it may take some time to be reachable): https://.trycloudflare.com ``` Open the URL yourself before you validate: it can take a few seconds to answer. Use it wherever [Your first run](#your-first-run) says `https://staging.example.com`. The tunnel's name resolves to Cloudflare's public addresses, so the spec passes the rule that refuses `localhost`. Each start prints a new URL, so a restarted tunnel needs the spec changed too. Anyone who has the URL reaches your dev server while cloudflared runs: the random name is all that keeps it unknown. Stop cloudflared (Ctrl+C) when the run ends. A dev server needs one line to work behind the tunnel. **Next.js 16, `next dev`.** The page renders but never hydrates: Next refuses its HMR websocket from the tunnel's origin and logs `Blocked cross-origin request to Next.js dev resource /_next/hmr`. Client components then do nothing, and a persona reports the dead controls as defects. Add this to `next.config` and restart `next dev`, or run `next build && next start` instead: ```js allowedDevOrigins: ['*.trycloudflare.com'], ``` **Vite 8, dev.** Every request answers `403`, `Blocked request. This host ("….trycloudflare.com") is not allowed`. Add this to `vite.config`: ```js server: { allowedHosts: ['.trycloudflare.com'] }, ``` cloudflared's `--http-host-header localhost:` is not a fix for both: it fixes Vite, but Next still does not hydrate, and Next's absolute redirects then send the browser to `https://localhost:/…`. The run depends on your machine for its whole length: - **Keep it awake and connected.** When cloudflared stops, because the laptop sleeps or the terminal closes, the URL answers `530` with a page titled `Cloudflare Tunnel error`, and with your dev server stopped it answers `502`. A session that meets either page sees it instead of your product, and Saram does not yet tell that page from your product's own. - **One session at a time.** Every step and every reset-hook call crosses your machine. Keep `budget.max_concurrent_sessions` at 1 unless your app keeps sessions apart by the `?saram_sid=` on each session's entry URL. - **Cloudflare's limits.** A quick tunnel is for testing and development, with no uptime guarantee: 200 requests in flight at most, then `429`, and no Server-Sent Events. ### A protected preview, through a door A preview behind a login of its host's, such as Vercel's or Netlify's, opens to a **door**: an `account` credential you upload, carrying the `headers`, `basic` or `cookies` that get past it, named in the spec's `target.auth.secret_ref`. Saram's browsers apply it only to hosts on the same site as your `entry_url`, and Saram sends its `basic` and `headers` on the reset-hook call. No MCP tool takes a credential and the dashboard's credential form does not take a door yet, so upload it with an [API key](#before-you-start) and [`create_credential`](/api/create_credential.md). The answer's `secret_ref` is the ref for the spec. A door is removed with [`delete_credential`](/api/delete_credential.md). A door of `headers` or `basic` is applied to the page's requests, not to websocket handshakes or service-worker fetches; those get past the host's login only with a cookie. #### Vercel Vercel's **Protection Bypass for Automation**, on every plan, gets past Vercel Authentication, Password Protection and Trusted IPs. Create a secret for it in the project's Deployment Protection settings, put it in `VERCEL_BYPASS`, and upload it as a door (`jq` builds the body, so the secret stays out of your shell history). The second header asks Vercel to set a cookie, which carries the bypass to the websockets and service workers the header does not reach: ```sh jq -n --arg s "$VERCEL_BYPASS" '{ name: "vercel-bypass", kind: "account", value: ({headers: { "x-vercel-protection-bypass": $s, "x-vercel-set-bypass-cookie": "true"}} | tojson)}' | curl -s -X POST https://api.saram.sh/api/v1/credentials \ -H 'content-type: application/json' \ -H "Authorization: Bearer $API_KEY" -d @- ``` ```json { "target": { "entry_url": "https://my-app-git-my-branch-my-team.vercel.app", "auth": { "kind": "header", "secret_ref": "" } } } ``` `vercel.app` is a public suffix, so the preview's site is its own host and the secret goes to that host alone. :::caution[The secret opens every deployment] A bypass secret opens **every deployment of the project, production included**, until you revoke it in Vercel. Make one secret for Saram alone, so you can revoke it without touching anything else that uses one. ::: #### Netlify Of Netlify's protections, only **Basic-Auth in `_headers`** (Pro and Enterprise) has a way through for Saram, because it is plain HTTP basic auth. Site password protection is a form with no way around it for automation, and team login has none either. Use Basic-Auth for deploy previews, or make the preview public: ```text /* Basic-Auth: saram: ``` The door is the same login as `basic`, uploaded as above with this `value` and named with `"kind": "basic"`: ```json { "basic": { "username": "saram", "password": "" } } ``` :::caution[The file gates every deploy it ships in] `_headers` holds the password in plain text, and it gates every deploy the file is in, production included. Write it in your build for deploy previews only (Netlify's `CONTEXT` is `deploy-preview` there), keep it out of your repository, and use a password made for Saram alone. ::: Anything else in front of a preview, such as a Cloudflare Access service token or an `X-Preview-Token` header, is a door of the same shape: its headers, its `basic` or its cookies. ## Your first run The server's [instructions](/mcp/tools/#server-instructions) tell the agent the order (validate, start, wait, read), so the prompts can be short. Use a URL of your product that Saram's browsers can reach over the internet: a staging deployment, a preview, or a tunnel to your machine ([Testing localhost and previews](#testing-localhost-and-previews)). **1. Validate.** Free, instant, and starts nothing: > Write the smallest run spec for https://staging.example.com: one persona from `list_personas`, > one mission "create an account" that succeeds when the persona reaches a signed-in page, and 30 > steps per session. Call `validate_spec` and show me the planned sessions, the warnings and the > cost ceiling. Do not start a run. `validate_spec` runs every check `start_run` runs. On the smallest spec the tool's schema gives: ```json { "target": { "entry_url": "https://example.com" }, "personas": ["dana_first_timer@1"], "missions": [ { "id": "sign_up", "goal": "create an account", "success_criteria": [ { "kind": "judge_assertion", "value": "the persona reached a signed-in page" } ] } ], "budget": { "steps_per_session": 30 } } ``` it answered `warnings: []`, one planned session (`dana_first_timer@1` on `sign_up`) and a `cost_ceiling` whose `basis` read: ```text wrap up to $0.60: 1 session x 30 persona steps at $0.02 a step on the pilot plan, which bills steps; judging and reproduction are included. ``` The figure is your plan's, and it is a ceiling, not a forecast. On the Free and Pro plans a spec with no `providers` block is refused: every role makes its model calls on your own provider. Only an organisation on an older plan, or with an agreement with Saram for its models, runs such a spec on Saram's own credential, which `cost_ceiling.model_calls` says. To set up your own provider, ask the agent to call `get_provider_status`, and see [Providers and your own keys](/providers/). This spec's one criterion is a `judge_assertion`, which is text the judge reads, not a check. Only a `url_matches` criterion is checked by machine, so a session on this spec that the persona finishes always ends with `end_reason` `claimed_finished_unverified`: the persona says it is signed in and nothing confirmed it. For a verified ending, `success_criteria_met`, name in the prompt a part of the URL your product shows once someone is signed in, such as a mission that "succeeds when the URL contains `/account`", so the agent can write a `url_matches` criterion ([Success criteria: what is checked](/run-spec/#success-criteria-what-is-checked)). **2. Start, then wait.** > Start it, wait until it is done, and tell me what it is doing between waits. `start_run` answers a `run_id` and nothing else. `wait_for_run` waits up to 60 seconds a call, so a run of several minutes takes several calls; `done: false` means the wait ran out, not that the run failed. A finished run answers its `status`, `session_counts`, `cost_usd` and each session's result. **3. Read what it found.** > Show me the issues that reproduced, the repro steps and a screenshot of the first, then the > personas' verdicts. | Tool | What it returns | | --- | --- | | [`get_issues`](/mcp/tools/#get_issues) | One row per defect, most severe first; `reproducible_only: true` keeps the ones that reproduced | | [`get_issue`](/mcp/tools/#get_issue) | One issue in full: persona-free repro steps, and whether it may file a ticket alone | | [`get_screenshot`](/mcp/tools/#get_screenshot) | One screenshot as an image, by a ref from an issue's evidence (2 MiB at most) | | [`get_evidence_url`](/mcp/tools/#get_evidence_url) | The URL, type and size of a video or anything else too large to read inline | | [`get_verdicts`](/mcp/tools/#get_verdicts) | Each persona's own verdict, with the diary quotes it rests on: advisory | | [`get_opportunities`](/mcp/tools/#get_opportunities) | Persona feedback that is not a defect: advisory | Every tool's arguments and fields are on the [MCP tools](/mcp/tools/) page. ## Rules for your agent Put these in your agent's instructions too. The server says the same in its instructions and its tools' descriptions: - **File a ticket from an issue only when `may_file_ticket_alone` is `true`.** That issue reproduced without the persona layer. - **When it is `false`, report the issue with its evidence, marked unconfirmed,** never as a confirmed bug. - **Verdicts and opportunities are never tickets.** They are the personas' opinions: say whose they are, and never state them as fact. - **An issue's `description` is objective; its `persona_rationale` is the persona's voice.** Quote the second; do not merge it into the first. :::caution[A run is billable] A run drives real browsers with real model calls and takes minutes. Start one because a person asked for it, in the session where they asked, and stay with it until it finishes. Never let an agent loop `start_run`, retry it on a refusal or run it on a schedule. `start_run` refuses a second run while one of yours is in flight, and `cancel_run` stops a run without refunding what it has spent. ::: ## When it does not connect - **The tools are listed but a call answers `needs you to sign in again (run /mcp to re-authenticate)`, or `codex mcp list` says `Not logged in`.** No sign-in has finished: the list needs none, a call does. Sign in: `/mcp` in Claude Code, `codex mcp login saram`, or the sign-in in Cursor's MCP settings. `Connected` in `claude mcp list` does not mean signed in. - **`Server rejected the configured Authorization header (HTTP 401)`.** The key is wrong, revoked or unexpanded; every cause is the same 401, and a wrong key is refused for the tool list too. Check that `API_KEY` is set where the agent starts, or mint a new key. - **`Protected resource https://api.saram.sh/mcp does not match expected ...`.** The client was added under another address. Remove the server (`claude mcp remove saram -s user`, `codex mcp remove saram`, or the entry in Cursor's file) and add it again as above. - **A 403 after signing in.** You have no seat in an organisation, or a role that may only read. Ask an owner of your organisation. In Claude Code, an `Authorization` header turns signing in off (`OAuth fallback is disabled when headers.Authorization is set`), so a 401 there is the key's. To sign in instead, remove the server and add it again without `--header`. ## What was measured Every block above was run as shown except where this table says otherwise. Nothing on this page is a screenshot; the outputs are copied from a terminal, with run ids and sign-in parameters left out. | Block | Measured | | --- | --- | | The public tool list, the 401 on a call, and its metadata | 2026-10-21, against platform-api 0.8.5 | | Claude Code, sign in: adding the server and the first tool call | 2026-10-21, Claude Code 2.1.280, against platform-api 0.8.5 | | Claude Code, sign in: `/mcp` and the browser | 2026-09-19, by a person; `list_runs` then answered their organisation | | Codex, sign in: adding the server, the browser and `list_runs` | 2026-10-21, Codex CLI 0.153.4; adding it and `Not logged in` again against platform-api 0.8.5 | | Cursor, both ways | **Not run** | | Claude Code, key: the one command | 2026-10-21, Claude Code 2.1.280; `Connected` with a valid key on 2026-09-24 | | Claude Code, key: `.mcp.json` | 2026-09-24, five tools called with it; the approval and the missing-variable warning on 2026-10-21 | | Codex, key: adding the server | 2026-10-21, Codex CLI 0.153.4; `list_runs` answered with a valid key on 2026-09-24 | | Check the connection | 2026-10-21 (Codex, signed in) | | Localhost: the quick tunnel | 2026-10-22, cloudflared 2026.10.3 on Windows against a static page on `localhost:3000`: the URL printed 4 s after the start, then `200` through it, 0.66 s for the first request and 0.14 to 0.17 s after; the same version on `winget`. No run started | | Localhost: the `530` and the `502` | 2026-10-22: `502` for the first seconds after cloudflared stopped, then `530` `Cloudflare Tunnel error` from 8 s on and still four minutes later; `502` with cloudflared up and the page's server stopped; a second start printed a new URL | | Localhost: the refusal, and a tunnel URL passing the rule | 2026-10-22, the control plane's own checks run outside Saram's cloud; `validate_spec` on `api.saram.sh` **not run** | | Localhost: the Next.js and Vite lines, `--http-host-header` | 2026-10-22, Next.js 16.3.4 `next dev` and Vite 8.3.5 behind a cloudflared 2026.10.3 tunnel; not re-run for this page | | Localhost: Cloudflare's limits | Cloudflare's quick tunnel page, read 2026-10-22; not measured | | Previews: Vercel | **Not run** against a protected Vercel preview. The upload body and the spec checked against platform-api 0.8.6's schemas on 2026-10-22; the door itself is tested against a header-gated page, not against Vercel | | Previews: Netlify | **Not run** | | Your first run, step 1: `validate_spec` | 2026-10-21, the spec and `basis` shown | | Your first run, step 1: what the criterion gives | **Not run**; from the `RunSpecMission` schema in platform-api 0.11.0 | | Your first run, step 2: `start_run` | **Not run for this page**; `wait_for_run` on a finished run, 2026-09-24 | | Your first run, step 3: the read tools | 2026-09-24, on a finished run | | The 401 with a wrong key | 2026-10-21, Claude Code 2.1.280; the tool list refused it too against platform-api 0.8.5 | --- # Issues vs persona feedback > What a Saram run reports and how to act on each part - issues a replay tried to reproduce, the personas' verdicts and opportunities, tester errors, and the confirmation rate - and what to file as a ticket. A run reports four kinds of record. Only one of them is a defect, and only part of that one may become a ticket by itself: | Record | What it is | Route | A ticket? | | --- | --- | --- | --- | | **Issue** | A product defect, which a replay without the persona then tried to reproduce | [`get_issues`](/api/get_issues.md) | Only when `may_file_ticket_alone` is `true` | | **Verdict** | Each persona's answer at the end of its session, quoted from its diary | [`get_verdicts`](/api/get_verdicts.md) | Never | | **Opportunity** | What the personas' behaviour says the product could do better, or already does well | [`get_opportunities`](/api/get_opportunities.md) | Never | | **Tester error** | A slip the persona made, such as a mistyped password | [`get_tester_errors`](/api/get_tester_errors.md) | Never | All four routes answer from the moment the run exists. While it is still going they answer an empty list beside the run's `status`, so an empty list is a result only once `status` is terminal. [`get_artifact`](/api/get_artifact.md) answers everything in one document. Over MCP, the tools of the same names answer the same records ([MCP tools](/mcp/tools/)). ## Issues: defects, checked by a replay An issue is something the product did wrong: where, in how many of the sessions that got there, with its evidence and the steps that reproduce it. Its `type` is `blocker`, `friction`, `confusion`, `bug` or `contradiction`, and its `severity` is `low`, `medium`, `high` or `critical`. After the sessions, Saram tries to reproduce each issue: a replay walks the recorded path again with no persona and no model, twice, with your app reset before each attempt. The result is in `reproduction` (`reproduced`, `not_reproduced`, `flaky` or `skipped`), and what it proved is in `claim.verification.status`: | `verification.status` | Meaning | Files alone? | | --- | --- | --- | | `verified` | The replay failed at the same step, a second check found the issue's title supported by a verbatim quote from the recorded steps, and the failure is the product's, not the persona's | Yes | | `marker_matched` | The replay failed at the same step, but whether it failed for the reason the title gives is not established | No | | `inconclusive` | A check ran and settled nothing: the failure did not come back, or the two attempts disagreed. Not a refutation | No | | `unverified` | Nothing checked it. Also how to read an issue with no `verification` at all | No | `reproducible` is `true` exactly when the status is `verified`. To check an issue again later, after a fix, [`retest_issue`](/api/retest_issue.md) runs the same replay now and keeps the answer beside the run's own; a replay's steps are never billed. ### The reading beside each issue `GET /api/v1/runs/{run_id}/issues` answers `issues[]` and, beside it, one `reading[]` entry per issue: ```json { "issue_id": "iss_signup_submit_500", "may_file_ticket_alone": true, "subjective_fields": ["persona_rationale"], "advisory": "SUBJECTIVE. `persona_rationale` is persona-voiced narration from an affected session, not a statement of observed behaviour. Quote it as the persona's words; the objective claim is `description`." } ``` - **`may_file_ticket_alone: true`** means reproduction cleared the issue as a confirmed bug. File it as it stands. - **`false`** means report it with its evidence and mark it unconfirmed. It may be real; nothing has proved it yet. - **`subjective_fields`** names the fields in the persona's voice. An issue's `description` is the objective account; `persona_rationale` is what the persona felt. Quote the second as the persona's words, and do not merge it into the first. [`get_issue`](/api/get_issue.md) answers one issue in full: its claim and how it was checked, the repro steps, the preconditions and the evidence refs. ## Verdicts and opportunities: persona feedback Both are opinions. They are grounded in what the personas did and said, and they are still opinions: say whose they are, and never state them as fact. - **Verdicts** are each persona's answer to the run's verdict question (`mission.verdict_prompt`, or the persona's own), quoted from its diary, with the cohort's tally of `positive`, `negative` and `undecided`. A verdict the judge gave but could not ground in the persona's diary is thrown away and counts as `undecided`; the row says so in `verdict_discarded`. - **Opportunities** are unmet expectations, missing capabilities, value and trust gaps, and moments of delight. They are scored by consensus, the share of the sessions that reached the area and raised it, never by reproduction. `display.shown` lists the ones that clear the cohort floors, and `display.folded` the single-persona notes. A moment that worked well is an opportunity of kind `delight`, never an issue. Each answer carries an `advisory` saying how to read it. ## Tester errors: the harness's own slips Some failures are the persona's, not the product's: it typed a password that differs from the one its account gave it, clicked the wrong element, or read placeholder text as a value. Saram asks of every finding whether the persona's own action could explain it, and records the answer as `claim.attribution`: - `product`: the failure is the product's. - `tester`: the record moves to `tester_errors[]`. It is not an issue, is not counted in `issues_total`, is not reproduced and is never a ticket. - `undetermined`: the harness could not tell, and a person must. Such an issue is never `verified`. Read tester errors for why a session stalled and what to fix in your test setup, such as a stale password in a test account. `classified: false` means the run's artifact is from before this sorting existed, not that it found none. ## The confirmation rate A run's `confirmation_rate` ([`get_run`](/api/get_run.md)) is the confirmed issues over the issues reproduction ruled on. It is there as soon as reproduction finishes. Read it with its denominator, `summary.reproduction`: ```json { "candidates": 2, "merged": 1, "evaluated": 1, "confirmed": 1, "inconclusive": 0, "excluded": 0 } ``` `candidates` are the per-session findings the judge raised, `merged` what they became once the same defect seen in several sessions was folded into one, `evaluated` the ones reproduction ruled on, and `confirmed` the ones it confirmed. A rate over an `evaluated` of 0 says nothing: a run that found nothing reads `0.0`. Do not compare runs by the rate alone. ## What to file as a ticket 1. **File an issue whose reading says `may_file_ticket_alone: true`**, with its repro steps, evidence and objective `description`. 2. **Report an issue whose reading says `false` as unconfirmed**, with its evidence, never as a confirmed bug. A person can look at the evidence, or [`retest_issue`](/api/retest_issue.md) can try it again. 3. **Never file a verdict or an opportunity as a ticket.** Take them to whoever owns the product decision, attributed to the persona. 4. **Never file a tester error against the product.** Fix the test setup it points at. With `connectors.github` on a Pro plan, Saram files issues on GitHub itself ([The run spec](/run-spec/#connectorsgithub)). ## What was checked | Block | Checked | | --- | --- | | The four routes, their `advisory` and `reading` | 2026-10-23, on a finished run against `api.saram.sh` (platform-api 0.9.0) | | The reading and the reproduction counts shown | The examples in the reference's `get_issues` and `get_run` ([`/openapi.json`](/openapi.json)) | | The verification statuses and attributions | The `Status` and `Attribution` schemas in the reference | --- # MCP tools > The 24 tools of the Saram MCP server at https://api.saram.sh/mcp, their arguments and what they return. Saram's MCP server is at `https://api.saram.sh/mcp`. It exposes 24 tools. This page is generated from `mcp-tools.json` of `@saram/platform-api` 0.16.1; the same file is at [/mcp-tools.json](/mcp-tools.json). To connect Claude Code, Cursor or Codex to it, by signing in or with an API key, see [Connect your coding agent](/connect-agent/). ## Server instructions What the server tells a connecting agent, verbatim: > Saram behavioural QA: query the artifact of a run in which synthetic personas drove a real > browser through this product. > > A run produces two kinds of output and they are not interchangeable: > > - Issues (`get_issues`, `get_issue`) are defects, gated on reproduction. An issue with > `may_file_ticket_alone: true` reproduced without the persona layer and is safe to act on by itself. > One with `false` did not: report it with its evidence, do not file it as a confirmed bug. > - Verdicts (`get_verdicts`) and opportunities (`get_opportunities`) are persona feedback: subjective, > evidence-linked, scored by agreement across personas. They are advisory. They never file a ticket on > their own and must be attributed to the persona, not stated as fact. > > An issue's `description` is objective; its `persona_rationale` is the persona's voice. Keep them apart. > > Start at `list_runs`, then `get_run` for one run, then `get_issues` to triage and `get_issue` for the repro > steps and evidence of one. `get_session_trace` shows what a persona said and what the page did, step by > step; `get_screenshot` fetches a `screenshot_ref` from any of the above. > > A third record sits beside those two and is neither (`get_tester_errors`): a finding the harness attributed to the persona's own action, not to the product. It is kept and quoted because it says why a session stalled, and it is never a defect - it is not counted in issues_total and never files a ticket. > > Starting a run is billable and takes minutes: a cohort of personas drives real > browsers through the product, with real model calls. So the order is validate_spec (free, instant, starts > nothing, and its cost_ceiling is the most the run can cost), then start_run, then wait_for_run until it > answers done, then the read tools above. > > Those browsers run in Saram's cloud, not on the user's machine, so the target has to be a URL the internet can > reach: for an app on localhost, open a tunnel to it or use a preview URL (https://docs.saram.sh/connect-agent). > > A run is a person's. Start one because someone asked you to, in the session where they asked, and stay with it > until it finishes - never on a schedule, never unattended, and never in a retry loop. When a run is billed to a > ChatGPT subscription login (get_provider_status), that is not a preference: the login serves runs a person > starts, and one run at a time is what start_run will do for you without being told twice. ## Tools | Tool | Behaviour | Summary | | --- | --- | --- | | [`list_runs`](#list_runs) | Read-only | List this deployment's runs, newest first, one row each: status, target, cohort packs, session counts, issue and opportunity counts, and cost. | | [`get_run`](#get_run) | Read-only | One run in full: its status and why, the target, the budgets, the files it hands its personas, the artifact summary once assembled, and one row per persona session with its result, cost and trace refs. | | [`get_issues`](#get_issues) | Read-only | A run's issues as triage rows, most severe first: title, type, severity, route, confidence, who it affected and whether it reproduced. | | [`get_issue`](#get_issue) | Read-only | One issue in full: the contract object verbatim, its reproduction result, deterministic persona-free repro steps, preconditions, the reading that says whether it may file a ticket alone, and the evidence refs get_screenshot accepts. | | [`get_verdicts`](#get_verdicts) | Read-only | ADVISORY. Each persona's own verdict at the end of its session, with the diary quotes it is grounded in, plus the cohort rollup. | | [`get_opportunities`](#get_opportunities) | Read-only | ADVISORY. Behaviour-grounded persona feedback that is not a defect: unmet expectations, missing capabilities, value and trust gaps, delight. | | [`get_tester_errors`](#get_tester_errors) | Read-only | HARNESS-ATTRIBUTED, NEVER A DEFECT. Findings this run attributed to the persona's own action rather than to the product: a mistyped credential, the wrong element, placeholder text read as a value. | | [`diff_vs_baseline`](#diff_vs_baseline) | Read-only | What changed since the run's baseline (its spec's baseline_run_id), as the run's artifact measured it: issues new on this run, issues of the baseline this run resolved, issues that persist, and the persona sessions that regressed - completed on the baseline and not now. | | [`get_session_trace`](#get_session_trace) | Read-only | One session's step-by-step ledger: the persona's diary line beside the surface the browser saw - url, inputs dispatched, network failures, console errors, screenshot_ref. | | [`get_screenshot`](#get_screenshot) | Read-only | Fetch one screenshot as an image, by a ref from an issue's evidence or a trace step's screenshot_ref. | | [`get_evidence_url`](#get_evidence_url) | Read-only | The URL one piece of evidence is fetched from, with its content type and its exact size in bytes - and none of the bytes. | | [`validate_spec`](#validate_spec) | Read-only | Check a run spec without starting anything, and see what the run would be. | | [`start_run`](#start_run) | Changes state, reaches outside Saram | Start a run from a run spec. BILLABLE: this drives real browsers with real model calls and takes minutes - validate_spec first, and only start a run a person has just asked you for. | | [`get_run_status`](#get_run_status) | Read-only | Where a run has got to: its status, whether it is done, how many sessions are in each state, what it has cost, and for each session that has not finished the heartbeat_step it last reported - the only field that says where a session in flight is. | | [`wait_for_run`](#wait_for_run) | Read-only | Wait for a run to finish, then answer exactly what get_run_status answers. | | [`cancel_run`](#cancel_run) | Changes state, destructive, idempotent | Cancel a run that is still going: its sessions stop where they are and the run ends as cancelled. | | [`list_personas`](#list_personas) | Read-only | The synthetic personas this deployment can run, by the address a run spec names them with (id@version), with each one's pack and tags. | | [`get_persona`](#get_persona) | Read-only | One persona in full, as the registry holds it and as a run spec may inline it: who they are, what they are trying to do, how they behave under friction, and what they find hard. | | [`list_packs`](#list_packs) | Read-only | The persona packs - the cohorts a run draws from - with how many personas each holds and their addresses. | | [`get_provider_status`](#get_provider_status) | Read-only | Which model provider your runs are billed to: the state of your caller's own ChatGPT login (none, pending, connected, expired or error), what the gateway last saw of its consumer rate limit, and - once connected - the exact providers block to paste into a run spec, credential name included. | | [`start_chatgpt_login`](#start_chatgpt_login) | Changes state, reaches outside Saram | Start connecting the person's own ChatGPT subscription, or reconnecting one that get_provider_status says is not 'connected'. | | [`create_share_link`](#create_share_link) | Changes state, reaches outside Saram | Publish one finished session's recording as a public link: anyone holding the URL can watch it, with the persona's steps and narration, without signing in; the recording hides only what the browser pool masked when it recorded it (masked and warning in the answer say what that is), so the test account's email and whatever else the product showed are visible as the browser showed them; and it plays until it expires or you revoke it with revoke_share_link and the id this answers. | | [`list_share_links`](#list_share_links) | Read-only | Every public link minted on one run's sessions, newest first - live, expired and revoked - each with its id (what revoke_share_link takes), the session it plays, its last four characters, its state, who minted and who revoked it, when it expires and how often it was opened. | | [`revoke_share_link`](#revoke_share_link) | Changes state, destructive, idempotent | Stop a public share link playing: the next request for it is refused, a video already playing included once it asks for its next part. | ## `list_runs` **Read-only.** List this deployment's runs, newest first, one row each: status, target, cohort packs, session counts, issue and opportunity counts, and cost. Start here; get_run has the rest of one run. About 400 characters a run. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `limit` | integer | no | `20` | How many runs to return, newest first, at most 50. | | `status` | string or null | no | `null` | Only runs in this status, e.g. running, completed, failed, cancelled. | | `target` | string or null | no | `null` | Entry URL or target name, matched exactly. | ### Returns | Field | Type | Description | | --- | --- | --- | | `runs` | array of RunRow | | ## `get_run` **Read-only.** One run in full: its status and why, the target, the budgets, the files it hands its personas, the artifact summary once assembled, and one row per persona session with its result, cost and trace refs. While the run is still open this also carries the live workflow query and each unfinished session's heartbeat_step, which is the only thing that says where a session in flight has got to. About 1,000 characters a session. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `run_id` | string | yes | | Run id, as returned by list_runs. | ### Returns | Field | Type | Description | | --- | --- | --- | | `run_id` | string | | | `status` | string | `pending`, `running`, `observing`, then one terminal value. `completed` means at least one session reached an ending its persona got to (`completed`, `abandoned`, `blocked`, `budget_exhausted`), so there are results to read. `failed` covers both a run that broke - a rejected spec, a reset hook or the observer - and a run whose every session `errored`, which came back with no persona outcome at all; `status_reason` says which. `cancelled` is a `cancel` signal. Poll until the value is terminal, and treat only `completed` as results-ready. | | `status_reason` | string or null | Why a run ended `failed` or `cancelled`, in a sentence for the caller: the spec's refusal or the target's reset hook's, quoted; that Saram could not start the run, or could not finish judging its sessions; that every session errored; or `cancelled by signal`. Absent on a `completed` run. | | `target` | string | | | `target_name` | string or null | | | `baseline_run_id` | string or null | | | `tenant_id` | string or null | Who owns this run, and so who may read it: every route filters by the tenant the request's key belongs to, and a run outside it is a 404 rather than a 403. Always your own tenant, which is what makes it safe to publish - a response can only ever carry the id you are already authenticated as. Absent on runs created before runs recorded their tenant; those are readable by this deployment's own fleet tenant and by nobody else. | | `created_by` | string or null | Which credential started it: `key_...` for an API key, `operator` for a break-glass entry or a run an operator started in-process. The id of the key rather than its name, so rotating a key leaves the runs it started still pointing at it. | | `caller` | string or null | Who started this run, in words: the caller name the API key gate identified at `POST /runs` - the operator's own name for a break-glass entry, a name like `acme/ci` for an API key. Absent when nobody was identified, which is the gate being off, a run an operator started in-process, or a run created before runs recorded their caller; the three are deliberately indistinguishable rather than dressed up with an invented name. Attribution, beside the authorization `tenant_id` carries. | | `confirmation_rate` | number or null | Confirmed issues over the issues reproduction ruled on. Available as soon as reproduction finishes, which is before `summary.confirmation_rate` exists; the two are the same number computed from the same lineage. | | `repro_attempts` | integer or null | | | `repro_gate_discards` | integer or null | How many things reproduction's gate threw away, of two kinds: claim-check quotes the cited evidence does not contain, each of which left its issue `ungrounded`, and replay attempts whose path diverged before the judged step, each of which ruled on nothing and left its issue `skipped`. Read beside `confirmation_rate`: that says how much of what the judge said survived the replay, this says how often the replay's own checker cited loosely or its path stopped being walkable. Zero is the expected value. Absent until reproduction has run, and on runs reproduced before this was recorded. | | `repro_gate_ref` | string or null | The records behind `repro_gate_discards`, fetched through `GET /api/v1/blobs`: `{"run_id", "discards"}`, one discard each with its `session_id`, `kind`, `reason` and a `payload` naming the `issue_id`. A `claim_quote` payload carries the `quote`; a `path_diverged` one carries the `attempt`, the `attempt_session_id`, the original `step_id` it diverged at (null for the entry url), that step's `action` and `expect`, and the `url` the replay was on instead. Written even when empty, so an absent ref is reproduction that has not run, never "nothing thrown away". | | `billed_steps` | integer | What this run is charged for: the steps its personas took - one browser action each - never the reproduction replay's, which are included at no charge. LIVE: a session is recorded when it ends, so while the run is open this is the finished sessions' counts plus each unfinished session's last `heartbeat_step`, and it climbs until the run does end. | | `billable_usd` | number or null | What this run costs you, to the cent: `billed_steps` at your plan's step price - or your own agreed price - in force when the run ran. This is Saram's charge; `cost_usd` beside it is model spend and is not. LIVE with `billed_steps` while the run is open, so it is what the run has cost you so far. Absent means nobody has priced your tenant, which is not the same as free - never read a missing price as $0. Your plan's included steps and monthly fee are not in it: they are properties of a month, and the invoice line with the allowance applied is this run's `billable_usd` on `GET /api/v1/usage`. | | `cost_usd` | number or null | What this run's model calls cost: `actor_cost_usd` and `judge_cost_usd` together. NOT what Saram charges you - `billable_usd` is that: on your own keys this is your provider's bill, and on Saram's models it is Saram's own cost, shown so your Saram usage and your provider's invoice add up. Written when the run finishes. Absent means nothing priced the models, which is not the same as free. On a run finished before the judge was priced it is the persona loop's alone, and `judge_cost_usd` is absent beside it. | | `actor_cost_usd` | number or null | Of `cost_usd`, the persona loop's model calls: the sum of the priced sessions' `cost_usd`. Absent when no session's model had a price. | | `judge_cost_usd` | number or null | Of `cost_usd`, the observer's model calls: the judge's reading of each session, attribution, the merge, and the reproduction's claim check and tie-break - a failed attempt included, because it was paid for. On today's models this is often the larger half. A retest you ask for later is not in it. Absent when none of those calls had a price, and on a run finished before they were priced. | | `usage` | number or null | This run's Usage: persona steps weighted by how long each took, summed over its sessions, to 0.001. LIVE while `usage_state` is `live`: settled sessions read what the ledger debited, and a session in flight its last heartbeat. Final once `usage_state` is `settled` or `refunded`, when it is the credit ledger's own figure. Absent on a finished run none of whose sessions was measured, which is not the same as none used. | | `usage_usd` | number or null | What this run's Usage costs you in dollars, at your plan's Usage price when each session ran: charged from your prepaid balance, refunds already taken off - so a run that failed on Saram's side reads 0 and the money given back is `refunded_usd`. Moves with `usage` while the run is live. This is Saram's charge on a plan that prices Usage (Free, Pro); your model provider's bill is never in it. Absent when nothing prices this run's Usage - a plan billed in steps, where `billable_usd` is the charge - which is not the same as free. | | `refunded_usd` | number or null | Of this run's Usage, the dollars given back because a session failed on Saram's side (the pool, a lost worker, a setup step in Saram's code: fault `saram`). Not in `usage_usd`. Absent wherever `usage_usd` is. | | `held_usd` | number or null | What this run still reserves from your balance: the most its spec's budgets can use (the Usage ceiling), less each finished session's share. Released when the run's last session is settled. Absent when the run holds nothing - it has finished, or the run gate did not hold for it. | | `usage_state` | one of `"live"`, `"held"`, `"settled"`, `"refunded"` or null | How final `usage` and `usage_usd` are. `held`: the run is open and holds `held_usd`, and no step is measured yet. `live`: the run is open, or has just finished and is being settled - the figures are so far and still rise. `settled`: every session is settled and the figures are the credit ledger's. `refunded`: settled, and every dollar of its Usage was given back because it failed on Saram's side. | | `sessions_per_hour` | number or null | Sessions that ran a step, per hour of the execution phase (run start to last session finish). | | `artifact_ref` | string or null | | | `summary` | Summary or null | The artifact's `summary` block, once the artifact is assembled. | | `session_counts` | object of integer | Session count per status for this run: pending, running, the artifact results, or cancelled. | | `packs` | array of string | The distinct persona packs this run's sessions were drawn from, sorted: the cohorts it covers. A run whose personas have no pack lists none rather than an empty cohort. | | `created_at` | string | | | `started_at` | string or null | | | `finished_at` | string or null | | | `evidence_expires_at` | string | When this run's step-by-step evidence is deleted: `created_at` plus the days its plan keeps evidence - 14 on Free, 90 on Pro, this deployment's default on a plan that sets none - or the days its plan kept when the run was created, whichever is longer, so a plan change never moves this date earlier and an upgrade moves it later. The screenshots, the video, the agent and surface step-event streams, the checkpoints and the per-session observation documents go at this date, in every bucket they live in. Earlier when they went earlier: a run whose recordings the storage had already removed is found by the retention sweep, which takes the rest of its evidence and records when, and that moment is this date. The run's *record* is kept and has no expiry: this response, its issues, its verdicts, its opportunities, the spec it ran and the assembled artifact. A reader that wants a screenshot after that date must keep the bytes, not the ref. | | `evidence_expired` | boolean | Whether that date has passed. `GET /api/v1/blobs` on this run's session evidence answers 404 once it has, and that 404 means "expired", not "never existed" - which is why the date is published beside it. | | `workflow_id` | string or null | | | `budgets` | object | | | `run_spec_ref` | string | | | `files` | array of RunFile | The files the run hands its personas, in the spec's order: its `files[]`, without the rest of the spec. Empty for a run that names none. | | `live` | LiveStatus or null | | | `sessions` | array of SessionRow | | ## `get_issues` **Read-only.** A run's issues as triage rows, most severe first: title, type, severity, route, confidence, who it affected and whether it reproduced. Issues are defects, gated on reproduction: a row with may_file_ticket_alone=true reproduced without the persona layer. Empty with the run's status - not an error - until the artifact is assembled. Call get_issue for one issue in full: its description, repro steps and evidence refs. About 550 characters an issue. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `run_id` | string | yes | | Run id. | | `min_severity` | one of `"low"`, `"medium"`, `"high"`, `"critical"` | no | `"low"` | Lowest severity to return. | | `reproducible_only` | boolean | no | `false` | Only issues that survived reproduction, i.e. that may file a ticket alone. | ### Returns | Field | Type | Description | | --- | --- | --- | | `run_id` | string | | | `status` | string | The run's status. An empty `issues` is only a result once this is terminal. | | `issues_total` | integer | The run's issues before the filter, so a narrowed answer says how much it left out. | | `issues` | array of IssueRow | | ## `get_issue` **Read-only.** One issue in full: the contract object verbatim, its reproduction result, deterministic persona-free repro steps, preconditions, the reading that says whether it may file a ticket alone, and the evidence refs get_screenshot accepts. Fields named in subjective_fields (persona_rationale) are the persona's voice, not observed behaviour. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `run_id` | string | yes | | Run id. | | `issue_id` | string | yes | | Issue id, as returned by get_issues. | ### Returns | Field | Type | Description | | --- | --- | --- | | `run_id` | string | | | `issue` | Issue | A product defect the run found: what the product did wrong, where, in how many of the sessions that got there, with the evidence and the steps that reproduce it. `claim.verification.status` says what checked it, and only a `verified` issue - `reproducible: true` - may be filed as a ticket on its own. `description` is objective; `persona_rationale` is the persona's voice. | | `reading` | IssueReading | How one issue of `IssueList.issues` may be acted on: whether it may be filed as a ticket on its own, and which of its fields are the persona's voice rather than observed behaviour. | | `evidence_refs` | EvidenceRefs | The store URIs on one issue, gathered where a consumer that wants to *fetch* evidence looks for them. `screenshots` are what the MCP `get_screenshot` tool accepts. The other two are not - it serves images only - and are fetched over `GET /api/v1/blobs?ref=` with the same key, which is why they are named here rather than left for a caller to dig out of `issue.evidence`. | ## `get_verdicts` **Read-only.** ADVISORY. Each persona's own verdict at the end of its session, with the diary quotes it is grounded in, plus the cohort rollup. Subjective feedback: attribute it to the persona and never file it as a defect. Each row names the question its verdict answers (verdict_prompt) and whose it was (verdict_prompt_source: the run's mission or the persona pack); a row with neither had no question in play. A row with no verdict says why when there is a reason: verdict_discarded is an answer the judge gave that could not be grounded in the persona's diary, so it was thrown away - never the persona's verdict and never counted, which is why the rollup has the session undecided - and observer_error is a session no judge read. About 600 characters a session. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `run_id` | string | yes | | Run id. | ### Returns | Field | Type | Description | | --- | --- | --- | | `run_id` | string | | | `status` | string | The run's status. An empty `verdicts` is only a result once this is terminal. | | `summary` | Verdicts or null | The artifact's cohort read (`summary.verdicts`). | | `verdicts` | array of VerdictRow | | | `advisory` | string | How to read this whole answer: persona feedback, not defects. Never a ticket on its own. | | `subjective_fields` | array of string | Fields on each row that are the persona's opinion - for a verdict, the verdict itself. | ## `get_opportunities` **Read-only.** ADVISORY. Behaviour-grounded persona feedback that is not a defect: unmet expectations, missing capabilities, value and trust gaps, delight. Scored by consensus (the fraction of sessions that reached the area and raised it), never by reproduction, and never a ticket on their own. Returned in display order; `display.shown` are the records that clear the cohort floors (40% consensus across at least 2 sessions, or solicited by the mission) and `display.folded` are single-persona notes - the same split GitHub and the dashboard show. About 1,300 characters an opportunity; min_consensus narrows a long list. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `run_id` | string | yes | | Run id. | | `min_consensus` | number | no | `0` | Lowest consensus to return, 0 to 1. 0.5 means half the sessions raised it. | ### Returns | Field | Type | Description | | --- | --- | --- | | `run_id` | string | | | `status` | string | The run's status. An empty `opportunities` is only a result once this is terminal. | | `opportunities` | array of Opportunity | | | `advisory` | string | How to read this whole answer: behaviour-grounded persona feedback, consensus-scored, never a ticket on its own. | | `subjective_fields` | array of string | Fields on each opportunity that are opinion. `description` is deliberately not among them. | | `display` | DisplaySplit | The run's shown/folded split, present even when the run raised nothing: the floors are the answer to "why is this list short", and they are the same whether or not anything cleared them. | ## `get_tester_errors` **Read-only.** HARNESS-ATTRIBUTED, NEVER A DEFECT. Findings this run attributed to the persona's own action rather than to the product: a mistyped credential, the wrong element, placeholder text read as a value. They carry the same shape as an issue - same id, same evidence, same screenshots - and they are not issues: not in issues_total, not reproduced, never a ticket. Read them for why a session stalled and for what to fix in the test setup. Each one quotes the recorded line that shows what the persona did. Each record is the whole issue-shaped object; reading[] gives its label, what the persona did in the words the dashboard and the PR comment use. Empty with the run's status until the artifact is assembled; classified=false means the run's artifact predates the classification, not that it found none. About 2,500 characters a record. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `run_id` | string | yes | | Run id. | ### Returns | Field | Type | Description | | --- | --- | --- | | `run_id` | string | | | `status` | string | The run's status. An empty `tester_errors` is only a result once this is terminal. | | `classified` | boolean | Whether the run's artifact sorts findings by whose failure they are at all. Empty with this false is an artifact from before the classification (contracts 0.5), or no artifact yet: nobody asked. Empty with this true is a run that asked and found none. | | `tester_errors` | array of Issue | The artifact's `tester_errors[]`, each the contract's own issue object with its `tester_error` block. | | `reading` | array of TesterErrorReading | One entry per record, in the same order and naming its `tester_error_id`. | | `advisory` | string | How to read this whole answer: harness-attributed, never a defect. What it may and may not be used for. | | `subjective_fields` | array of string | Fields on each record that are somebody's words rather than an observation: the persona's narration, and `tester_error.reason`, the harness's sentence about its own persona. | ## `diff_vs_baseline` **Read-only.** What changed since the run's baseline (its spec's baseline_run_id), as the run's artifact measured it: issues new on this run, issues of the baseline this run resolved, issues that persist, and the persona sessions that regressed - completed on the baseline and not now. Only a reproduction-gated issue is ever new or resolved; a persisting one may not be, so may_file_ticket_alone on each row still decides what may be filed. A regressed session is a signal, never a defect: it is reportable only through its new_issue_ids. Resolved rows are the baseline's issues, read whole with get_issue on baseline_run_id; every row carries the evidence refs get_screenshot and get_evidence_url accept. Refused, with the reason, for a run that named no baseline or was never compared with it, and until the run's artifact is assembled. About 450 characters an issue. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `run_id` | string | yes | | Run id; its baseline is the one its spec named. | ### Returns | Field | Type | Description | | --- | --- | --- | | `run_id` | string | | | `baseline_run_id` | string | | | `issues_new` | integer | | | `issues_resolved` | integer | | | `issues_persisting` | integer | | | `new` | array of DiffIssueRow | This run's issues the baseline did not have, on routes it reached. Only a reproduction-gated issue is ever new, so every row here may file a ticket alone. | | `resolved` | array of DiffIssueRow | The baseline's issues this run no longer has, with the baseline's ids and evidence: read one whole with `get_issue` on `baseline_run_id`. Only an issue that reproduced there, on a route this run reached, counts. | | `persisting` | array of DiffIssueRow | This run's issues the baseline had too, reproduced or not: persisting is a fact about both runs' findings, not a new claim about the product, so `may_file_ticket_alone` on each row still decides what may be filed. | | `regressed_sessions` | array of RegressedSession | | | `advisory` | string | How to read `regressed_sessions`: a signal, never a defect on its own. | | `note` | string or null | Present when the artifact counts resolved issues it does not all name: one written before contracts 0.6.2, or a baseline assembled again since. | ## `get_session_trace` **Read-only.** One session's step-by-step ledger: the persona's diary line beside the surface the browser saw - url, inputs dispatched, network failures, console errors, screenshot_ref. Step ids match the step_refs on issues and verdicts. Readable while the session is still running, in which case `status` says `in_flight`, more steps are coming, and the session video is not stored yet. By default each step is its diary: the action, what the persona said and perceived, the url and the screenshot_ref it was looking at - about 300 characters a step, which is what 'what was it thinking' needs. detail='full' adds the surface block - inputs dispatched, network failures, console errors, timing, tokens, video refs - at about 1,500 a step. With no window this returns the last 60 steps of a longer session; page back with from_step/to_step (inclusive). No answer is more than 25,000 characters: a window that would be is cut to the steps that fit - the latest of an unwindowed ask, the first of an explicit one - and from_step/to_step are the steps that came back, so the next page starts at to_step + 1. `total_steps` is the whole trace. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `run_id` | string | yes | | Run id. | | `session_id` | string | yes | | Session id, as returned by get_run. | | `from_step` | integer or null | no | `null` | First step id to return, inclusive. | | `to_step` | integer or null | no | `null` | Last step id to return, inclusive. | | `detail` | one of `"diary"`, `"full"` | no | `"diary"` | diary: per step, the action, the persona's words, the url and the screenshot_ref. full: every field, the surface block included, at several times the size. | ### Returns | Field | Type | Description | | --- | --- | --- | | `run_id` | string | | | `session_id` | string | | | `persona_id` | string | | | `mission_id` | string | | | `status` | string | `in_flight` while the session has not been recorded yet: the steps below are what the persona and the browser have written so far, both halves joined, and more will follow. The one thing a live page lacks is the session video, which is stored when the browser closes: `video_ref` and each step's window are there, `video_url` is absent until the page is final. A step whose surface half is missing on a live page is one the browser has not answered yet or a real gap, as on a final one. Otherwise the session's own status (`completed`, `abandoned`, `blocked`, `errored`, `budget_exhausted`, `cancelled`), and the page is final. | | `heartbeat_step` | integer or null | LIVE. The `step_id` the persona last reported: where the browser has got to, which is at or one ahead of `last_step_id` while a step is in flight - a step is written after the browser answers, so a step in flight shows here first. Only asked for while `status` is `in_flight`: absent on a final page, and on a live one whose live state cannot be read. | | `last_heartbeat_at` | string or null | When that heartbeat reached the server: how stale `heartbeat_step` is. Absent whenever it is. | | `waiting_for_seat` | boolean or null | LIVE. `true` while the session is queued for a browser seat, which is why a live page can have no steps at all: every seat in the pool is taken, and it takes no steps until one frees. A session resuming after a pause or a pool failure queues the same way, so it can be `true` on a page that has steps. Only asked for while `status` is `in_flight`: absent on a final page, and on a live one whose live state cannot be read. | | `waiting_since` | string or null | When the session was queued for the seat it is waiting for: how long it has waited. Absent whenever `waiting_for_seat` is not `true`. | | `workflow_id` | string or null | The session's workflow id, as `SessionRow.workflow_id`: what `heartbeat_step` was read off. | | `from_step` | integer or null | The inclusive lower bound in effect, whether it came from `from` or `after` + 1. | | `to_step` | integer or null | | | `total_steps` | integer | Steps in the whole trace, before the `from`/`to` window - not the count of `steps`. | | `first_step_id` | integer or null | | | `last_step_id` | integer or null | The last step in the whole trace. On an `in_flight` page this is what a tailing caller passes back as `after` to get the next batch; absent means the session has written no step yet. | | `agent_trace_ref` | string or null | | | `surface_trace_ref` | string or null | | | `video_ref` | string or null | | | `video_url` | string or null | | | `steps` | array of AlignedStep | | ## `get_screenshot` **Read-only.** Fetch one screenshot as an image, by a ref from an issue's evidence or a trace step's screenshot_ref. Refs outside this run's evidence are refused. Images only, at most 2 MiB; anything else - a video, a DOM snapshot, a raw trace - is a call to get_evidence_url, which answers a URL to fetch instead of bytes to read. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `ref` | string | yes | | Screenshot URI, e.g. s3://bucket/run/session/shots/0012.png. | ### Returns Content without a declared schema (for example an image). ## `get_evidence_url` **Read-only.** The URL one piece of evidence is fetched from, with its content type and its exact size in bytes - and none of the bytes. This is the way to a session video, a screenshot too large to look at inline, and anything else get_screenshot refuses: a plain GET on the URL with the same Authorization header you are configured with, which honours Range, and HEAD for the size again without a body. Read size_bytes before you fetch: evidence is megabytes, and a video is not something to read into a conversation. The ref is checked against the evidence store and the run it belongs to before any URL is answered, so a URL from this tool is one your key can fetch. Images and video only - the raw agent and surface traces and the run spec are not served here, and the modelled view of a session is get_session_trace. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `ref` | string | yes | | Evidence URI, e.g. s3://bucket/run/session/video.webm. | ### Returns | Field | Type | Description | | --- | --- | --- | | `ref` | string | The store URI asked about, unchanged - what a trace or an issue's evidence recorded. | | `url` | string | The absolute `GET /api/v1/blobs?ref=` URL, fetched with the same API key and honouring `Range`. | | `content_type` | string | What the route will answer as `Content-Type`, from the ref's file suffix. | | `size_bytes` | integer | The whole blob's size as the store reports it, so a caller knows the cost before it pays it. | ## `validate_spec` **Read-only.** Check a run spec without starting anything, and see what the run would be. Runs every refusal start_run runs - the contract, the cross-references inside providers, whether each providers entry is on a provider Saram resolves, the single-user rule on a ChatGPT login, whether every awssm:// credential_ref is one your tenant uploaded, whose uploaded files the spec names, whether your caller has a model provider at all, and - for a providers block on your ChatGPT login - whether the gateway holds that login to sign with - then answers the spec as the run would store it and the sessions it would plan, one per persona per mission, plus warnings: a ChatGPT login whose access token has lapsed or whose gateway could not be asked, and a model Saram has no price for, which is new or misspelt. It also answers what the run would cost: cost_ceiling.usd is the most the run can bill on your plan, in dollars, on every plan (0 when your plan bills nothing), with a one-line basis and model_calls, who bills the model calls on your own provider, which are never in the figure. It is a ceiling, not a forecast - every step counted at its most - so when a person asks what a run would cost, answer 'up to $' from this one call and quote the basis. Where your plan is billed in Usage, usage_ceiling says the most Usage the run can use, the balance available to cover it, and the steps_per_session that would fit; start_run refuses a run it does not cover when enforced is true. Free, instant, and creates nothing: call it before every start_run. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `spec` | object | yes | | A run spec, the RunSpec contract. Required: target.entry_url, personas (id@version addresses from list_personas), missions (each an id, a goal in words a persona would understand and at least one success_criteria {kind, value}) and budget.steps_per_session. The example is the smallest spec that validates; everything else is optional. | ### Returns | Field | Type | Description | | --- | --- | --- | | `run_id` | string or null | The run id the spec carries. Absent means `POST /runs` would assign one. | | `spec` | object | The spec as the run would store it. | | `sessions` | array of PlannedSession | | | `warnings` | array of string | What the dry run can say about the spec but does not refuse it for, one sentence each, the ones about your ChatGPT login first. Today: providers entries on a ChatGPT login whose access token has lapsed (the gateway refreshes it on the run's first model call, and the run fails if that refresh is refused) or whose gateway this API could not ask; a providers entry whose model this platform's price table has no price for, which is either a model too new for the table or a misspelt id that fails at the run's first model call; and a budget.max_concurrent_sessions above what your plan runs at once, which the run honours by running fewer sessions at a time. | | `cost_ceiling` | CostCeiling or null | What the run would cost: the most it can bill on your plan (usd), with a one-line basis and who bills its model calls. A ceiling, not a forecast. On every plan; absent only for a caller this deployment has no tenant for. | | `usage_ceiling` | UsageCeiling or null | The most Usage this run can use and the balance it is held against. Absent when this deployment's run gate is off, or your plan is not billed in Usage. | ## `start_run` **Changes state, reaches outside Saram.** Start a run from a run spec. BILLABLE: this drives real browsers with real model calls and takes minutes - validate_spec first, and only start a run a person has just asked you for. Answers a run_id and a workflow_id and nothing else, so wait_for_run is the next call. The spec is posted exactly as you give it: no providers block is added for you (get_provider_status prints one to paste), no credential_ref is rewritten and no budget is changed. The two fields it fills in when you leave them out are trigger.kind, which becomes 'api', and trigger.actor, which becomes your caller name. Refuses a second run while one you started is still in flight, unless you pass allow_concurrent. A run your plan cannot pay for is refused with an error whose structured content is {detail, usage_ceiling} or {detail, monthly_cap}: usage_ceiling, present when the balance does not cover the run, is the one validate_spec answers; monthly_cap, present when the month has billed your monthly cap, is {period, billed_usd, cap_usd}. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `spec` | object | yes | | A run spec, the RunSpec contract, as validate_spec accepted it. | | `allow_concurrent` | boolean | no | `false` | Start this run even though one you started is still in flight. Say true only because a person asked for both runs. | ### Returns | Field | Type | Description | | --- | --- | --- | | `run_id` | string | The run's id: the handle every other run route and tool takes. | | `workflow_id` | string | The id of the workflow executing the run, for support requests. | ## `get_run_status` **Read-only.** Where a run has got to: its status, whether it is done, how many sessions are in each state, what it has cost, and for each session that has not finished the heartbeat_step it last reported - the only field that says where a session in flight is. A finished session says whose failure ended it (fault), and one that errored says why (end_reason). To wait for a run rather than watch it, use wait_for_run; get_run is the same run with its spec, budgets and trace refs. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `run_id` | string | yes | | Run id, as start_run returned it. | ### Returns | Field | Type | Description | | --- | --- | --- | | `run_id` | string | | | `status` | string | `pending`, `running`, `observing`, then one terminal value - the same vocabulary `RunSummary.status` carries, with the same meaning. Read `done` rather than comparing against a list of them. | | `done` | boolean | Whether `status` is terminal (`completed`, `cancelled`, `failed`), i.e. nothing more will happen to this run. What a polling agent loops on; `status` then says how it ended and `completed` is the only one with results to read. | | `status_reason` | string or null | Why the run ended `failed` or `cancelled`, as `RunSummary.status_reason` says it. Absent otherwise. | | `session_counts` | object of integer | | | `cost_usd` | number or null | | | `artifact_ref` | string or null | Set once the artifact is assembled, which is what makes the issue and feedback views non-empty. | | `sessions` | array of SessionProgress | | | `created_at` | string | | | `started_at` | string or null | | | `finished_at` | string or null | | ## `wait_for_run` **Read-only.** Wait for a run to finish, then answer exactly what get_run_status answers. Returns as soon as the run reaches a terminal status, or after timeout_s if it has not - so done=false means the timeout won, not that anything went wrong, and the way to keep waiting is to call this again. One call is one HTTP request, which is why timeout_s stops at 60; a run of any size takes several calls. Between them, say what the run is doing rather than waiting silently. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `run_id` | string | yes | | Run id, as start_run returned it. | | `timeout_s` | integer | no | `60` | How long to wait, in seconds, at most 60. | ### Returns | Field | Type | Description | | --- | --- | --- | | `run_id` | string | | | `status` | string | `pending`, `running`, `observing`, then one terminal value - the same vocabulary `RunSummary.status` carries, with the same meaning. Read `done` rather than comparing against a list of them. | | `done` | boolean | Whether `status` is terminal (`completed`, `cancelled`, `failed`), i.e. nothing more will happen to this run. What a polling agent loops on; `status` then says how it ended and `completed` is the only one with results to read. | | `status_reason` | string or null | Why the run ended `failed` or `cancelled`, as `RunSummary.status_reason` says it. Absent otherwise. | | `session_counts` | object of integer | | | `cost_usd` | number or null | | | `artifact_ref` | string or null | Set once the artifact is assembled, which is what makes the issue and feedback views non-empty. | | `sessions` | array of SessionProgress | | | `created_at` | string | | | `started_at` | string or null | | | `finished_at` | string or null | | ## `cancel_run` **Changes state, destructive, idempotent.** Cancel a run that is still going: its sessions stop where they are and the run ends as cancelled. There is no resume and no refund - what has been spent is spent, and the only way on from here is a new run. A run that is not running is refused rather than cancelled twice. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `run_id` | string | yes | | Run id. | ### Returns | Field | Type | Description | | --- | --- | --- | | `run_id` | string | The run the signal was delivered to. | | `signal` | string | The signal delivered: pause, resume or cancel. | ## `list_personas` **Read-only.** The synthetic personas this deployment can run, by the address a run spec names them with (id@version), with each one's pack and tags. Narrow to one pack with the pack argument, or call list_packs to see the cohorts. About 200 characters a persona. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `pack` | string or null | no | `null` | Only personas in this pack, matched exactly. | ### Returns | Field | Type | Description | | --- | --- | --- | | `personas` | array of PersonaSummary | | ## `get_persona` **Read-only.** One persona in full, as the registry holds it and as a run spec may inline it: who they are, what they are trying to do, how they behave under friction, and what they find hard. Read this to say whose experience a verdict or an issue is - never to speak as them. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `address` | string | yes | | id@version, or a bare id for the latest version. | ### Returns | Field | Type | Description | | --- | --- | --- | | `id` | string | | | `version` | integer | | | `name` | string or null | | | `pack` | string or null | | | `tags` | array of string or null | | | `backstory` | string or null | | | `vocabulary` | Vocabulary or null | | | `facts` | object of string or null | | | `conversation` | Conversation or null | | | `verdict_prompt` | string or null | | | `goals` | Goals | What the persona wants out of the visit, in their own terms: the `primary` goal, anything `secondary` they also want if they get that far, and how much they want it (`intensity`). All three go into the persona's briefing beside the mission's goal; what counts as success for the run is the mission's `success_criteria`. | | `priors` | Priors | What the persona knows before they arrive: how well they know this product, the language of its industry and technology in general, the expectations they bring from other products, and what they were told beforehand. Each goes into the persona's briefing and shapes what they understand, expect and notice. | | `action_policy` | ActionPolicy | How the persona works a page: how much of it they read, what they work it with, whether they wander or head straight for the goal, and how they treat defaults, help and errors. Each value becomes a line of the persona's briefing. | | `tolerance` | Tolerance | How much the persona puts up with before giving up. Saram counts, step by step, the steps without visible progress, the errors and slow responses, and the moments with no clear next action, and ends the session `abandoned` the moment one crosses its threshold here; the model playing the persona cannot argue with the count. The persona is also told these limits in their own terms. | | `constraints` | Constraints | The browser the persona uses: the kind of device, the screen size, the connection, the language, accessibility needs and the signed-in state they start in. Most of these set up the browser session rather than the persona's briefing. A replay of an issue runs with the same ones, and the issue lists them in `preconditions`. | | `voice` | Voice or null | | | `provenance` | Provenance or null | | ## `list_packs` **Read-only.** The persona packs - the cohorts a run draws from - with how many personas each holds and their addresses. A run spec has no pack field: it names personas one by one, so this is how you put a whole cohort in one. Personas that declare no pack are in none of these; list_personas has them. ### Arguments None. ### Returns | Field | Type | Description | | --- | --- | --- | | `packs` | array of PackSummary | Sorted by name. A persona that declares no pack is in no cohort and is in none of these - `list_personas` is what has all of them, the same way `RunSummary.packs` lists a run's cohorts and not its packless personas. | ## `get_provider_status` **Read-only.** Which model provider your runs are billed to: the state of your caller's own ChatGPT login (none, pending, connected, expired or error), what the gateway last saw of its consumer rate limit, and - once connected - the exact providers block to paste into a run spec, credential name included. Metadata about a login and never any part of one: no token, no routing key. When state is not 'connected', tell the person, and offer to sign them in to ChatGPT: with their go-ahead, start_chatgpt_login answers a link and a code for them to open in their browser. While state is 'pending', pending carries that link and code again - show them, rather than starting another. ### Arguments None. ### Returns | Field | Type | Description | | --- | --- | --- | | `caller` | string | | | `state` | one of `"none"`, `"pending"`, `"connected"`, `"expired"`, `"error"` | | | `pending` | CodexPending or null | | | `account_id` | string or null | | | `plan_type` | string or null | | | `connected_at` | string or null | | | `expires_at` | string or null | | | `last_refresh` | string or null | | | `error` | string or null | Why the last login attempt failed, when `state` is `error` and no gateway call was involved. The OAuth `error_description`, which cannot carry a token. | | `gateway` | CodexGateway or null | | | `providers` | CodexProviders or null | | ## `start_chatgpt_login` **Changes state, reaches outside Saram.** Start connecting the person's own ChatGPT subscription, or reconnecting one that get_provider_status says is not 'connected'. Call it only when the person has asked for it, in this session: a login is theirs alone and serves only the runs they start. Answers a verification_url and a user_code - show both to the person, exactly as given, and ask them to open the link in their own browser, sign in to ChatGPT, and enter the code. The sign-in is theirs to do: never open the link, sign in or enter the code for them. The code lasts expires_in seconds (15 minutes); once they say they are done, call get_provider_status, which says 'connected' and prints the providers block to paste into a run spec. Refused while an earlier code is still open (the refusal carries that link and code) and for a credential that is not a person's own. ### Arguments None. ### Returns | Field | Type | Description | | --- | --- | --- | | `caller` | string | Whose ChatGPT login this connects: the caller the credential identifies. | | `verification_url` | string | The page to open in a browser, where the person signs in to ChatGPT. Show it to them. | | `user_code` | string | The code the person enters on that page. Show it to them. | | `expires_in` | integer | Seconds until the code expires and the sign-in has to start again. | ## `create_share_link` **Changes state, reaches outside Saram.** Publish one finished session's recording as a public link: anyone holding the URL can watch it, with the persona's steps and narration, without signing in; the recording hides only what the browser pool masked when it recorded it (masked and warning in the answer say what that is), so the test account's email and whatever else the product showed are visible as the browser showed them; and it plays until it expires or you revoke it with revoke_share_link and the id this answers. Call it only when a person has asked to share this session, and show them the answer's warning before the link goes anywhere. The url is in this answer and never again - only a hash of it is kept - so a lost link is replaced by minting another and revoking this one. It expires after expires_in_days (30 unless you ask for 1 to 90) and never after the run's evidence does; capped_by_evidence says when that won. Refused while your tenant's public sharing is off, which only Saram turns on; for a session that has not ended, whose recording is not stored yet; for a run whose evidence has expired; and for a session the browser pool did not record under its mask, which paints over what the persona typed. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `run_id` | string | yes | | Run id. | | `session_id` | string | yes | | Session id, one of the sessions get_run lists. | | `expires_in_days` | integer | no | `30` | How many days the link plays, 1 to 90; 30 when omitted. Never past the run's evidence lifetime, whatever you ask for. No link lives forever and none is extended: mint a new one instead. | ### Returns | Field | Type | Description | | --- | --- | --- | | `id` | string | shr_...: the link's id, for listing and revoking it. Not the link itself. | | `url` | string | The public link. Shown once, here, and never again by any route. Anyone who has it can watch this session until it expires or is revoked, with no sign-in. | | `run_id` | string | | | `session_id` | string | | | `created_at` | string | | | `created_by` | string | key_... for an API key, usr_... for a person, operator for the operator. | | `expires_at` | string | | | `capped_by_evidence` | boolean | True when the run's evidence lifetime, and not the days asked for, set expires_at: the link ends with the recording. | | `masked` | boolean | True when the browser pool recorded the session under its mask: the value of every field the persona typed into, every one-time-code field and every data-saram-mask element are painted over in every frame. False only for a session of Saram's own, which is shared unmasked. | | `warning` | string | What anyone holding the link will see, what in the recording is masked and what is not, and how to revoke it. Show it to whoever posts the link. | ## `list_share_links` **Read-only.** Every public link minted on one run's sessions, newest first - live, expired and revoked - each with its id (what revoke_share_link takes), the session it plays, its last four characters, its state, who minted and who revoked it, when it expires and how often it was opened. Never a link itself: a URL is shown once, by create_share_link. public_shares says whether your tenant may share at all; while it is off no link plays, whatever its state. mask_required says whether a session of yours is shared only when the browser pool recorded it under its mask (recorded_under_mask on its session row): while it is true, create_share_link refuses any other. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `run_id` | string | yes | | Run id. | ### Returns | Field | Type | Description | | --- | --- | --- | | `run_id` | string | | | `public_shares` | boolean | Whether your tenant may publish sessions at all. Off, a new link is refused with a 403 and no existing link plays; their rows are kept and they play again, until their own expiry, once it is back on. | | `mask_required` | boolean | Whether a session of yours is shared only when the browser pool recorded it under its mask (SessionRow.recorded_under_mask): a share of any other is refused with a 409. True for every customer; false only for Saram's own sessions, which are shared unmasked. | | `shares` | array of ShareSummary | | ## `revoke_share_link` **Changes state, destructive, idempotent.** Stop a public share link playing: the next request for it is refused, a video already playing included once it asks for its next part. There is no undoing it; to share the session again, create_share_link mints a new link. Any credential of your tenant that may write runs revokes any of its links, not only the one that minted it. The link's record stays, with who revoked it and when, and asking again answers the same record. ### Arguments | Argument | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `share_id` | string | yes | | shr_...: the link's id, as create_share_link and list_share_links answer it. | ### Returns | Field | Type | Description | | --- | --- | --- | | `id` | string | | | `run_id` | string | | | `session_id` | string | | | `token_hint` | string | The link's last four characters, to tell your links apart. | | `state` | one of `"live"`, `"expired"`, `"revoked"` | live plays, expired and revoked do not. Read it beside ShareList.public_shares: while your tenant's sharing is off no link plays, whatever its state. | | `created_at` | string | | | `created_by` | string | | | `caller` | string or null | Who minted it, in words, when the credential had a name. | | `expires_at` | string | | | `revoked_at` | string or null | | | `revoked_by` | string or null | Who revoked it: any principal of your tenant that may write runs can. | | `view_count` | integer | How many times the replay was opened. Nothing about who opened it is kept. | | `last_viewed_at` | string or null | | --- # Providers and your own keys > Which model provider pays for a Saram run's model calls, how to store your own provider key, the ChatGPT login, and how a run spec's providers block names the model for each role. A run makes model calls for three roles: - **`actor`**: the persona deciding each step. - **`judge`**: the observer that reads each session and merges the run's findings. - **`reproduction`**: the check that asks whether a replayed failure supports the issue's claim. Saram bills the run itself ([Usage and billing](/billing/)). Who bills the model calls depends on where they are made, and that is what the spec's `providers` block says. ## Who pays for the model calls | The calls are made on | Who bills them | How the spec names it | | --- | --- | --- | | **Your own provider key**, stored with Saram | Your provider, on your account with them | A `providers` entry whose `credential_ref` is your key's `secret_ref` | | **Your ChatGPT login**, where Saram offers it | OpenAI, within your ChatGPT subscription's limits | The `providers` block [`codex_status`](/api/codex_status.md) answers | | **Saram's own credential** | Nobody separately | No `providers` block | **On the Free and Pro plans, every role is on your own provider.** A spec with no `providers` block, or one that leaves a role on a Saram-held credential, is refused with a `422` that names the role: ```text wrap plan 'free' pays for none of a run's model calls, and the actor role would make its calls on a Saram-held credential (this spec carries no providers block): bring your own key for it - POST /api/v1/credentials returns the credential_ref to name in providers.roles.actor ``` Saram's own models run where your organisation has an agreement with Saram for them, or on an older plan that includes them. `house_models: true` in [`get_usage`](/api/get_usage.md) says Saram carries your model spend. Every validate answer says who bills each role, in `cost_ceiling.model_calls`. A model call is never part of `cost_ceiling.usd`. ## Store your own key Supported providers, by the kind a credential is stored as: `anthropic`, `bedrock`, `openai`, `openrouter`, `azure`, `google`, `google-cloud`, `litellm` Store a key on the dashboard under **Settings > Credentials**, or with [`create_credential`](/api/create_credential.md). The key needs the `credentials:write` scope. Build the body with `jq`, so the key stays out of your shell history: ```sh jq -n --arg v "$ANTHROPIC_API_KEY" '{name: "anthropic", kind: "anthropic", value: $v}' | curl -s -X POST https://api.saram.sh/api/v1/credentials \ -H "Authorization: Bearer $API_KEY" -H 'content-type: application/json' -d @- ``` It answers `201` with the credential's metadata: ```json { "id": "...", "name": "anthropic", "kind": "anthropic", "secret_ref": "awssm://.../anthropic", "created_at": "2026-10-23T09:00:00Z" } ``` - **`secret_ref`** is what the spec names as `credential_ref`. Only your organisation's specs may name it; another organisation's spec naming it is refused. - **The value is never returned.** No response anywhere has a `value` field, and the value is not logged. [`list_credentials`](/api/list_credentials.md) answers metadata only. - **`name`** is lowercase letters, digits, `_` and `-`, up to 64 characters. A name you already have is a `409`. - **`value`** is the key as one string. For `google-cloud` it is the service-account JSON document. - **There is no replace.** To rotate a key, [`delete_credential`](/api/delete_credential.md) it, then store it again under the same name. The same route stores the other credentials a spec names: `account` for test logins ([The run spec](/run-spec/#logins-targetauth-and-targetseedaccounts)), `webhook` for a webhook's signing key, and `github` for a GitHub token. ## Name it in the spec The smallest block is one entry, which every role uses: ```json "providers": { "entries": { "main": { "model": "anthropic:claude-opus-5", "credential_ref": "" } } } ``` To put roles on different models, name several entries, pick a `default`, and bind roles to entries in `roles`: ```json "providers": { "entries": { "fast": { "model": "openrouter:anthropic/claude-opus-5", "credential_ref": "" }, "careful": { "model": "anthropic:claude-opus-5", "credential_ref": "" } }, "default": "careful", "roles": { "actor": "fast" } } ``` Here the personas run on OpenRouter, and the judge and the reproduction check on Anthropic. | Field | | | --- | --- | | `entries` | **Required**, at least one. Each name is lowercase letters, digits, `_` and `-`. | | `default` | The entry every role uses unless `roles` says otherwise. Left out with one entry, that entry. | | `roles` | `actor`, `judge`, `reproduction`: an entry name each. | An entry: | Field | | | --- | --- | | `model` | **Required.** `:`, such as `anthropic:claude-opus-5`, `openai:gpt-5.6`, `openrouter:anthropic/claude-opus-5`, `bedrock:us.anthropic.claude-opus-5`, `google:gemini-3-pro` or `azure:`. The prefix must be the kind the credential was stored as. | | `credential_ref` | The `secret_ref` of your stored key. Never the key itself: an entry has no field for one, and a field it does not list is refused. | | `base_url` | An endpoint in place of the provider's public one: a LiteLLM or other OpenAI-compatible gateway, an Azure resource, a Bedrock VPC endpoint. | | `region` | `bedrock`: the AWS region. | | `project`, `location` | `google-cloud`: the GCP project and the Vertex location. | | `api_version` | `azure`: the Azure OpenAI API version. | A `providers` block wins over `actor.actor_model` and `actor.judge_model`. Validate checks the whole block before anything starts: that every role resolves to an entry, that each prefix is a provider Saram resolves, and that every `credential_ref` is yours. A model Saram has no price for, which is either new or misspelt, is a warning rather than a refusal, so read `warnings`. For example: ```text wrap run spec rejected: providers entry 'j' names model 'nope-provider:abc', and 'nope-provider' is not a provider this platform resolves: the prefix must be one of anthropic, bedrock, openai, openrouter, azure, google, google-cloud, litellm ``` ## Your ChatGPT login Where Saram offers it, a run's model calls can be made on your ChatGPT subscription instead of a provider key: 1. [`codex_login`](/api/codex_login.md) (`POST /api/v1/providers/codex/login`) answers a `verification_url` and a `user_code`. Open the URL and type the code. 2. Poll [`codex_status`](/api/codex_status.md) (`GET /api/v1/providers/codex`) until `state` is `connected`. It is `none`, `pending`, `connected`, `expired` or `error`, and it shows the plan, when the login expires and the subscription's rate-limit use. 3. Once connected, the same answer carries the exact `providers` block to paste into a spec, the credential's name included. You never type one. The login is yours alone. It serves only the caller that connected it, and only runs that caller starts: a spec on it with `trigger.kind: ci` is refused, as a subscription login serves runs a person starts. A `403` from `codex_login` means Saram does not offer ChatGPT logins to your account. [`codex_disconnect`](/api/codex_disconnect.md) revokes the login and forgets it; your specs keep their `providers` block, so connecting again is all a later run needs. Over MCP, `get_provider_status` answers the same state and block, and `start_chatgpt_login` starts the login ([MCP tools](/mcp/tools/)). ## What was checked | Block | Checked | | --- | --- | | Storing a key | **Not run for this page**; the body and answer are the reference's `CredentialWrite` and `CredentialRecord` | | `list_credentials` | 2026-10-23 against `api.saram.sh` (platform-api 0.9.0): metadata only, no value | | The refusals for a ref that is not yours and for an unknown prefix | 2026-10-23, `POST /api/v1/runs/validate` | | The Free and Pro refusal without a `providers` block | **Not run**: the organisation used for this page is not on Free or Pro. The sentence is the platform's, for `plan 'free'` | | The ChatGPT login | 2026-10-23: `GET /api/v1/providers/codex` answered `connected` with a `providers` block; validate accepted that block with `trigger.kind: manual` and refused it with `ci`. `codex_login` not run | --- # Quickstart > From a dashboard API key to a first run and its results over HTTP - check the key, write a run spec, validate it for free, start the run, poll it, then read its issues, verdicts and session video. This page takes you from an API key to a finished run over plain HTTP, with `curl`. To do the same from Claude Code, Cursor or Codex, connect them to the MCP server instead: [Connect your coding agent](/connect-agent/) has the setup and the same first run as prompts. Every request goes to `https://api.saram.sh` with `Authorization: Bearer `. The [HTTP API reference](/api/) has every operation in full. ## 1. Get an API key Sign up or sign in at [dashboard.saram.sh](https://dashboard.saram.sh). Signing up creates your organisation. Mint a key under **Settings > API keys**. The dashboard shows it once: Saram keeps only a hash of it, so a lost key is revoked and replaced, never shown again. Put it in your shell, never in a file you commit: ```sh export API_KEY= ``` Check it with [`who_am_i`](/api/who_am_i.md), the one route every key may call: ```sh curl -s https://api.saram.sh/api/v1/me -H "Authorization: Bearer $API_KEY" ``` The answer names the key's caller, its organisation (`tenant`, with its `plan`) and what it may do (`scopes`). To start runs, the key needs `runs:write`; to read them, `runs:read`. Storing a model key needs `credentials:write`, and billing needs `keys:admin`. ## 2. Pick a persona A run sends personas through your product. Each one is named by its registry address, `id@version`: ```sh curl -s https://api.saram.sh/api/v1/personas -H "Authorization: Bearer $API_KEY" ``` ```json { "personas": [ { "address": "dana_first_timer@1", "id": "dana_first_timer", "version": 1, "name": "Dana Whitfield", "pack": "general", "tags": ["general", "novice", "desktop", "first_time"] } ] } ``` The list above is cut to its first entry. [`get_persona`](/api/get_persona.md) answers one persona in full: its goals, its tolerance and the question it answers at the end. ## 3. Bring a model provider The personas and the judge make model calls. On the Free and Pro plans they are made on your own provider: store a key from Anthropic, OpenAI, OpenRouter or another supported provider, and the run spec names it. Store one under **Settings > Credentials** on the dashboard, or over the API: ```sh jq -n --arg v "$ANTHROPIC_API_KEY" '{name: "anthropic", kind: "anthropic", value: $v}' | curl -s -X POST https://api.saram.sh/api/v1/credentials \ -H "Authorization: Bearer $API_KEY" -H 'content-type: application/json' -d @- ``` The answer's `secret_ref` is what the spec names. The key itself is never returned. [Providers and your own keys](/providers/) covers every provider, the ChatGPT login, and which model each role uses. ## 4. Write the smallest run spec A run spec says where to go (`target`), who goes (`personas`), what they try (`missions`) and how far each may go (`budget`). Save this as `spec.json`, with your own URL, the `secret_ref` from step 3, and in place of `/account` a part of the URL your product shows once someone is signed in: ```json { "target": { "entry_url": "https://staging.example.com" }, "personas": ["dana_first_timer@1"], "missions": [ { "id": "sign_up", "goal": "create an account", "success_criteria": [ { "kind": "url_matches", "value": "/account" }, { "kind": "judge_assertion", "value": "the persona reached a signed-in page" } ] } ], "budget": { "steps_per_session": 30 }, "providers": { "entries": { "main": { "model": "anthropic:claude-opus-5", "credential_ref": "" } } } } ``` Only the `url_matches` criterion is checked by machine: when the persona says it is finished, the session ends `success_criteria_met` if the last URL it was on contains `/account`, and `claimed_finished_unverified` if not. The `judge_assertion` is text the judge reads beside the goal. Without a `url_matches` criterion, every finished session ends unverified ([Success criteria: what is checked](/run-spec/#success-criteria-what-is-checked)). The URL must be one Saram's browsers reach over the internet: a staging deployment, a preview, or a tunnel to your machine ([Testing localhost and previews](/connect-agent/#testing-localhost-and-previews)). [The run spec](/run-spec/) explains every field. ## 5. Validate it (free) [`validate_run`](/api/validate_run.md) runs every check that starting a run runs, and starts nothing. It costs nothing, so call it before every run: ```sh curl -s -X POST https://api.saram.sh/api/v1/runs/validate \ -H "Authorization: Bearer $API_KEY" -H 'content-type: application/json' -d @spec.json ``` A spec that passes answers `200` with the spec as the run would store it, the sessions it plans (one per persona per mission), any `warnings`, and a `cost_ceiling`: ```json { "cost_ceiling": { "usd": 0.6, "sessions": 1, "steps_per_session": 30, "basis": "up to $0.60: 1 session x 30 persona steps at $0.02 a step on the pilot plan, which bills steps; judging and reproduction are included. ...", "model_calls": "Model calls, never in this figure: ..." }, "sessions": [ { "session_id": ".sess_01_dana_first_timer", "persona_id": "dana_first_timer@1", "mission_id": "sign_up" } ], "warnings": [] } ``` This answer is cut to its main fields, and it is from an organisation on an older plan that bills persona steps; on Free and Pro the `basis` counts Usage instead. `cost_ceiling.usd` is the most the run can bill on your plan, not a forecast: every step is counted at its most. `basis` says how it was reached, in your plan's terms, and `model_calls` says who bills the model calls. On Free and Pro, `usage_ceiling` also says whether your balance covers the run ([Usage and billing](/billing/)). A spec that fails answers `422` with a `detail` naming each field and what is wrong with it, for example a `credential_ref` that is not one of yours: ```text wrap run spec rejected: providers entry 'main' names awssm://... which is not this tenant's credential: upload the key with POST /api/v1/credentials and name the ref it returns ``` Fix the spec and validate again until it answers `200`. ## 6. Start the run :::caution[A run is billable] A run drives real browsers with real model calls and takes minutes. Start it once, by hand, and never in a retry loop. ::: ```sh curl -s -X POST https://api.saram.sh/api/v1/runs \ -H "Authorization: Bearer $API_KEY" -H 'content-type: application/json' -d @spec.json ``` [`create_run`](/api/create_run.md) answers `202` with the `run_id` at once, and the run starts in the background: ```json { "run_id": "run_2026-10-23_ab12cd", "workflow_id": "..." } ``` A `422` is a spec that fails a check, with no run created. A `402` is a run your plan cannot pay for now ([Usage and billing](/billing/)). ## 7. Poll until it finishes [`get_run`](/api/get_run.md) answers the run's `status`, which moves `pending`, `running`, `observing`, then stops on `completed`, `failed` or `cancelled`: ```sh RUN=run_2026-10-23_ab12cd while :; do status=$(curl -s https://api.saram.sh/api/v1/runs/$RUN \ -H "Authorization: Bearer $API_KEY" | jq -r .status) echo "$status" case $status in completed|failed|cancelled) break ;; esac sleep 15 done ``` Only `completed` means there are results to read. `failed` carries a `status_reason`. While the run is open, `billed_steps` and each session's `heartbeat_step` climb. To stop a run early, `POST /api/v1/runs/$RUN/cancel` ([`signal_run`](/api/signal_run.md)); a cancelled run is not judged. ## 8. Read what it found A run gives two kinds of output, and they are not the same thing ([Issues vs persona feedback](/issues-and-feedback/)): ```sh H="Authorization: Bearer $API_KEY" curl -s https://api.saram.sh/api/v1/runs/$RUN/issues -H "$H" # defects curl -s https://api.saram.sh/api/v1/runs/$RUN/verdicts -H "$H" # persona feedback curl -s https://api.saram.sh/api/v1/runs/$RUN/opportunities -H "$H" # persona feedback curl -s https://api.saram.sh/api/v1/runs/$RUN/tester-errors -H "$H" # the persona's own slips ``` - **Issues** ([`get_issues`](/api/get_issues.md)) are product defects. Beside them, `reading[]` says for each one whether `may_file_ticket_alone` is `true`: only then did a replay reproduce it, and only then file it as a confirmed bug. - **Verdicts** ([`get_verdicts`](/api/get_verdicts.md)) are each persona's answer at the end of its session, quoted from its own diary. **Opportunities** ([`get_opportunities`](/api/get_opportunities.md)) are what the personas' behaviour says the product could do better or already does well. Both are opinion: attribute them to the persona. - **Tester errors** ([`get_tester_errors`](/api/get_tester_errors.md)) are slips the persona made, such as a mistyped password. They are not defects and say what to fix in your test setup. ### The session video Each session is recorded unless the spec turns it off. [`list_sessions`](/api/list_sessions.md) gives each session's `video_url`, a path on the API that takes the same key: ```sh video=$(curl -s https://api.saram.sh/api/v1/runs/$RUN/sessions -H "$H" | jq -r '.sessions[0].video_url') curl -s -o session.webm "https://api.saram.sh$video" -H "$H" ``` The video is a WebM file. The route honours `Range` and `HEAD`, so a player can seek in it. Evidence is kept for your plan's retention period ([Usage and billing](/billing/#what-each-plan-includes)); the run says when in `evidence_expires_at`. ## Next - [The run spec](/run-spec/): every field, with a full example. - [Issues vs persona feedback](/issues-and-feedback/): what to file as a ticket, and what not to. - [Providers and your own keys](/providers/): who bills each model call. - [Usage and billing](/billing/): what a run costs and how you pay for it. ## What was checked | Block | Checked | | --- | --- | | `GET /me`, `GET /personas` | 2026-10-23 against `api.saram.sh`, platform-api 0.9.0; the answers' fields as shown, with the account's own values left out | | Storing a model key | **Not run for this page**; the body is checked against the reference's `CredentialWrite` schema | | Validate, `200` | 2026-10-23, the spec above without its `providers` block and its `url_matches` criterion, on an organisation on a plan that bills steps; the answer is cut to the fields shown | | The `url_matches` criterion | **Not run for this page**; added 2026-10-24 from the `RunSpecMission` schema in platform-api 0.11.0, which also gives the two `end_reason`s | | Validate, `422` | 2026-10-23, a `providers` entry naming a ref that is not the organisation's; the ref is elided here | | Starting a run | **Not run for this page**; the answer's fields are the reference's `StartedRun` | | Polling, the read routes and the video | 2026-10-23, on a finished run: the status, the four read routes, and `HEAD` on a `video_url` (`200`, `video/webm`, `accept-ranges: bytes`) | --- # The run spec > Every field of a Saram run spec - target, logins, personas, missions, budget, actor, trigger, connectors, files and providers - what each does, which are required, and a full example. A run spec is the JSON body of [`create_run`](/api/create_run.md) and [`validate_run`](/api/validate_run.md). 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 ```json { "target": { "entry_url": "https://staging.example.com", "name": "Example shop, staging", "auth": { "kind": "basic", "secret_ref": "" }, "seed": { "accounts": ["", ""], "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": "" } } } } ``` 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: ```json [ { "session_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": "" }, { "session_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": "" } ] ``` Each session's entry URL carries `?saram_sid=`, so your app can keep the sessions' data apart. The sections below take each field in turn. ## `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](/connect-agent/#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` 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`](/api/create_credential.md). 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](/connect-agent/#a-protected-preview-through-a-door) shows a door for Vercel and Netlify, step by step. ### `target.seed` | Field | | | --- | --- | | `accounts` | Test account refs, one per session (above). | | `reset_hook` | A URL Saram `POST`s 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) At least one persona, each named by its registry address `id@version`, such as `dana_first_timer@1`. [`list_personas`](/api/list_personas.md) lists them and [`get_persona`](/api/get_persona.md) shows one in full. :::note[Only registry personas run today] The contract also describes a persona written inline as an object. Validating or starting a spec with one is refused with a `422` saying only registry ids are supported. Name personas from `GET /api/v1/personas`. ::: ## `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](#success-criteria-what-is-checked)). 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 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, `=`, 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_reason`s, which [`get_run`](/api/get_run.md) 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. ## `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`](/api/signal_run.md)). | 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](/billing/)). ## `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](/providers/) has the whole block: `entries`, `default` and `roles`. ## `actor` 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` 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](/providers/#your-chatgpt-login)). ## `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` 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` | 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` 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=,v1=` header: HMAC-SHA256 over `.` 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. ## `files` 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`](/api/upload_file.md) (25 MiB at most; images, PDF, CSV, text, JSON, `.docx` and `.xlsx`), then name it here: ```json "files": [ { "ref": "saram-file:///", "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` Leave it out: the API assigns one. ## 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`](/openapi.json) | | Success criteria and `end_reason` | **Not run**: the `RunSpecMission` schema's `success_criteria` in platform-api 0.11.0's [`/openapi.json`](/openapi.json) | --- # Security > What Saram holds of yours, where it lives, who can read it, how you take it back, and how to report a vulnerability. Dated 2026-10-26. What Saram holds of yours, where each thing lives, who or what can read it, how it reaches a run, how it is kept out of the evidence a run publishes, how you take it back, and how long it lasts, down to what you choose to publish. Then what Saram never holds and how to report a vulnerability. Written for a customer and for anyone reviewing Saram on a customer's behalf. Every claim is true by a file, a commit or a recorded decision; the copy Saram's operators keep names it beside the claim, and the public copy of this page leaves those pointers out. No secret value appears here and none ever will. ## Scope Saram is a behavioral QA harness: synthetic personas drive a real browser through your product and the run produces an evidence-backed artifact. To do that it needs a door into your product and a way to pay for model calls, which is the whole of why it holds anything at all. One deployment exists, the dev stack on AWS (`https://api.saram.sh`, which was `https://api.saram.online` before 2026-09-24), and the current milestone is a private pilot on it. There is no production deployment yet, so everything below describes dev. Dev is not a place for customer data. ## What Saram holds, by class Ten classes. Each table answers the same seven questions, in the same order. Class 7 splits the last one into what is decided and what is deployed, because on dev today they differ. Class 9 is what you choose to make public, so where the others say how a thing reaches a run, it says how it leaves Saram. Class 10 is what a visitor with no account hands Saram on the homepage, and what Saram hands back. ### 1. Your Saram API key | | | |---|---| | What | One machine credential for your tenant, `sk_saram__` plus 40 base62 characters. The prefix is what lets GitHub's secret scanning recognise a leaked one. | | Where it lives | An `api_keys` row in Saram's Postgres, as `sha256` of the whole plaintext beside the first eight characters of the random part. The plaintext exists once: in the reply to `POST /api/v1/api-keys`, or on the last line of `saram-tenants key mint`. It is stored nowhere. | | Who can read it | Nobody, including Saram. The gate looks the row up by those eight characters and compares digests with `hmac.compare_digest`. `GET /api/v1/api-keys` lists metadata only, no secret and no hash. The key is never printed, never put on a span, never recorded. | | How it reaches a run | As `Authorization: Bearer` on your request. The gate turns it into a `Principal` (tenant, subject, caller, scopes) and the key itself goes no further. | | In the evidence | Not in it. `runs.created_by` holds the key's id (`key_...`) and `runs.caller` its label; the artifact carries neither. | | How you revoke it | `DELETE /api/v1/api-keys/{key_id}`, or `saram-tenants key revoke`. The row stays with `revoked_at` set so the runs it started keep pointing at something. `--expires-in-days N` refuses it at the gate after N days. Rotation is mint, move the caller, revoke. Suspending the tenant stops every key at once without touching one. | | Retention | The row is kept. The secret was never stored, so losing it means minting another. | ### 2. Your people's sign-in | | | |---|---| | What | Who a person is and which organisation they act for. Saram stores `users.idp_subject`, `users.email` (nullable) and a `memberships` row carrying their role. | | Where it lives | The credential itself is WorkOS AuthKit's: sign-up, sign-in, verification, password reset, organisations and invitations are theirs. Saram never sees a password. The dashboard holds a sealed `wos-session` cookie, server-side sealing only. The API holds no session at all. | | Who can read it | The API verifies the access token per request against a cached key set, with `alg` pinned to RS256, `iss` choosing the key set, `exp` and `iat` checked. No vendor SDK and no call to WorkOS on the request path. The token is never logged. | | How it reaches a run | The dashboard's proxy forwards the person's own token; the dashboard holds no key of its own and the pod is deployed without one. At `/mcp` a coding agent signs in through AuthKit and gets a token bound to that resource. | | In the evidence | `runs.caller` records the email, or the provider subject until an address arrives. `trigger.actor` is filled from the caller when a run starts over MCP. The artifact carries neither. | | How you revoke it | Remove the seat at WorkOS. The signed webhook mirrors it and the `memberships` row is what decides the role, never the token's `role` claim, so a demotion takes effect on the person's next request rather than when their session expires. Deleting the organisation suspends the tenant, never deletes it. | | Retention | Removing a seat at WorkOS deletes that organisation's `memberships` row; the `users` row stays. Deleting the user at WorkOS deletes their `users` row and their `memberships` row in every organisation. The runs they started are kept. `runs.created_by` holds the person's user id as text, not as a foreign key, so it outlives the deleted row and then names no user. `runs.caller` keeps what it was written with, the email or the provider subject. | ### 3. Your model provider keys | | | |---|---| | What | The provider or router key that pays for your run's model calls. Bring-your-own keys are the product. | | Where it lives | AWS Secrets Manager, at `saram//tenants//providers/`. The `provider_credentials` row in Postgres records the name, the kind and the `awssm://` ref, never the value. | | Who can read it | The three model-calling workers (actor, observer, reproduction), at the start of the activity that makes the call. The API holds `CreateSecret`, `PutSecretValue`, `DescribeSecret` and `DeleteSecret` on that prefix and **no** `GetSecretValue`, so "the API cannot read a customer's provider key" is a property of the deployment and not a promise about its code. | | How it reaches a run | As a reference and never as a value: `providers.entries[].credential_ref` in your run spec. Resolved in the worker that makes the call, with no cache, so a rotation is live on the next session. The resolved value enters no Temporal payload, span, event, checkpoint or artifact. `POST /runs` refuses any `awssm://` ref outside your tenant's prefix with a 422 naming the entry. | | In the evidence | The ref is in the spec, which the artifact carries, because a spec is evidence of what the run was asked to do. The value is in the `Model` object and nowhere else. Spans carry `saram.provider.name`, `.kind` and `.model`, never the credential and never `base_url`. | | How you revoke it | `DELETE /api/v1/credentials/{name}`. It force-deletes the secret with no recovery window, because a credential you withdrew has to stop being usable now. The row stays with `deleted_at` set as the only remaining record of what you had, and the name is free again. There is no replace: rotation is `DELETE` then `POST`. | | Retention | As long as you keep it. Nothing expires it. | ### 4. Your target's door: basic auth and bypass headers | | | |---|---| | What | The credential that gets a browser past your staging host's own gate, rather than past your product's sign-in: HTTP basic auth (`entry.basic`), or a bypass header such as `x-vercel-protection-bypass` or a Cloudflare Access service token (`entry.headers`). | | Where it lives | Today: one deployment-wide JSON document in Secrets Manager, mounted read-only on the browser pool and the actor worker at a path the deployment configures. There is no customer channel for it, and `secret://` refs are not tenant-checked. | | Who can read it | The pool, which applies `basic` as Playwright `httpCredentials` and `headers` as `extraHTTPHeaders` on the browser context. Never rendered into a persona prompt: a persona cannot type into a realm. | | How it reaches a run | `target.auth.secret_ref` in the spec, carried to the body as `session_create.target_auth_secret_ref` by the actor and by every replay attempt. | | In the evidence | The contract requires the pool to keep it out of every log line, span attribute and surface event, as it keeps request headers out. The pool half is not written yet, so nothing is applied and nothing is recorded: a run against a gated target ends its sessions `errored` at setup, saying so. | | How you revoke it | Rotate it at your own host, and ask the operators to remove the entry. You cannot do it yourself today. | | Retention | Until the entry is removed by hand. | ### 5. Your test accounts on the target | | | |---|---| | What | The account a persona signs in with: an email, a password, whether that address has a readable inbox, free-text notes, or pre-authenticated cookies instead of a login. | | Where it lives | The same deployment-wide secret store as class 4, with the same limits. | | Who can read it | Two readers with different appetites. The persona prompt renders `email`, `password`, `inbox` and `notes` and never sees a cookie; the pool applies `cookies` to the browser context and never reads the login. Each ignores the other's fields, and the entry is closed (`x-closed`), so a field the contract does not list is refused rather than dropped in silence. | | How it reaches a run | `target.seed.accounts` in the spec, as a `secret://` ref. | | In the evidence | Three sinks are closed and one is open. The actor's checkpoint replaces the password with `[redacted credential]`; pydantic-ai's content export is off, so no span carries it; the agent step-event writes a marker, `{"text": "[secret:secret://...]", "secret": true}`, in place of the typed value, and the artifact's `repro_steps` carry the same marker, which reproduction resolves back at replay through its own store. The open sink is the pool's surface trace: the published pool does not advertise the `type.secret` capability, so the actor sends the characters unflagged and `surface.ndjson` still records what was typed. | | How you revoke it | Change the password on your own product, and ask the operators to remove the entry. | | Retention | The entry until it is removed; the trace that records the session for your plan's evidence retention (class 7). | ### 6. Files you give a persona | | | |---|---| | What | A file a persona hands to your product: a logo for an onboarding upload, a purchase order at checkout. | | Where it lives | Two objects in the evidence store, `files///blob` and `.../meta.json`. You name it only as `saram-file:///`. | | Who can read it | The pool, which fetches exactly the URIs the control plane gives it and never lists and never browses. **No route serves an uploaded file's bytes back at all**, yours included; `GET /api/v1/blobs` refuses any key whose first segment is not a run, which is what makes it safe to keep files in the evidence bucket. Another caller's file id is a 404, not a 403. | | How it reaches a run | The spec's `files[]` block, by ref. `POST /runs` refuses a ref that names another caller, one whose metadata is not in the store, one past its `expires_at`, or more files or bytes than the deployment allows. | | In the evidence | The surface event carries the name, the size and the SHA-256, never the bytes. The persona never sees a path: the pool's per-session temp directory is the only place one exists and it dies with the session. Bytes appear in no log line, span, checkpoint or event. | | How you revoke it | `POST /api/v1/files/{file_id}/delete`, at any time. The run's evidence never copied the bytes, so that deletes the only copy. | | Retention | 30 days, stamped at upload as `expires_at` from the deployment's file lifetime, and read back from what was promised at upload rather than from the deployment's current setting. Four things may delete a file: you, `create_run` refusing a run that names an expired one, a bucket lifecycle rule on the `files/` prefix, and the `saram-retention` sweep. All four are on dev: the `files/` rule at 30 days and the daily sweep since the deploy of 2026-10-21 (class 7, "Retention, as deployed"). | ### 7. What a run records: traces, recordings and checkpoints | | | |---|---| | What | The evidence a run is worth reading for: the agent and surface step-event streams, the actor's checkpoints, `observation.json` and `gate.json`, and on the recordings side a screenshot per step, a video per session and the pool's browser checkpoints, which are Playwright `storageState` and therefore whatever cookies and local storage your product set for that persona's session. | | Where it lives | The evidence bucket under `/...` and the recordings bucket under `//...`. The layout is by run id; the browser pool goes on not knowing that tenants exist. | | Who can read it | `GET /api/v1/blobs`, gated with everything else and scoped to your tenant. A run outside it is a 404 byte-for-byte identical to the 404 a run id nobody ever used gets, because a 403 would confirm that a guessed id is real. That check is the whole of the S3 isolation. Presigned S3 URLs are refused on purpose: a presigned URL is bearer authority on the object and leaves both the gate and the ownership check behind. The dashboard proxies media same-origin and adds the header server-side. One exception is built: a share link (class 9) lets anyone holding it read one finished session's video and an allowlisted view of its steps, through two anonymous routes that proxy the video and never presign it. It opens that one session and nothing else: no screenshot, checkpoint or trace, and no other session or run. A customer tenant's session can be shared only if it was recorded under the pool's mask and an operator has turned the tenant's switch on (class 9). | | How it reaches a run | It is produced by one. | | Redaction | Typed credentials, as in class 5. Narration and target descriptions also lose a one-time code (`[redacted code]`) and a key-shaped string (`[redacted key]`) by pattern, in the actor before the line is stored, so the dashboard, MCP and a share page read one text; a session recorded before that reached dev keeps its narration as it was. The account email is kept on purpose. The pool masks the stored screenshots and video at record time, as solid black boxes: the value of every field the persona typed into, every `autocomplete="one-time-code"` field and every element carrying `data-saram-mask`, with what is inside it, on dev since 2026-10-23. Nothing else on screen is changed: text your product rendered, the account email and another user's data are in the recording as the browser painted them, and you take one of those out by marking it `data-saram-mask`. What the persona is shown is not masked, because it has to read the page it tests; only what is stored is. A session recorded before the masking pool reached dev is unmasked, and the mask has limits. Treat a run's recordings as being as sensitive as the account it ran on. | | How you revoke it | `POST /api/v1/runs/{run_id}/delete`, on a run of your own tenant with a key carrying `runs:write`. It deletes the run's traces, checkpoints, screenshots, video and own documents in both buckets, then the run and every row below it; asked again it answers the same, and another tenant's run is the same 404 an unknown one gets. A run still running, or with a retest still running, is a 409: cancel it first. What the run was billed stays in your usage. Dev serves it from the first deploy of a control plane carrying `@saram/platform-api` 0.8.0. A tenant is still retired by suspending it, revoking its keys and deleting its secrets. | | Retention, as decided | Session evidence for your plan's number of days from `runs.created_at`, 90 on Pro and 14 on Free, swept by `saram-retention`; recordings the same number of days; the run's record (its rows) and its own documents (the spec, the artifact, `repro.json`) are kept, because the record is the product and the trace underneath it is what goes stale. Every run row carries `evidence_expires_at` and `evidence_expired`, so a 404 on a trace is never ambiguous between "expired" and "never existed". A run whose video S3 has already removed is marked expired then, and the rest of its session evidence goes on the same pass, because a half-dead trace is worse than none. | | Retention, as deployed | The per-plan number is built: a run created on Free keeps its evidence 14 days, on Pro 90, on a step plan the deployment's 90, and a run keeps the longer of its plan's number at creation and its tenant's plan's number now. On dev the recordings bucket's one rule expires an object at 90 days, the backstop at the longest plan number, and in the evidence bucket nothing expires but `files/`, at 30. A probe reads both sets of rules off S3 on every deploy and fails the deploy on any other; it has read them from the first deploy after 2026-09-27, on 2026-10-21, to the deploy of 2026-10-24, which read `90 d` and `files/ = 30 d`. The sweep is the `retention` CronJob, a real pass daily at 15:43 UTC. The deploy of 2026-10-24 listed its Jobs of 2026-10-22 and 2026-10-23, both completed. A Free run's 14 days and every run's session evidence in the evidence bucket have no rule behind them: they go when the sweep runs. Marking a run expired as soon as its video is gone ("Retention, as decided") is on dev since 2026-10-23. Its first pass stamped `evidence_gone_at` on the nineteen runs from before 2026-09-20 whose media the bucket's earlier thirty-day rule had taken, and took their remaining 526 keys. Those runs now read `evidence_expired: true`. Since a change of the same day, a missing video on a run younger than thirty days is reported as `videos_missing_unexplained` and neither stamped nor taken, so such a run reads live over a 404. The dashboard does not yet show "evidence expired". | ### 8. Your billing identity | | | |---|---| | What | A Stripe customer id on `tenants.billing_customer_ref`, a subscription id, and a ledger of what your runs metered. | | Where it lives | Saram's Postgres (`usage_events`, append-only, unique per run, session, meter and role) and Stripe. | | Who can read it | You, through `GET /api/v1/usage` and `GET /api/v1/usage.csv`; an operator, through `saram-tenants usage`, which produces byte-for-byte the same file, because the operator sends the invoice and you check it. | | What crosses to Stripe | One meter, `saram_steps`: a quantity, a timestamp, your customer id, and the ledger row's own id as the idempotency key. No run content, no spec and no evidence. | | Redaction | Not applicable. Nothing about a run's content is in the billing path. | | How you revoke it | Your card and your invoices are Stripe's hosted portal, reached from the usage screen behind `keys:admin`. Saram holds no payment instrument, so there is nothing here to revoke at Saram. | | Retention | The ledger is kept: `run_id` is deliberately not a foreign key, so the billing record survives retention and suspension. | ### 9. What you publish: a session's replay, by link | | | |---|---| | What | An unlisted link to one finished session's replay, which anyone holding it may open with no sign-in. The link is the share host, `https://share.saram.sh` on dev, followed by a token: `shs_` plus 32 base62 characters, about 190 bits. Not `sk_`, because it is meant to be posted and secret scanning should not page anyone over it. One link names one run and one session and opens nothing else. | | Where it lives | A `share_links` row in Saram's Postgres, fenced by row-level security like every table a tenant owns: the run and session, who minted it, `expires_at`, `revoked_at` and `revoked_by`, a view count and the last view's time, and of the token only its `sha256` and its last four characters, so two links can be told apart. The token exists once, inside `url` in the create answer, and is stored nowhere, so a database read yields no working link; a lost link is replaced, never recovered. Nothing about a viewer is kept. The video it plays stays where class 7 put it. | | Who can read it | Anyone holding the link: that is what publishing is. They get an allowlist, built field by field: the video; the persona's label and pack, the mission's goal, the target's origin, the result, the duration and the link's expiry; and per step the action type, the target description, the narration, the page as origin plus path, and its window in the video. Never `action_args`, `inputs`, `field`, a URL's query, fragment or `;` parameters, dialogs, downloads, files, console or network text, the reasoning summary, the perceived state, tokens, model, latency, refs, ids, screenshots, verdicts or opportunities. In your tenant, `runs:write` mints and revokes and `runs:read` lists, over REST, the MCP tools or the dashboard (a Share button on a finished session, the public links on the run page); a link belongs to the tenant, as a run does, so any writer may revoke any of its links, and revoking the key that minted one leaves the link alone. | | How it leaves | Two anonymous routes, `GET /api/v1/shared/session` and `GET`/`HEAD /api/v1/shared/session/video`, live on dev. The token is in the link's path, which the share host's server reads; it reaches the API in `X-Saram-Share` and never in a path or a query, so no API access log has it, and nothing logs it. A malformed token is refused before a query; a well-shaped one is found by its `sha256` alone, through a reveal that shows the application role that one row, and every later read acts for the link's tenant, with no id read from the request. The video is streamed by proxy from the private bucket, never by a presigned URL. Every answer, refusals included, carries `Cache-Control: no-store`, an `X-Robots-Tag` starting `noindex`, `Referrer-Policy: no-referrer` and `X-Content-Type-Options: nosniff`. A missing, malformed, unknown, revoked or expired token, the run's evidence expired, the run or the session gone, the tenant suspended or its switch off, a customer's session recorded without the mask, and a session with no stored video all get one 404, byte-identical in body and headers. Limits, per API pod: 60 requests at once and one a second after, per client address, then a 429; at most 24 open video streams per link. The page a reader opens is the dashboard image's own on the share host, outside its sign-in gate and under a root layout with no session in it, on dev since 2026-10-23: the persona, the goal, the origin and the result, the video, and the steps, each seeking the video. The video is served from the share host's own path, so the API's address and the bucket never reach the reader's browser, and the page carries a per-request content security policy with `media-src 'self'`. The sign-in cookie is host-only, so an owner who opens a link sends no session to it. An unknown link, and every other path on the share host, is one neutral 404 page. A post's unfurl carries the persona's name, the mission's goal, the origin, the result and the step count, over one static card and never a frame of the session. | | Redaction | Narration and target descriptions are redacted for one-time codes and key-shaped strings at the source (class 7) and once more on the way out, so a session recorded before the actor did it is published no less redacted. The account email is shown. The view never carries what the persona typed as text, so the pool's open typed-password sink does not reach it. The video is masked as class 7 says: typed values, one-time-code fields and `data-saram-mask` elements are black boxes, and everything else the page showed is in it. **A customer tenant's session is shared only if it was recorded under the mask**: the pool stamps every session it opens, the session row keeps the stamp (`sessions.recorded_under_mask`, on dev since 2026-10-24), and create refuses a session without it with a 409 whatever the tenant's switch says, as the public read does; a session recorded before that stays unshareable. The fleet's tenant is exempt, its runs being against public products with Saram's own throwaway accounts, so a fleet session recorded before the mask reached dev shows typed values as typed. The create answer, over REST and MCP, carries `masked`, whether the pool recorded the session under the mask, and `warning`: anyone holding the link can watch; what is masked, by name, or that nothing is; what is not, by name (the test account's email, rendered text, another user's data); and how to revoke. The dashboard's share dialog shows the warning with the link. The `create_share_link` MCP tool's description opens by saying the link is public, the recording unredacted, and how to revoke it. | | How you revoke it | `POST /api/v1/shares/{share_id}/revoke`, the `revoke_share_link` MCP tool, or Revoke on the run page's public links in the dashboard, with `runs:write`. The next request is refused and there is no cache to purge; a viewer who buffered part of the video keeps only that. Asking again answers the same row. `GET /api/v1/runs/{run_id}/shares`, or `list_share_links`, lists a run's links, revoked and expired ones included, and says whether your tenant's sharing is on. Deleting the run deletes its links. The switch, `tenants.public_shares`, is off for every tenant until an operator turns it on with `saram-tenants set --public-shares on --by `, and back off the same way; no route sets it. Off, minting is a 403 and every link of the tenant is the 404 at once, rows kept; back on, they live again until their own expiry. A customer tenant's switch is turned on only for a tenant that asked, and only once the mask and the narration set are deployed; on dev both are, since 2026-10-24. Before you post a link, open it signed out and watch the whole replay. | | Retention | 30 days unless the create asks for 1 to 90 (`expires_in_days`), and never past the run's `evidence_expires_at`; `capped_by_evidence` in the answer says when that won. No link that does not expire and no extending one: a link that should live longer is a new link. A revoked or expired row is kept for your list and goes only with its run (`POST /api/v1/runs/{run_id}/delete` cascades). | ### 10. What a visitor hands the homepage demo: a URL, an address, and the replay handed back The demo opens on no environment before the browser pool's egress wall is deployed there. Until then this class describes routes that are built and closed: no demo tenant is configured on dev, so both routes answer 404. | | | |---|---| | What | A stranger with no account on `saram.sh` pastes a site's address, and Saram runs one capped session of one persona against it and hands back a 7-day link to the replay. Saram holds: the address as pasted, cut to origin plus path; the visitor's network address, as an HMAC; a demo token, `dmo_` plus 32 base62 characters (about 190 bits), the visitor's only credential for that demo's status, for 24 hours; and the run, its recording and its share link, as for any run (classes 7 and 9). The visitor holds no key, and no key is minted for the demo tenant. | | Where it lives | A `demo_requests` row in Saram's Postgres, fenced by row-level security like every table a tenant owns: the address as `HMAC-SHA256(pepper, its IPv4 /32 or IPv6 /64)`, so the table holds no raw address; the target's registrable domain, not the URL; the demo token's `sha256` alone; the run's id once it starts; and once the replay exists, the share link's id and its token **sealed under the demo token** (AES-256-GCM under a key HKDF-SHA256 derives from the plaintext demo token, bound to the row's id). A database read therefore yields neither token and no working link. The pepper is 32 characters or more, without which the api refuses to start once the demo tenant is named; no deployed environment holds one yet. A refusal by the bot check or a limit is a row too, with its reason and no token; a refused URL writes nothing. The URL itself lives where any run's does: `runs.spec` and `runs.target`, query and fragment dropped before anything is stored. The run and its recording are the demo tenant's, Saram's own, on the `hosted` plan. | | Who can read it | The visitor, with the demo token: `pending`, `running` with a step count, `done` with the share link, `failed` or `refused`, and nothing of the run itself (no run id, verdict, issue or trace). Anyone the visitor gives the share link to sees class 9's allowlist. Saram's operators see the rows and the run as they see any tenant's. Nobody else: the demo tenant has no standing key, no member and no sign-in. | | How it leaves | Two anonymous routes, `POST /api/v1/demo/runs` and `GET /api/v1/demo/runs/status`, called cross-origin by the landing's page from `https://saram.sh`, which is the only origin they answer and is answered by no other route. No principal: the routes act as the demo tenant, built from the deployment's demo tenant setting by one function and from nothing a request carries. The demo token reaches the API in `X-Saram-Demo` and never in a path or a query, so no access log has it. The pasted URL decides where the browser goes and nothing else: the server composes the rest of the run (one persona, one mission that forbids creating an account, paying or sending the visitor's details, 20 steps, 240 seconds), refuses Saram's own zones, an operator's deny list, a non-default port and any private, cluster or local address (`private_target`), and counts per UTC day 3 demos per address, 10 per registrable domain, a model-spend ceiling and 2 in flight; a demo left waiting for a seat past a configured wait is cancelled. A Cloudflare Turnstile check gates every start where its secret is set (only compose and a local stack run without it), and fails closed. None of this is the wall: a name public at the check can be private at the browser, and a redirect is not re-checked, which is why the pool's egress network policy is the precondition. Every answer carries class 9's headers (`no-store`, `noindex`, `no-referrer`, `nosniff`); a missing, malformed, unknown or expired demo token gets one byte-identical 404; a per-address bucket (60 at once, one a second, per API pod) answers a 429 past it. | | Redaction | The query and the fragment of the pasted URL are dropped before it is stored or opened, so a reset link or a token in a query never reaches the browser or the run record; a capability in the path cannot be told apart from an ordinary path, and is the visitor's own to give. The recording shows whatever the target rendered, as class 9 says; the demo tenant is exempt from the mask stamp, because its spec hands the persona no account and its mission forbids typing the visitor's details, so there is no typed secret for the mask to hide. The visitor's network address is never stored, only its keyed hash. | | How you revoke it | A visitor has nothing to revoke: the demo token dies at 24 hours, and the link at 7 days. An operator closes the demo at once with no deploy (`saram-tenants suspend demo`, or `saram-tenants amend demo --public-shares off`), which also stops every demo link playing; refuses a site for every later demo by adding it to the operators' deny list; and withdraws one link with `POST /api/v1/shares/{share_id}/revoke` as the demo tenant, which takes a key minted on it for the purpose (`saram-tenants key mint demo --name revoke-share --scopes runs:write --expires-in-days 1`) and revoked after, since a break-glass key is the fleet's and sees no other tenant's links. | | Retention | The demo token: 24 hours. The share link: 7 days, never past the run's evidence. The run and its recording: the `hosted` plan's evidence term, as for any run (class 7); whether a stranger-chosen third party's recording should live shorter is open. The `demo_requests` rows: kept, to be pruned at 30 days, not built yet. | ## What Saram never holds - **Your card, or any payment instrument.** Payment details are entered in Stripe's hosted portal and never touch Saram. - **Your people's passwords.** WorkOS AuthKit owns sign-in, reset and verification; Saram stores a subject, an address and a role. - **A Claude subscription token.** No Saram component collects, stores, refreshes or forwards one, because Anthropic's terms forbid it and making it work would mean impersonating Claude Code. - **A credential inline in a run spec.** A credential arrives only as a reference, and the entry object is closed, so a field the contract does not list is treated as an inline secret and every reader rejects it. - **A credential in a Temporal payload or in workflow history.** Resolution happens inside the activity that makes the call. Payloads stay references. - **A credential in a log line, a span, an event or a checkpoint.** The gate never prints a key or a token; `POST /api/v1/credentials` takes the value as a write-only field that pydantic masks in every `repr` and every dump; the ChatGPT connect path's tests grep every response body, log line and span for the token set. - **Your tenant id in the artifact.** The artifact is what your coding agent reads and is meant to be portable, so it carries no `tenant_id`, no `caller` and no `created_by`. - **Another tenant's anything, from your credential, or yours from theirs.** Every read of a run, its sessions, steps, artifact, issues, verdicts, opportunities and evidence takes the principal's tenant as a required argument, so a route that forgot it does not type-check. `/mcp` is scoped identically. Behind that filter, row-level security in Postgres refuses another tenant's rows on every table that holds them, the tenant's own record and everything under a run included, and a person's own record to anyone but that person. - **A house-key fallback.** A model credential that will not resolve fails the activity with the reason; the worker's own key is never reached for, because billing the house key for your run invisibly is the thing the `providers` block exists to prevent. ## Reporting a vulnerability Email **noxalgia@gmail.com** with a subject beginning `saram security`. Include what you did, what you saw, the URL or the run id, and the time. A proof of concept is welcome; a request that damages or exfiltrates somebody else's data is not, and stopping at the first evidence that a boundary is crossable is enough. - **Do not open a GitHub issue.** Issues are the visibility layer between the two fleets and a report should not travel that way before it is fixed. - **There is no rota and no bug bounty.** Saram is two owners; expect a human answer rather than an automated acknowledgement, and no fixed response time. - If the finding is in a dependency or a vendor (WorkOS, Stripe, AWS, Playwright), say so and report it there as well. --- # Saram control plane - **OpenAPI Version:** `3.2.0` - **API Version:** `0.16.1` Saram sends synthetic personas through your product in a real browser and returns what they found as evidence: **issues** a replay reproduced, and **persona feedback** - verdicts and opportunities - quoted from what each persona did and said. This API starts runs, follows them while they execute, and reads their results, together with the files, credentials and keys a run needs. ## Authentication Every route but `GET /api/v1/healthz` wants `Authorization: Bearer `. The credential is an API key (`sk_saram_...`), minted in the dashboard or with `POST /api/v1/api-keys` and shown once, or the access token of a person signed in to the dashboard. A missing, unknown, expired or revoked credential gets one indistinguishable `401`. A key carries scopes, and a route it lacks the scope for answers `403` naming it: - `runs:read` - read runs, sessions, steps, results, evidence, personas and usage, list a run's share links, and validate a run spec. - `runs:write` - start, pause, resume, cancel and delete runs, retest an issue, and mint and revoke share links. - `files:write` - upload, list and delete the files a persona can bring into a run. - `credentials:write` - manage your own model provider keys, test-account logins and ChatGPT connection. - `keys:admin` - mint, list and revoke API keys, and manage billing. A key minted without `scopes` gets `runs:write` and `runs:read`. ## A run, end to end 1. `POST /api/v1/runs/validate` with a run spec: every check `POST /api/v1/runs` makes, the sessions it would plan and the most it can bill on your plan (`cost_ceiling`: dollars, a one-line basis, and who bills its model calls) - and no run. 2. `POST /api/v1/runs` with the same spec: `202` with the `run_id`. 3. Poll `GET /api/v1/runs/{run_id}`. `status` moves `pending`, `running`, `observing`, then stops on `completed`, `failed` or `cancelled`. Only `completed` means there are results to read. 4. Read `GET /api/v1/runs/{run_id}/issues`, `/verdicts` and `/opportunities`, or the whole artifact at `GET /api/v1/runs/{run_id}/artifact`. ## Issues and persona feedback An issue whose reading says `may_file_ticket_alone: true` was reproduced by a replay and can become a ticket as it stands. Verdicts and opportunities are persona feedback: subjective, tied to the evidence and the persona's own words, and never a ticket on their own. ## Conventions - Responses leave an unset field out rather than writing `null`. - An error is JSON with a `detail` that says what was refused and why. - A run, credential or key of another tenant's answers the same `404` as one that does not exist. - Starting, following and reading runs are also MCP tools at `https://api.saram.sh/mcp`, for a coding agent, behind the same credential and the same scopes. ## Contents **Operations** - [GET /api/v1/healthz](#scalar-operation-get-apiv1healthz) - [POST /api/v1/runs](#scalar-operation-post-apiv1runs) - [GET /api/v1/runs](#scalar-operation-get-apiv1runs) - [GET /api/v1/runs/{run\_id}](#scalar-operation-get-apiv1runsrun-id) - [POST /api/v1/runs/{run\_id}/{signal}](#scalar-operation-post-apiv1runsrun-idsignal) - [GET /api/v1/runs/{run\_id}/artifact](#scalar-operation-get-apiv1runsrun-idartifact) - [POST /api/v1/runs/validate](#scalar-operation-post-apiv1runsvalidate) - [POST /api/v1/runs/{run\_id}/delete](#scalar-operation-post-apiv1runsrun-iddelete) - [POST /api/v1/runs/{run\_id}/sessions/{session\_id}/shares](#scalar-operation-post-apiv1runsrun-idsessionssession-idshares) - [GET /api/v1/runs/{run\_id}/shares](#scalar-operation-get-apiv1runsrun-idshares) - [POST /api/v1/shares/{share\_id}/revoke](#scalar-operation-post-apiv1sharesshare-idrevoke) - [GET /api/v1/runs/{run\_id}/sessions](#scalar-operation-get-apiv1runsrun-idsessions) - [GET /api/v1/runs/{run\_id}/sessions/{session\_id}/steps](#scalar-operation-get-apiv1runsrun-idsessionssession-idsteps) - [GET /api/v1/runs/{run\_id}/issues](#scalar-operation-get-apiv1runsrun-idissues) - [GET /api/v1/runs/{run\_id}/issues/{issue\_id}](#scalar-operation-get-apiv1runsrun-idissuesissue-id) - [POST /api/v1/runs/{run\_id}/issues/{issue\_id}/retest](#scalar-operation-post-apiv1runsrun-idissuesissue-idretest) - [GET /api/v1/runs/{run\_id}/issues/{issue\_id}/retests](#scalar-operation-get-apiv1runsrun-idissuesissue-idretests) - [GET /api/v1/runs/{run\_id}/issues/{issue\_id}/retests/{retest\_id}](#scalar-operation-get-apiv1runsrun-idissuesissue-idretestsretest-id) - [GET /api/v1/runs/{run\_id}/verdicts](#scalar-operation-get-apiv1runsrun-idverdicts) - [GET /api/v1/runs/{run\_id}/opportunities](#scalar-operation-get-apiv1runsrun-idopportunities) - [GET /api/v1/runs/{run\_id}/tester-errors](#scalar-operation-get-apiv1runsrun-idtester-errors) - [GET /api/v1/usage](#scalar-operation-get-apiv1usage) - [GET /api/v1/usage.csv](#scalar-operation-get-apiv1usagecsv) - [POST /api/v1/billing/portal](#scalar-operation-post-apiv1billingportal) - [GET /api/v1/billing](#scalar-operation-get-apiv1billing) - [POST /api/v1/billing/auto-refill](#scalar-operation-post-apiv1billingauto-refill) - [POST /api/v1/billing/subscription](#scalar-operation-post-apiv1billingsubscription) - [DELETE /api/v1/billing/subscription](#scalar-operation-delete-apiv1billingsubscription) - [POST /api/v1/billing/setup](#scalar-operation-post-apiv1billingsetup) - [POST /api/v1/billing/top-ups](#scalar-operation-post-apiv1billingtop-ups) - [GET /api/v1/billing/top-ups/{topup\_id}](#scalar-operation-get-apiv1billingtop-upstopup-id) - [POST /api/v1/providers/codex/login](#scalar-operation-post-apiv1providerscodexlogin) - [GET /api/v1/providers/codex](#scalar-operation-get-apiv1providerscodex) - [POST /api/v1/providers/codex/disconnect](#scalar-operation-post-apiv1providerscodexdisconnect) - [GET /api/v1/credentials](#scalar-operation-get-apiv1credentials) - [POST /api/v1/credentials](#scalar-operation-post-apiv1credentials) - [DELETE /api/v1/credentials/{name}](#scalar-operation-delete-apiv1credentialsname) - [GET /api/v1/me](#scalar-operation-get-apiv1me) - [GET /api/v1/api-keys](#scalar-operation-get-apiv1api-keys) - [POST /api/v1/api-keys](#scalar-operation-post-apiv1api-keys) - [DELETE /api/v1/api-keys/{key\_id}](#scalar-operation-delete-apiv1api-keyskey-id) - [GET /api/v1/github/installations](#scalar-operation-get-apiv1githubinstallations) - [GET /api/v1/files](#scalar-operation-get-apiv1files) - [POST /api/v1/files](#scalar-operation-post-apiv1files) - [GET /api/v1/files/{file\_id}](#scalar-operation-get-apiv1filesfile-id) - [POST /api/v1/files/{file\_id}/delete](#scalar-operation-post-apiv1filesfile-iddelete) - [GET /api/v1/personas](#scalar-operation-get-apiv1personas) - [GET /api/v1/personas/{address}](#scalar-operation-get-apiv1personasaddress) - [GET /api/v1/blobs](#scalar-operation-get-apiv1blobs) - [HEAD /api/v1/blobs](#scalar-operation-head-apiv1blobs) **Schemas** - [A](#scalar-schema-a) - [AbandonBehavior](#scalar-schema-abandonbehavior) - [AccessibilityEnum](#scalar-schema-accessibilityenum) - [ActionPolicy](#scalar-schema-actionpolicy) - [ActionStep](#scalar-schema-actionstep) - [AlignedStep](#scalar-schema-alignedstep) - [ApiKeyList](#scalar-schema-apikeylist) - [ApiKeyMinted](#scalar-schema-apikeyminted) - [ApiKeySummary](#scalar-schema-apikeysummary) - [ApiKeyWrite](#scalar-schema-apikeywrite) - [Attribution](#scalar-schema-attribution) - [AuthState](#scalar-schema-authstate) - [AutoRefill](#scalar-schema-autorefill) - [AutoRefillState](#scalar-schema-autorefillstate) - [B](#scalar-schema-b) - [BaselineStatus](#scalar-schema-baselinestatus) - [BillingOverview](#scalar-schema-billingoverview) - [BillingPortal](#scalar-schema-billingportal) - [BillingSetup](#scalar-schema-billingsetup) - [BillingSubscription](#scalar-schema-billingsubscription) - [Check](#scalar-schema-check) - [CheckedBy](#scalar-schema-checkedby) - [Claim](#scalar-schema-claim) - [CodexDisconnected](#scalar-schema-codexdisconnected) - [CodexGateway](#scalar-schema-codexgateway) - [CodexLoginStarted](#scalar-schema-codexloginstarted) - [CodexPending](#scalar-schema-codexpending) - [CodexProviders](#scalar-schema-codexproviders) - [CodexProvidersEntry](#scalar-schema-codexprovidersentry) - [CodexStatus](#scalar-schema-codexstatus) - [Constraints](#scalar-schema-constraints) - [Contradiction](#scalar-schema-contradiction) - [Conversation](#scalar-schema-conversation) - [CostCeiling](#scalar-schema-costceiling) - [CredentialList](#scalar-schema-credentiallist) - [CredentialRecord](#scalar-schema-credentialrecord) - [CredentialWrite](#scalar-schema-credentialwrite) - [DecidedBy](#scalar-schema-decidedby) - [Decision](#scalar-schema-decision) - [Device](#scalar-schema-device) - [DisplaySplit](#scalar-schema-displaysplit) - [DomainLiteracy](#scalar-schema-domainliteracy) - [ErrorResponse](#scalar-schema-errorresponse) - [Evidence](#scalar-schema-evidence) - [Evidence1](#scalar-schema-evidence1) - [EvidenceRefs](#scalar-schema-evidencerefs) - [Exploration](#scalar-schema-exploration) - [FileDeleted](#scalar-schema-filedeleted) - [FileList](#scalar-schema-filelist) - [FileRecord](#scalar-schema-filerecord) - [GitHubInstallationList](#scalar-schema-githubinstallationlist) - [GitHubInstallationRecord](#scalar-schema-githubinstallationrecord) - [Goals](#scalar-schema-goals) - [HTTPValidationError](#scalar-schema-httpvalidationerror) - [HelpSeeking](#scalar-schema-helpseeking) - [Input](#scalar-schema-input) - [Intensity](#scalar-schema-intensity) - [Issue](#scalar-schema-issue) - [IssueDetail](#scalar-schema-issuedetail) - [IssueList](#scalar-schema-issuelist) - [IssueReading](#scalar-schema-issuereading) - [JudgeDecision](#scalar-schema-judgedecision) - [Kind](#scalar-schema-kind) - [Kind1](#scalar-schema-kind1) - [LiveStatus](#scalar-schema-livestatus) - [Location](#scalar-schema-location) - [Location1](#scalar-schema-location1) - [Me](#scalar-schema-me) - [MeTenant](#scalar-schema-metenant) - [MeUser](#scalar-schema-meuser) - [Method](#scalar-schema-method) - [MonthlyCap](#scalar-schema-monthlycap) - [Network](#scalar-schema-network) - [Opportunity](#scalar-schema-opportunity) - [OpportunityList](#scalar-schema-opportunitylist) - [PaymentRequired](#scalar-schema-paymentrequired) - [Persona](#scalar-schema-persona) - [PersonaList](#scalar-schema-personalist) - [PersonaSummary](#scalar-schema-personasummary) - [Phrasing](#scalar-schema-phrasing) - [PlanLimits](#scalar-schema-planlimits) - [PlannedSession](#scalar-schema-plannedsession) - [Priors](#scalar-schema-priors) - [ProductFamiliarity](#scalar-schema-productfamiliarity) - [Provenance](#scalar-schema-provenance) - [ProviderRun](#scalar-schema-providerrun) - [ProviderSpend](#scalar-schema-providerspend) - [QuoteStep](#scalar-schema-quotestep) - [Reading](#scalar-schema-reading) - [RefillSkip](#scalar-schema-refillskip) - [ReproStep](#scalar-schema-reprostep) - [Reproduction](#scalar-schema-reproduction) - [Reproduction1](#scalar-schema-reproduction1) - [Result1](#scalar-schema-result1) - [RetestAttempt](#scalar-schema-retestattempt) - [RetestList](#scalar-schema-retestlist) - [RetestRecord](#scalar-schema-retestrecord) - [RetestRequest](#scalar-schema-retestrequest) - [RunDeleted](#scalar-schema-rundeleted) - [RunDetail](#scalar-schema-rundetail) - [RunFile](#scalar-schema-runfile) - [RunFileAssign](#scalar-schema-runfileassign) - [RunList](#scalar-schema-runlist) - [RunSignalled](#scalar-schema-runsignalled) - [RunSummary](#scalar-schema-runsummary) - [SessionList](#scalar-schema-sessionlist) - [SessionRow](#scalar-schema-sessionrow) - [Severity](#scalar-schema-severity) - [ShareCreated](#scalar-schema-sharecreated) - [ShareList](#scalar-schema-sharelist) - [ShareSummary](#scalar-schema-sharesummary) - [ShareWrite](#scalar-schema-sharewrite) - [SpecValidation](#scalar-schema-specvalidation) - [StartedRun](#scalar-schema-startedrun) - [Status](#scalar-schema-status) - [StepRef](#scalar-schema-stepref) - [StepTiming](#scalar-schema-steptiming) - [StepTokens](#scalar-schema-steptokens) - [StepsPage](#scalar-schema-stepspage) - [SubscriptionState](#scalar-schema-subscriptionstate) - [Summary](#scalar-schema-summary) - [TechnicalLiteracy](#scalar-schema-technicalliteracy) - [TesterError](#scalar-schema-testererror) - [TesterErrorList](#scalar-schema-testererrorlist) - [TesterErrorReading](#scalar-schema-testererrorreading) - [Tolerance](#scalar-schema-tolerance) - [TopUpRecord](#scalar-schema-topuprecord) - [TopUpWrite](#scalar-schema-topupwrite) - [TrustDefaults](#scalar-schema-trustdefaults) - [Type](#scalar-schema-type) - [UsageCeiling](#scalar-schema-usageceiling) - [UsageChargeRun](#scalar-schema-usagechargerun) - [UsageCharges](#scalar-schema-usagecharges) - [UsageMeter](#scalar-schema-usagemeter) - [UsagePersona](#scalar-schema-usagepersona) - [UsageRefund](#scalar-schema-usagerefund) - [UsageReport](#scalar-schema-usagereport) - [UsageRun](#scalar-schema-usagerun) - [UsageTotals](#scalar-schema-usagetotals) - [ValidationError](#scalar-schema-validationerror) - [Verbosity](#scalar-schema-verbosity) - [Verdict](#scalar-schema-verdict) - [Verdict1](#scalar-schema-verdict1) - [VerdictDiscarded](#scalar-schema-verdictdiscarded) - [VerdictList](#scalar-schema-verdictlist) - [VerdictPromptSource](#scalar-schema-verdictpromptsource) - [VerdictRow](#scalar-schema-verdictrow) - [Verdicts](#scalar-schema-verdicts) - [Verification](#scalar-schema-verification) - [Video](#scalar-schema-video) - [Vocabulary](#scalar-schema-vocabulary) - [Voice](#scalar-schema-voice) - [RunSpec](#scalar-schema-runspec) - [RunSpecMission](#scalar-schema-runspecmission) - [RunSpecProvider](#scalar-schema-runspecprovider) ## Servers - **URL:** `https://api.saram.sh` - **Description:** Saram ## Tags ### runs Start a run from a run spec, check a spec without starting one, list and read runs, and pause, resume or cancel one while it executes. ### sessions One session is one persona on one mission. Read a run's sessions, and each session's steps: what the persona saw, did and said, aligned with what the browser recorded, readable while it runs. ### shares Publish one finished session's replay by an unlisted link anyone holding it can open with no sign-in, list a run's links, and revoke one. The recording is not redacted. Off until Saram turns it on for your tenant. ### artifact What a run found: the artifact, its reproduced issues, the personas' verdicts and opportunities, and retests that replay an issue again to answer whether it is fixed. ### personas The personas a run spec can name, by `id` or `id@version`, and each one's full document. ### files Files a persona can bring into a run - a document to upload, an image to attach. Uploaded once, named in a run spec by their `ref`, never served back. ### blobs Evidence bytes - screenshots, videos, traces - streamed by the ref a step or an issue records, with `Range` for seeking. ### credentials Your own model provider keys and the test-account logins your personas sign in with. Stored as secrets and named in a run spec by `secret_ref`; a value is never returned. ### providers Connect your own ChatGPT subscription as a model provider for the runs you start yourself: a device login, its status, and disconnecting it. ### identity Who a credential is - its tenant, role and scopes - and the API keys that tenant holds. ### usage What your runs have used and cost, by month, and billing: your plan, prepaid balance, top-ups, auto-refill and the card on file. ### health Whether the API is up. The one route that needs no credential. ## Operations ### Healthz - **Method:** `GET` - **Path:** `/api/v1/healthz` - **Operation ID:** `healthz` - **Tags:** health Whether the API is up: `{"status": "ok"}`. The one route that takes no credential. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Responses ##### 200 Successful Response **Content type:** `application/json` `object` **Additional properties:** `string` **Generated example:** ```json { "additionalProperty": "" } ``` ### Create Run - **Method:** `POST` - **Path:** `/api/v1/runs` - **Operation ID:** `create_run` - **Tags:** runs Start a run: send the personas in the spec through the target, one session per persona per mission. The body is a run spec. It is checked in full before anything starts - the contract, the references between its parts, your model providers and credentials - and a spec that fails any check is a 422 naming the field and the reason, with no run created. `POST /api/v1/runs/validate` makes the same checks without starting anything, and says what the run would cost. A 402 is a run your plan cannot pay for. The answer is the `run_id` at once, while the run starts in the background: follow it with `GET /api/v1/runs/{run_id}` until `status` is terminal. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Request body **Required:** `true` **Content type:** `application/json` [RunSpec](#scalar-schema-runspec) Input to the control plane. A run = target x persona set x missions x budget x baseline. **Example:** ```json { "target": { "entry_url": "https://example.com" }, "personas": [ "dana_first_timer@1" ], "missions": [ { "id": "sign_up", "goal": "create an account", "success_criteria": [ { "kind": "judge_assertion", "value": "the persona reached a signed-in page" } ] } ], "budget": { "steps_per_session": 30 } } ``` #### Responses ##### 202 The run is accepted and has started. **Content type:** `application/json` [StartedRun](#scalar-schema-startedrun) A run accepted and started. Follow it by its `run_id`: `GET /api/v1/runs/{run_id}`, or the MCP tools `get_run_status` and `wait_for_run`. **Generated example:** ```json { "run_id": "", "workflow_id": "" } ``` ##### 402 no run is started. On a plan that bills steps (pilot), because this tenant's month has billed its monthly cap (the detail carries what the month has billed and what the cap is, and monthly\_cap the same numbers). On a plan that prices Usage (free, pro), because the available balance does not cover the most Usage this run can use (the detail carries the ceiling, the balance, and what to change, and usage\_ceiling the same numbers as POST /api/v1/runs/validate answers them); such a tenant never meets the monthly cap **Content type:** `application/json` [PaymentRequired](#scalar-schema-paymentrequired) No run is started, because the tenant's plan cannot pay for it: `POST /runs`' 402 body, and the structured content of the `start_run` tool's refusal for the same reason. **Generated example:** ```json { "detail": "", "usage_ceiling": { "sessions": 1, "steps_per_session": 1, "wall_clock_s_per_session": 1, "usage_per_session": 1, "usage": 1, "usage_usd": 1, "usage_price_usd": 1, "available_usd": 1, "covered": true, "steps_per_session_that_fit": 1, "enforced": true }, "monthly_cap": { "period": "", "billed_usd": 1, "cap_usd": 1 } } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) **Generated example:** ```json { "detail": [ { "loc": [ "" ], "msg": "", "type": "", "input": null, "ctx": {} } ] } ``` ### List Runs - **Method:** `GET` - **Path:** `/api/v1/runs` - **Operation ID:** `list_runs` - **Tags:** runs Your runs, newest first: up to `limit` (50 unless you ask for up to 500), optionally only those with one `status` or against one target. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Query parameters - **`limit`**: `integer`, default: `50`, minimum: `1`, maximum: `500` - **`status`**: `string | null` - **`target`**: `string | null` entry URL or target name, exact #### Responses ##### 200 Successful Response **Content type:** `application/json` [RunList](#scalar-schema-runlist) Your runs, newest first. **Generated example:** ```json { "runs": [ { "run_id": "", "status": "", "status_reason": "", "target": "", "target_name": "", "baseline_run_id": "", "tenant_id": "", "created_by": "", "caller": "", "confirmation_rate": 1, "repro_attempts": 1, "repro_gate_discards": 1, "repro_gate_ref": "", "billed_steps": 0, "billable_usd": 1, "cost_usd": 1, "actor_cost_usd": 1, "judge_cost_usd": 1, "usage": 1, "usage_usd": 1, "refunded_usd": 1, "held_usd": 1, "usage_state": "live", "sessions_per_hour": 1, "artifact_ref": "", "summary": { "personas_run": 1, "missions": 1, "sessions": 1, "completed": 1, "abandoned": 1, "blocked": 1, "errored": 1, "budget_exhausted": 0, "issues_total": 1, "issues_reproducible": 1, "issues_marker_matched": 1, "tester_errors": 1, "issues_new_vs_baseline": 1, "issues_resolved_vs_baseline": 1, "issues_persisting_vs_baseline": 1, "opportunities_total": 1, "verdicts": { "positive": 1, "negative": 1, "undecided": 1, "top_reasons": [ "" ] }, "confirmation_rate": 0, "reproduction": { "candidates": 1, "merged": 1, "evaluated": 1, "confirmed": 1, "inconclusive": 1, "excluded": 1 }, "gate_discards": 0, "path_diversity": 0, "cost_usd": 1, "actor_cost_usd": 0, "judge_cost_usd": 0, "wall_clock_s": 1 }, "session_counts": { "additionalProperty": 1 }, "packs": [ "" ], "created_at": "", "started_at": "", "finished_at": "", "evidence_expires_at": "", "evidence_expired": false } ] } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Get Run - **Method:** `GET` - **Path:** `/api/v1/runs/{run_id}` - **Operation ID:** `get_run` - **Tags:** runs One run: its status, what it has cost and billed so far, the artifact's summary once there is one, and every session with where it has got to. Poll this while a run is open. `status` moves `pending`, `running`, `observing`, then stops on `completed`, `failed` or `cancelled`, and only `completed` means there are results to read. Live fields (`billed_steps`, each session's `heartbeat_step`) climb while the run does. A run of another tenant's is a 404, the same as a run that does not exist. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`run_id` (required)**: `string` #### Responses ##### 200 Successful Response **Content type:** `application/json` [RunDetail](#scalar-schema-rundetail) One run in full: everything `RunSummary` carries, plus its budgets, the files it hands its personas, its live state while it is open, and every session. **Example:** ```json { "run_id": "run_2026-09-26_4f1c2a", "status": "completed", "target": "https://example.com", "tenant_id": "ten_2026-06-02_3f9a2c14", "created_by": "key_2026-09-01_8d1e04b7", "caller": "acme/ci", "confirmation_rate": 1, "repro_attempts": 2, "repro_gate_discards": 0, "repro_gate_ref": "s3://saram-evidence/run_2026-09-26_4f1c2a/repro_gate.json", "billed_steps": 27, "cost_usd": 0.59, "actor_cost_usd": 0.38, "judge_cost_usd": 0.21, "usage": 30.114, "usage_usd": 0.48, "refunded_usd": 0, "usage_state": "settled", "sessions_per_hour": 49.7, "artifact_ref": "s3://saram-evidence/run_2026-09-26_4f1c2a/artifact.json", "summary": { "personas_run": 2, "missions": 1, "sessions": 2, "completed": 1, "abandoned": 1, "blocked": 0, "errored": 0, "budget_exhausted": 0, "issues_total": 1, "issues_reproducible": 1, "issues_marker_matched": 0, "tester_errors": 0, "opportunities_total": 0, "verdicts": { "positive": 1, "negative": 1, "undecided": 0, "top_reasons": [ "The sign-up form failed without saying why.", "Signing up was fast." ] }, "confirmation_rate": 1, "reproduction": { "candidates": 2, "merged": 1, "evaluated": 1, "confirmed": 1, "inconclusive": 0, "excluded": 0 }, "gate_discards": 0, "path_diversity": 1, "cost_usd": 0.59, "actor_cost_usd": 0.38, "judge_cost_usd": 0.21, "wall_clock_s": 214 }, "session_counts": { "completed": 1, "abandoned": 1 }, "packs": [ "general" ], "created_at": "2026-09-26T14:02:11Z", "started_at": "2026-09-26T14:02:14Z", "finished_at": "2026-09-26T14:05:47Z", "evidence_expires_at": "2026-12-25T14:02:11Z", "evidence_expired": false, "workflow_id": "run-run_2026-09-26_4f1c2a", "budgets": { "steps_per_session": 30 }, "run_spec_ref": "s3://saram-evidence/run_2026-09-26_4f1c2a/run_spec.json", "files": [], "sessions": [ { "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer", "persona_id": "dana_first_timer@1", "pack": "general", "tags": [ "general", "novice", "desktop", "first_time" ], "mission_id": "sign_up", "status": "abandoned", "result": "abandoned", "end_reason": "patience_exhausted", "fault": "none", "steps": 18, "end_step": 18, "abandon_step": 18, "tokens_in": 212340, "tokens_out": 3120, "tokens_cache_read": 168000, "tokens_cache_write": 0, "model": "anthropic:claude-sonnet-5", "cost_usd": 0.26, "price_source": "genai-prices/0.0.41", "wall_clock_s": 142.6, "uncertain_steps": [], "resumed_at_steps": [], "verdict_decision": "negative", "outcome_read": "abandoned", "usage": 21.412, "usage_usd": 0.34, "refunded_usd": 0, "workflow_id": "run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_01_dana_first_timer", "agent_trace_ref": "s3://saram-evidence/run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_01_dana_first_timer/agent.ndjson", "surface_trace_ref": "s3://saram-evidence/run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_01_dana_first_timer/surface.ndjson", "video_ref": "s3://saram-evidence/run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_01_dana_first_timer/video.webm", "video_url": "/api/v1/blobs?ref=s3%3A%2F%2Fsaram-evidence%2Frun_2026-09-26_4f1c2a%2Frun_2026-09-26_4f1c2a.sess_01_dana_first_timer%2Fvideo.webm", "recorded_under_mask": false, "created_at": "2026-09-26T14:02:14Z", "finished_at": "2026-09-26T14:04:39Z" }, { "session_id": "run_2026-09-26_4f1c2a.sess_02_marcus_fast_skimmer", "persona_id": "marcus_fast_skimmer@1", "pack": "general", "tags": [ "general", "power_user", "desktop", "skimmer", "fast" ], "mission_id": "sign_up", "status": "completed", "result": "completed", "end_reason": "success_criteria_met", "fault": "none", "steps": 9, "end_step": 9, "tokens_in": 88410, "tokens_out": 1460, "tokens_cache_read": 70200, "tokens_cache_write": 0, "model": "anthropic:claude-sonnet-5", "cost_usd": 0.12, "price_source": "genai-prices/0.0.41", "wall_clock_s": 71.3, "uncertain_steps": [], "resumed_at_steps": [], "verdict_decision": "positive", "outcome_read": "completed", "usage": 8.702, "usage_usd": 0.14, "refunded_usd": 0, "workflow_id": "run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_02_marcus_fast_skimmer", "agent_trace_ref": "s3://saram-evidence/run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_02_marcus_fast_skimmer/agent.ndjson", "surface_trace_ref": "s3://saram-evidence/run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_02_marcus_fast_skimmer/surface.ndjson", "video_ref": "s3://saram-evidence/run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_02_marcus_fast_skimmer/video.webm", "video_url": "/api/v1/blobs?ref=s3%3A%2F%2Fsaram-evidence%2Frun_2026-09-26_4f1c2a%2Frun_2026-09-26_4f1c2a.sess_02_marcus_fast_skimmer%2Fvideo.webm", "recorded_under_mask": false, "created_at": "2026-09-26T14:02:14Z", "finished_at": "2026-09-26T14:03:27Z" } ] } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Signal Run - **Method:** `POST` - **Path:** `/api/v1/runs/{run_id}/{signal}` - **Operation ID:** `signal_run` - **Tags:** runs Pause, resume or cancel a run in flight: the last path segment is `pause`, `resume` or `cancel`. `pause` starts no new session and holds the running ones; `resume` releases a paused run, including one that paused itself on reaching `budget.total_cost_usd`; `cancel` stops the running sessions, starts no more, and ends the run `cancelled` without judging what they did. A run that has already finished is a 409\. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`run_id` (required)**: `string` - **`signal` (required)**: `string` #### Responses ##### 202 The signal is delivered to the run. **Content type:** `application/json` [RunSignalled](#scalar-schema-runsignalled) A pause, resume or cancel delivered to a run. **Generated example:** ```json { "run_id": "", "signal": "" } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Get Artifact - **Method:** `GET` - **Path:** `/api/v1/runs/{run_id}/artifact` - **Operation ID:** `get_artifact` - **Tags:** artifact The run's whole artifact: every issue, verdict, opportunity and tester error, each session's outcome, the summary, and the evidence refs behind them, as one document in the published artifact format. A 404 until the run has assembled one: a `completed` run has one, and a run that failed or was cancelled before assembly has none. The per-kind views (`/issues`, `/verdicts`, `/opportunities`, `/tester-errors`) serve the same records with how each may be acted on beside them. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`run_id` (required)**: `string` #### Responses ##### 200 Successful Response **Content type:** `application/json` `object` **Generated example:** ```json { "additionalProperty": "anything" } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Validate Run - **Method:** `POST` - **Path:** `/api/v1/runs/validate` - **Operation ID:** `validate_run` - **Tags:** runs The dry run: everything `POST /runs` checks, the sessions it would plan, the most it can bill on the caller's plan (`cost_ceiling`, on every plan), and no run. Every refusal `POST /runs` would give is the same 422 here, and a 200 means that spec is accepted as written - so a caller can learn the API, and check a spec they are about to spend money on, without spending it. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Request body **Required:** `true` **Content type:** `application/json` [RunSpec](#scalar-schema-runspec) Input to the control plane. A run = target x persona set x missions x budget x baseline. **Example:** ```json { "target": { "entry_url": "https://example.com" }, "personas": [ "dana_first_timer@1" ], "missions": [ { "id": "sign_up", "goal": "create an account", "success_criteria": [ { "kind": "judge_assertion", "value": "the persona reached a signed-in page" } ] } ], "budget": { "steps_per_session": 30 } } ``` #### Responses ##### 200 Successful Response **Content type:** `application/json` [SpecValidation](#scalar-schema-specvalidation) `POST /api/v1/runs/validate`: what `POST /runs` would make of this spec, without making it. Everything `POST /runs` would refuse is the same 422 here: the contract, the cross-references inside `providers`, and the caller's own model provider - including a ChatGPT login the gateway no longer holds. A 200 means the spec is accepted as written. **Generated example:** ```json { "run_id": "", "spec": { "additionalProperty": "anything" }, "sessions": [ { "session_id": "", "persona_id": "", "mission_id": "", "entry_url": "", "account_secret_ref": "" } ], "warnings": [ "" ], "cost_ceiling": { "usd": 1, "billed_in": "usage", "plan": "", "price_usd": 1, "sessions": 1, "steps_per_session": 1, "wall_clock_s_per_session": 1, "usage_per_session": 1, "usage": 1, "basis": "", "model_calls": "" }, "usage_ceiling": { "sessions": 1, "steps_per_session": 1, "wall_clock_s_per_session": 1, "usage_per_session": 1, "usage": 1, "usage_usd": 1, "usage_price_usd": 1, "available_usd": 1, "covered": true, "steps_per_session_that_fit": 1, "enforced": true } } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Delete Run - **Method:** `POST` - **Path:** `/api/v1/runs/{run_id}/delete` - **Operation ID:** `delete_run` - **Tags:** runs Delete one of your runs, for good: its traces, screenshots, video and checkpoints, its own documents (the spec, the artifact), and its record - the run, its sessions, issues, verdicts, opportunities and retests. Every route then answers the run with the 404 an unknown run gets. Your own tenant's runs and no other: another tenant's run is that same 404, so a delete neither reaches nor confirms a run that is not yours. Asking again answers what the first delete did, so a retry is safe. A run that is still running, or has a retest still running, is a 409 and nothing is deleted: cancel it and delete it once it has stopped. What the run was billed stays in your usage. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`run_id` (required)**: `string` #### Responses ##### 200 The run is deleted, now or by an earlier request. **Content type:** `application/json` [RunDeleted](#scalar-schema-rundeleted) A run its owner deleted: its evidence, its recordings, and its record - the run, its sessions, issues, verdicts, opportunities, retests and artifact - are gone, and every route now answers the run with the 404 an unknown run gets. Asking again answers this same body, so a retried delete is safe. What is kept is what the run was billed: the usage ledger outlives a deleted run as it outlives expired evidence, so a month's usage still counts it. **Generated example:** ```json { "run_id": "", "deleted_at": "", "objects_deleted": 1 } ``` ##### 403 the API key lacks the required scope ##### 409 the run, or a retest of it, is still running: nothing is deleted under something still writing to it. Cancel it and delete it once it has stopped ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Create Share Link - **Method:** `POST` - **Path:** `/api/v1/runs/{run_id}/sessions/{session_id}/shares` - **Operation ID:** `create_share_link` - **Tags:** shares Publish one finished session's replay by a link anyone holding it can open, with no sign-in. The link plays the session's recording with the persona's steps and narration beside it. Whatever the browser showed that is not masked - the test account's email, any text the product rendered - anyone with the link sees, and `warning` in the answer says what that is: show it to whoever posts the link. Revoke it with `POST /api/v1/shares/{share_id}/revoke`, and it stops playing on the next request. The `url` is shown once, in this answer: only a hash of it is kept, so a lost link is replaced by minting another and revoking the first. It expires after `expires_in_days` (30 unless you ask for 1 to 90), and never later than the run's evidence does. A session that has not ended yet is a 409, as is a run whose evidence has expired, and a session the browser pool did not record under its mask (`masked` in the answer says the session's was). Sharing is off for every tenant until Saram turns it on, and a 403 says so. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`run_id` (required)**: `string` - **`session_id` (required)**: `string` #### Request body **Content type:** `application/json` [ShareWrite](#scalar-schema-sharewrite) | `null` The optional body of `POST /runs/{run_id}/sessions/{session_id}/shares`. **Generated example:** ```json { "expires_in_days": 1 } ``` #### Responses ##### 201 The link is minted. Its URL is in this answer and in no other. **Content type:** `application/json` [ShareCreated](#scalar-schema-sharecreated) A new public link to one session's replay, and the one and only sight of its URL. Anyone holding `url` can watch the session's recording, which hides only what the browser pool masked when it recorded it (`masked`): whatever else the browser showed is in it. Only a hash of the link's token is stored, so no route shows the URL again; a lost link is replaced by minting another and revoking this one with `POST /api/v1/shares/{id}/revoke`. **Generated example:** ```json { "id": "", "url": "", "run_id": "", "session_id": "", "created_at": "", "created_by": "", "expires_at": "", "capped_by_evidence": true, "masked": true, "warning": "" } ``` ##### 403 the API key lacks the required scope, or public sharing is off for this tenant: only Saram turns it on, because a recording shows what the browser showed, but for what the pool masked ##### 404 no such run or session of yours - the same answer for another tenant's as for none ##### 409 the session has not ended, so its recording is not stored yet; the run's evidence has expired and there is no recording left to share; or the browser pool did not record the session under its mask, which paints over what the persona typed ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ##### 503 this deployment has no share host to open a link on ### List Share Links - **Method:** `GET` - **Path:** `/api/v1/runs/{run_id}/shares` - **Operation ID:** `list_share_links` - **Tags:** shares Every public link minted on a run's sessions, newest first - live, expired and revoked - with who minted each, when it expires and how often it was opened. Never a link itself: that was shown once, when it was minted. `public_shares` says whether your tenant may share at all; while it is off no link plays. `mask_required` says whether a session of yours is shared only when the browser pool recorded it under its mask (the session row's `recorded_under_mask`): while it is true, a share of any other is a 409. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`run_id` (required)**: `string` #### Responses ##### 200 Successful Response **Content type:** `application/json` [ShareList](#scalar-schema-sharelist) A run's public links, revoked and expired ones included, newest first. **Generated example:** ```json { "run_id": "", "public_shares": true, "mask_required": true, "shares": [ { "id": "", "run_id": "", "session_id": "", "token_hint": "", "state": "live", "created_at": "", "created_by": "", "caller": "", "expires_at": "", "revoked_at": "", "revoked_by": "", "view_count": 1, "last_viewed_at": "" } ] } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Revoke Share Link - **Method:** `POST` - **Path:** `/api/v1/shares/{share_id}/revoke` - **Operation ID:** `revoke_share_link` - **Tags:** shares Stop a public link playing: the next request for it is refused, a video already playing included once it asks for its next range. Any key or member of your tenant that may write runs can revoke any of its links, not only the one who minted it. The link's record stays, with who revoked it and when, so the list still says what was shared. Asking again answers the same record. Another tenant's link is the 404 an unknown id gets. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`share_id` (required)**: `string` #### Responses ##### 200 The link is revoked, now or by an earlier request. **Content type:** `application/json` [ShareSummary](#scalar-schema-sharesummary) One public link to a session's replay, as the list and the revocation answer it. Never the link itself. **Generated example:** ```json { "id": "", "run_id": "", "session_id": "", "token_hint": "", "state": "live", "created_at": "", "created_by": "", "caller": "", "expires_at": "", "revoked_at": "", "revoked_by": "", "view_count": 1, "last_viewed_at": "" } ``` ##### 403 the API key lacks the required scope ##### 404 no link of yours has that id - the same answer for another tenant's link as for none ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### List Sessions - **Method:** `GET` - **Path:** `/api/v1/runs/{run_id}/sessions` - **Operation ID:** `list_sessions` - **Tags:** sessions A run's sessions, one per persona per mission: each with its status, result, whose fault an error was, its tokens, cost and Usage, and - while it runs - where it has got to. `pack` narrows them to one persona cohort. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`run_id` (required)**: `string` #### Query parameters - **`pack`**: `string | null` persona pack (cohort), exact #### Responses ##### 200 Successful Response **Content type:** `application/json` [SessionList](#scalar-schema-sessionlist) A run's sessions, one per persona per mission. **Generated example:** ```json { "run_id": "", "sessions": [ { "session_id": "", "persona_id": "", "pack": "", "tags": [ "" ], "mission_id": "", "status": "", "result": "", "end_reason": "", "fault": "none", "steps": 1, "end_step": 1, "abandon_step": 1, "tokens_in": 1, "tokens_out": 1, "tokens_cache_read": 0, "tokens_cache_write": 0, "model": "", "cost_usd": 1, "price_source": "", "wall_clock_s": 1, "uncertain_steps": [ 1 ], "resumed_at_steps": [ 1 ], "verdict_decision": "", "outcome_read": "", "observer_error": "", "verdict_discarded": { "judge_decision": "positive", "gate_reason": "" }, "heartbeat_step": 1, "last_heartbeat_at": "", "heartbeat_usage": 1, "usage": 1, "usage_usd": 1, "refunded_usd": 1, "waiting_for_seat": true, "waiting_since": "", "workflow_id": "", "browser_session_id": "", "agent_trace_ref": "", "surface_trace_ref": "", "video_ref": "", "video_url": "", "recorded_under_mask": false, "created_at": "", "finished_at": "" } ] } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Get Steps - **Method:** `GET` - **Path:** `/api/v1/runs/{run_id}/sessions/{session_id}/steps` - **Operation ID:** `get_steps` - **Tags:** sessions One session's steps, both halves aligned: what the persona decided and said, beside what the browser did and saw - url, inputs, dialogs, downloads, network failures, console errors, screenshot and video window. Readable while the session runs. Page through a long session with `from` and `to`, or tail a live one by passing the previous answer's `last_step_id` as `after` until `status` is no longer `in_flight`. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`run_id` (required)**: `string` - **`session_id` (required)**: `string` #### Query parameters - **`from`**: `integer | null`, minimum: `0` Inclusive lower bound on step\_id. Mutually exclusive with after, which is the same bound minus one. - **`to`**: `integer | null`, minimum: `0` - **`after`**: `integer | null`, minimum: `0` Exclusive lower bound: return the steps written after this step\_id. The cursor for tailing a session that is still running - pass back the last\_step\_id of the previous page. Mutually exclusive with from. #### Responses ##### 200 Successful Response **Content type:** `application/json` [StepsPage](#scalar-schema-stepspage) A session's steps, aligned. Readable while the session runs, not only after it ends. One step is written as the persona takes it, so a caller polling `?after=` tails a live session: each call returns the steps written since the last one, and `status` says whether more are coming. `heartbeat_step` beside it is where the browser has got to, which is at or one ahead of `last_step_id` while a step is in flight. **Generated example:** ```json { "run_id": "", "session_id": "", "persona_id": "", "mission_id": "", "status": "", "heartbeat_step": 1, "last_heartbeat_at": "", "waiting_for_seat": true, "waiting_since": "", "workflow_id": "", "from_step": 1, "to_step": 1, "total_steps": 1, "first_step_id": 1, "last_step_id": 1, "agent_trace_ref": "", "surface_trace_ref": "", "video_ref": "", "video_url": "", "steps": [ { "step_id": 1, "has_agent": true, "has_surface": true, "terminal": true, "ts_agent": "", "action_type": "", "target_description": "", "action_args": { "additionalProperty": "anything" }, "narration": "", "reasoning_summary": "", "perceived_state": "", "observation_mode": "", "tolerance_state": { "additionalProperty": "anything" }, "latency_ms": 1, "tokens": { "input": 1, "output": 1, "cached": 1 }, "model": "", "end_reason": "", "result": "", "field": { "additionalProperty": "anything" }, "ts_surface": "", "url": "", "route": "", "viewport": "", "inputs": [ { "additionalProperty": "anything" } ], "files_attached": [ { "additionalProperty": "anything" } ], "dialogs": [ { "additionalProperty": "anything" } ], "downloads": [ { "additionalProperty": "anything" } ], "network_failures": [ { "additionalProperty": "anything" } ], "console_errors": [ { "additionalProperty": "anything" } ], "dom_mutation_count": 1, "timing": { "action_to_settle_ms": 1, "longest_task_ms": 1, "navigation_ms": 1 }, "screenshot_ref": "", "screenshot_url": "", "video_ref": "", "video_url": "", "ts_start_s": 1, "ts_end_s": 1 } ] } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Get Issues - **Method:** `GET` - **Path:** `/api/v1/runs/{run_id}/issues` - **Operation ID:** `get_issues` - **Tags:** artifact The product defects the run found, each with its evidence and repro steps, and beside them how each may be acted on: an issue whose reading says `may_file_ticket_alone` was reproduced by a replay and can become a ticket as it stands; any other must be reported with its evidence and marked unconfirmed. Answers from the moment the run exists: an empty list with the run's `status` while it is still going, and a result only once that status is terminal. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`run_id` (required)**: `string` #### Responses ##### 200 Successful Response **Content type:** `application/json` [IssueList](#scalar-schema-issuelist) A run's issues: product defects the run found, each with its evidence, and beside them how each may be acted on (`reading`). Only an issue whose reading says `may_file_ticket_alone` is a confirmed bug. **Example:** ```json { "run_id": "run_2026-09-26_4f1c2a", "status": "completed", "issues": [ { "id": "iss_signup_submit_500", "type": "bug", "severity": "high", "reproducible": true, "reproduction": { "attempted": true, "result": "reproduced", "attempts": 2, "notes": "Both replays filled the sign-up form with a password containing a space and pressed Create account: POST /api/signup answered 500 and the page did not change." }, "persona_specific": false, "claim": { "action_step": { "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer", "step_id": 7 }, "expected": "Create account submits the form and signs the new user in.", "observed": "POST /api/signup answered 500; the form stayed on screen with every field filled and no message.", "verification": { "status": "verified", "method": "marker", "markers": [ "failed_requests" ], "note": "The same failed request at the judged step on both attempts; the claim check found the title supported by the network line, and the failure is the product's." }, "attribution": "product", "check": { "verdict": "supported", "quote": "[step 7] click -> button[type=submit]: POST /api/signup 500", "quote_step": { "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer", "step_id": 7 }, "reason": "The request the button sends fails on the server, which is what the title claims.", "checked_by": "judge" } }, "affected_sessions": [ "run_2026-09-26_4f1c2a.sess_01_dana_first_timer" ], "affected_personas": [ "dana_first_timer@1" ], "affected_count": 1, "affected_fraction": 0.5, "reached_sessions": [ "run_2026-09-26_4f1c2a.sess_01_dana_first_timer", "run_2026-09-26_4f1c2a.sess_02_marcus_fast_skimmer" ], "first_seen_step": 7, "location": { "route": "/signup", "selector": "button[type=submit]", "element_description": "Create account button under the sign-up form", "viewport": "1440x900" }, "title": "Create account fails with a server error when the password contains a space", "description": "On /signup, submitting the form with a password that contains a space sends POST /api/signup, which answers 500. The page shows no error and keeps the form filled, so the persona pressed Create account three more times before giving up.", "persona_rationale": "I pressed the button four times and nothing happened, so I assumed the site was broken.", "evidence": { "step_refs": [ { "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer", "step_id": 7 }, { "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer", "step_id": 10 } ], "screenshots": [ "s3://saram-evidence/run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_01_dana_first_timer/shots/0007.png" ] }, "repro_steps": [ { "action": "navigate", "target": "https://example.com/signup", "expect": "sign-up form visible" }, { "action": "fill", "target": "form#signup", "expect": "a password containing a space" }, { "action": "click", "target": "button[type=submit]", "expect": "a signed-in page" } ], "preconditions": [ "viewport 1440x900", "auth_state anonymous" ], "baseline_status": "new", "confidence": 0.92 } ], "reading": [ { "issue_id": "iss_signup_submit_500", "may_file_ticket_alone": true, "subjective_fields": [ "persona_rationale" ], "advisory": "SUBJECTIVE. `persona_rationale` is persona-voiced narration from an affected session, not a statement of observed behaviour. Quote it as the persona's words; the objective claim is `description`." } ] } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Get Issue - **Method:** `GET` - **Path:** `/api/v1/runs/{run_id}/issues/{issue_id}` - **Operation ID:** `get_issue` - **Tags:** artifact One issue in full - its claim and how it was checked, repro steps, preconditions and evidence - with its reading and the evidence refs to fetch. A 404 until the run's artifact is assembled; an id that is one of the run's tester errors is a 404 that says where to read it instead. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`run_id` (required)**: `string` - **`issue_id` (required)**: `string` #### Responses ##### 200 Successful Response **Content type:** `application/json` [IssueDetail](#scalar-schema-issuedetail) One issue with its reading and its evidence refs beside it: `GET /runs/{id}/issues/{issue_id}` and the MCP `get_issue` answer. `issue` is the contract object verbatim and `reading` is the entry `IssueList.reading[]` carries for the same issue, computed by the same function, so a surface that shows one issue - an issue page, or an agent that calls `get_issue` first - reads whether it may be filed alone, and how to read its persona-voiced fields, without a second call and cannot disagree with the list about them. **Example:** ```json { "run_id": "run_2026-09-26_4f1c2a", "issue": { "id": "iss_signup_submit_500", "type": "bug", "severity": "high", "reproducible": true, "reproduction": { "attempted": true, "result": "reproduced", "attempts": 2, "notes": "Both replays filled the sign-up form with a password containing a space and pressed Create account: POST /api/signup answered 500 and the page did not change." }, "persona_specific": false, "claim": { "action_step": { "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer", "step_id": 7 }, "expected": "Create account submits the form and signs the new user in.", "observed": "POST /api/signup answered 500; the form stayed on screen with every field filled and no message.", "verification": { "status": "verified", "method": "marker", "markers": [ "failed_requests" ], "note": "The same failed request at the judged step on both attempts; the claim check found the title supported by the network line, and the failure is the product's." }, "attribution": "product", "check": { "verdict": "supported", "quote": "[step 7] click -> button[type=submit]: POST /api/signup 500", "quote_step": { "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer", "step_id": 7 }, "reason": "The request the button sends fails on the server, which is what the title claims.", "checked_by": "judge" } }, "affected_sessions": [ "run_2026-09-26_4f1c2a.sess_01_dana_first_timer" ], "affected_personas": [ "dana_first_timer@1" ], "affected_count": 1, "affected_fraction": 0.5, "reached_sessions": [ "run_2026-09-26_4f1c2a.sess_01_dana_first_timer", "run_2026-09-26_4f1c2a.sess_02_marcus_fast_skimmer" ], "first_seen_step": 7, "location": { "route": "/signup", "selector": "button[type=submit]", "element_description": "Create account button under the sign-up form", "viewport": "1440x900" }, "title": "Create account fails with a server error when the password contains a space", "description": "On /signup, submitting the form with a password that contains a space sends POST /api/signup, which answers 500. The page shows no error and keeps the form filled, so the persona pressed Create account three more times before giving up.", "persona_rationale": "I pressed the button four times and nothing happened, so I assumed the site was broken.", "evidence": { "step_refs": [ { "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer", "step_id": 7 }, { "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer", "step_id": 10 } ], "screenshots": [ "s3://saram-evidence/run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_01_dana_first_timer/shots/0007.png" ] }, "repro_steps": [ { "action": "navigate", "target": "https://example.com/signup", "expect": "sign-up form visible" }, { "action": "fill", "target": "form#signup", "expect": "a password containing a space" }, { "action": "click", "target": "button[type=submit]", "expect": "a signed-in page" } ], "preconditions": [ "viewport 1440x900", "auth_state anonymous" ], "baseline_status": "new", "confidence": 0.92 }, "reading": { "issue_id": "iss_signup_submit_500", "may_file_ticket_alone": true, "subjective_fields": [ "persona_rationale" ], "advisory": "SUBJECTIVE. `persona_rationale` is persona-voiced narration from an affected session, not a statement of observed behaviour. Quote it as the persona's words; the objective claim is `description`." }, "evidence_refs": { "screenshots": [ "s3://saram-evidence/run_2026-09-26_4f1c2a/run_2026-09-26_4f1c2a.sess_01_dana_first_timer/shots/0007.png" ] } } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Retest Issue - **Method:** `POST` - **Path:** `/api/v1/runs/{run_id}/issues/{issue_id}/retest` - **Operation ID:** `retest_issue` - **Tags:** artifact Replay one issue's recorded path again, now, and keep the answer beside the run's own. The same deterministic replay the run's reproduction ran: no persona and no model walks the path, two attempts, the target's partition reset before each. When the failure does happen again, the claim check the run's reproduction asks is asked once more, on the run's own reproduction provider. The issue and the artifact keep what the run found; this record is where the new answer goes. A replay's steps are never billed. Poll `GET .../retests/{retest_id}` until `status` is `completed` or `failed`. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`run_id` (required)**: `string` - **`issue_id` (required)**: `string` #### Request body **Content type:** `application/json` [RetestRequest](#scalar-schema-retestrequest) | `null` The optional body of `POST /runs/{id}/issues/{issue_id}/retest`. **Generated example:** ```json { "entry_url": "" } ``` #### Responses ##### 202 Successful Response **Content type:** `application/json` [RetestRecord](#scalar-schema-retestrecord) One retest of one issue: a later replay a person asked for, kept beside what the run concluded. The issue itself - `GET /runs/{id}/issues/{issue_id}` - still says what the run's own reproduction found, and so does the artifact: a retest never rewrites the run's record. Every retest is its own record. **Generated example:** ```json { "retest_id": "", "run_id": "", "issue_id": "", "status": "pending", "entry_url": "", "requested_by": "", "workflow_id": "", "result": "reproduced", "verification": { "status": "verified", "method": "marker", "markers": [ "" ], "note": "" }, "check": { "verdict": "supported", "quote": "", "quote_step": { "session_id": "", "step_id": 1 }, "reason": "", "checked_by": "judge" }, "attempts": [ { "attempt_id": "", "issue_id": "", "session_id": "", "outcome": "", "expected": { "additionalProperty": "anything" }, "observed": { "additionalProperty": "anything" }, "notes": "", "surface_trace_ref": "", "screenshot_refs": [ "" ] } ], "gate_discards": 1, "gate_ref": "", "error": "", "created_at": "", "started_at": "", "finished_at": "" } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### List Retests - **Method:** `GET` - **Path:** `/api/v1/runs/{run_id}/issues/{issue_id}/retests` - **Operation ID:** `list_retests` - **Tags:** artifact Every retest asked for on one issue, newest first, each with its status and, once it has one, its result. Empty for an issue nobody has retested. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`run_id` (required)**: `string` - **`issue_id` (required)**: `string` #### Responses ##### 200 Successful Response **Content type:** `application/json` [RetestList](#scalar-schema-retestlist) Every retest asked for on one issue, newest first. **Generated example:** ```json { "run_id": "", "issue_id": "", "retests": [ { "retest_id": "", "run_id": "", "issue_id": "", "status": "pending", "entry_url": "", "requested_by": "", "workflow_id": "", "result": "reproduced", "verification": { "status": "verified", "method": "marker", "markers": [ "" ], "note": "" }, "check": { "verdict": "supported", "quote": "", "quote_step": { "session_id": "", "step_id": 1 }, "reason": "", "checked_by": "judge" }, "attempts": [ { "attempt_id": "", "issue_id": "", "session_id": "", "outcome": "", "expected": { "additionalProperty": "anything" }, "observed": { "additionalProperty": "anything" }, "notes": "", "surface_trace_ref": "", "screenshot_refs": [ "" ] } ], "gate_discards": 1, "gate_ref": "", "error": "", "created_at": "", "started_at": "", "finished_at": "" } ] } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Get Retest - **Method:** `GET` - **Path:** `/api/v1/runs/{run_id}/issues/{issue_id}/retests/{retest_id}` - **Operation ID:** `get_retest` - **Tags:** artifact One retest of one issue: poll it after `POST .../retest` until `status` is `completed` or `failed`. `result` then says whether the failure happened again, and the issue itself keeps what the run found. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`run_id` (required)**: `string` - **`issue_id` (required)**: `string` - **`retest_id` (required)**: `string` #### Responses ##### 200 Successful Response **Content type:** `application/json` [RetestRecord](#scalar-schema-retestrecord) One retest of one issue: a later replay a person asked for, kept beside what the run concluded. The issue itself - `GET /runs/{id}/issues/{issue_id}` - still says what the run's own reproduction found, and so does the artifact: a retest never rewrites the run's record. Every retest is its own record. [Generated example](#scalar-example-17) ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Get Verdicts - **Method:** `GET` - **Path:** `/api/v1/runs/{run_id}/verdicts` - **Operation ID:** `get_verdicts` - **Tags:** artifact Each persona's verdict at the end of its session - its answer to the run's verdict question, quoted from its own diary - and the cohort's tally. Persona feedback: subjective, to be attributed to the persona, and never a ticket on its own. An empty list with the run's `status` until the run is done. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`run_id` (required)**: `string` #### Responses ##### 200 Successful Response **Content type:** `application/json` [VerdictList](#scalar-schema-verdictlist) A run's persona verdicts: each session's answer to the run's verdict question, grounded in the persona's own diary quotes, and the cohort's tally. Persona feedback, never a ticket on its own. **Example:** ```json { "run_id": "run_2026-09-26_4f1c2a", "status": "completed", "summary": { "positive": 1, "negative": 1, "undecided": 0, "top_reasons": [ "The sign-up form failed without saying why.", "Signing up was fast." ] }, "verdicts": [ { "session_id": "run_2026-09-26_4f1c2a.sess_01_dana_first_timer", "persona_id": "dana_first_timer@1", "mission_id": "sign_up", "result": "abandoned", "verdict_prompt": "Would you use this product again after today, and what decided it?", "verdict_prompt_source": "mission", "verdict": { "decision": "negative", "decision_text": "No. If I can't even make an account, I'm not trusting it with my work.", "reason": "I pressed the button four times and nothing happened", "reason_paraphrase": "The sign-up form failed without saying why.", "quotes": [ "GAVE UP: I pressed the button four times and nothing happened, so I assumed the site was broken. No. If I can't even make an account, I'm not trusting it with my work." ], "step_refs": [ 10, 18 ] } }, { "session_id": "run_2026-09-26_4f1c2a.sess_02_marcus_fast_skimmer", "persona_id": "marcus_fast_skimmer@1", "mission_id": "sign_up", "result": "completed", "verdict_prompt": "Would you use this product again after today, and what decided it?", "verdict_prompt_source": "mission", "verdict": { "decision": "positive", "decision_text": "Yes - I was in and looking at a dashboard in under a minute.", "reason": "I was in and looking at a dashboard in under a minute", "reason_paraphrase": "Signing up was fast.", "quotes": [ "FINISHED: Yes - I was in and looking at a dashboard in under a minute." ], "step_refs": [ 9 ] } } ], "advisory": "ADVISORY, SUBJECTIVE. A verdict is the persona's own answer at the end of its session, grounded in verbatim diary quotes. It is feedback, not a defect: it never files a ticket on its own and must be presented as the persona's opinion, never as an observed fact.", "subjective_fields": [ "verdict" ] } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Get Opportunities - **Method:** `GET` - **Path:** `/api/v1/runs/{run_id}/opportunities` - **Operation ID:** `get_opportunities` - **Tags:** artifact What the personas' behaviour says the product could do better, or already does well - unmet expectations, missing capabilities, value gaps, delight - scored by how many sessions share it. Persona feedback, never a ticket on its own. `display` says which clear the cohort floors and which are single-persona notes. An empty list with the run's `status` until the run is done. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`run_id` (required)**: `string` #### Responses ##### 200 Successful Response **Content type:** `application/json` [OpportunityList](#scalar-schema-opportunitylist) A run's opportunities: behaviour-grounded persona feedback that is not a defect - unmet expectations, missing capabilities, value gaps, delight - scored by how many personas share it. Never a ticket on its own. **Generated example:** ```json { "run_id": "", "status": "", "opportunities": [ { "id": "", "kind": "unmet_expectation", "title": "", "description": "", "persona_rationale": "", "suggested_change": "", "affected_sessions": [ "" ], "affected_personas": [ "" ], "reached_sessions": [ "" ], "consensus": 0, "solicited_by": "", "location": { "route": "", "element_description": "" }, "evidence": { "step_refs": [ { "session_id": "", "step_id": 1 } ], "quotes": [ "" ], "screenshots": [ "" ] }, "baseline_status": "new", "connector_refs": { "additionalProperty": "" } } ], "advisory": "", "subjective_fields": [ "" ], "display": { "note": "", "min_consensus": 1, "min_affected_sessions": 1, "strong_consensus": 1, "shown": [ "" ], "folded": [ "" ] } } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Get Tester Errors - **Method:** `GET` - **Path:** `/api/v1/runs/{run_id}/tester-errors` - **Operation ID:** `get_tester_errors` - **Tags:** artifact The findings this run attributed to the persona's own action rather than to the product. A mistyped credential, the wrong element, placeholder text read as a value: issue-shaped records that are never issues - not in `/issues`, not in `issues_total`, not reproduced, never a ticket. Read them for why a session stalled and for what to fix in the test setup. Empty with the run's `status` until the artifact is assembled, like the other views; `classified` tells an artifact that never asked from one that found none. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`run_id` (required)**: `string` #### Responses ##### 200 Successful Response **Content type:** `application/json` [TesterErrorList](#scalar-schema-testererrorlist) The run's tester errors: findings the harness attributed to its own persona, never to the product. Issue-shaped - the same id, evidence and screenshots an issue carries - and never an issue: not in `IssueList`, not in `issues_total`, not reproduced, never a ticket. They are served so a customer can see why a session stalled on their own test setup (a mistyped credential, the wrong element) instead of reading the stall as the product's. **Generated example:** ```json { "run_id": "", "status": "", "classified": true, "tester_errors": [ { "id": "", "type": "blocker", "severity": "low", "reproducible": true, "reproduction": { "attempted": true, "result": "reproduced", "attempts": 1, "notes": "" }, "persona_specific": true, "claim": { "action_step": { "session_id": "", "step_id": 1 }, "expected": "", "observed": "", "verification": { "status": "verified", "method": "marker", "markers": [ "" ], "note": "" }, "attribution": "product", "check": { "verdict": "supported", "quote": "", "quote_step": { "session_id": "", "step_id": 1 }, "reason": "", "checked_by": "judge" } }, "tester_error": { "kind": "mistyped_credential", "quote": "", "quote_step": { "session_id": "", "step_id": 1 }, "decided_by": "code", "reason": "" }, "affected_sessions": [ "" ], "affected_personas": [ "" ], "affected_count": 1, "affected_fraction": 0, "reached_sessions": [ "" ], "first_seen_step": 1, "location": { "route": "", "selector": "", "element_description": "", "viewport": "" }, "title": "", "description": "", "persona_rationale": "", "contradiction": { "a": { "step_id": 1, "session_id": "", "text": "" }, "b": { "step_id": 1, "session_id": "", "text": "" } }, "evidence": { "step_refs": [ { "session_id": "", "step_id": 1 } ], "screenshots": [ "" ], "video": { "ref": "", "ts_start_s": 1, "ts_end_s": 1 }, "dom_snapshot": "", "network": [ { "additionalProperty": "anything" } ], "console": [ { "additionalProperty": "anything" } ] }, "repro_steps": [ { "action": "", "target": "", "value": "", "replace": true, "expect": "" } ], "preconditions": [ "" ], "baseline_status": "new", "confidence": 0, "suggested_owner": "", "connector_refs": { "additionalProperty": "" } } ], "reading": [ { "tester_error_id": "", "label": "", "may_file_ticket_alone": false } ], "advisory": "", "subjective_fields": [ "" ] } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Get Usage - **Method:** `GET` - **Path:** `/api/v1/usage` - **Operation ID:** `get_usage` - **Tags:** usage This tenant's usage for one calendar month: the meters, the runs behind them, and the totals. Your own tenant and no other - the ledger is filtered by the tenant the request's key belongs to. A month you ran nothing in is an empty report, not a 404. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Query parameters - **`period`**: `string | null` The calendar month to report, YYYY-MM, in UTC. Omitted, it is the month it is now. A month you ran nothing in answers with zero totals, not a 404. #### Responses ##### 200 Successful Response **Content type:** `application/json` [UsageReport](#scalar-schema-usagereport) One month of your tenant's usage: the meters, the runs behind them, and the totals (`GET /api/v1/usage`). Your own tenant and no other: the ledger is filtered by the tenant the request's key belongs to, so a neighbour's run is not in these figures and cannot be. `GET /api/v1/usage.csv` is the same report as the file an invoice is written from. **One meter is billed and four are reported.** `billable_usd` is the month's bill: billed steps at `step_price_usd`, less `included_steps`, plus `monthly_fee_usd`. The model spend, the session count and the browser seconds are here so you can see what your keys spent and how long your personas were in your product; none of them is multiplied by anything. The arithmetic is served rather than left to the client, so a screen and an invoice cannot disagree. **Generated example:** ```json { "period": "", "from": "", "to": "", "tenant_id": "", "tenant_slug": "", "tenant_name": "", "plan": "", "house_models": false, "meters": [ { "meter": "", "role": "", "quantity": 1 } ], "totals": { "steps": 0, "sessions": 0, "browser_seconds": 0, "model_usd_hosted": 0, "model_usd_byo": 0 }, "runs": [ { "run_id": "", "caller": "", "occurred_at": "", "steps": 0, "billed_steps": 0, "sessions": 0, "browser_seconds": 0, "model_usd_hosted": 0, "model_usd_byo": 0, "billable_usd": 0 } ], "model_usd_complete": false, "billed_steps": 0, "step_price_usd": 0, "included_steps": 0, "monthly_fee_usd": 0, "monthly_cap_usd": 1, "billable_usd": 0, "usage": { "usage_price_usd": 1, "usage": 0, "usage_usd": 0, "refunded_usd": 0, "sessions": 0, "runs": [ { "run_id": "", "caller": "", "settled_at": "", "sessions": 1, "usage": 1, "usage_usd": 1, "refunded_usd": 1 } ], "personas": [ { "persona_id": "", "persona_label": "", "deleted": true, "runs": 1, "sessions": 1, "usage": 1, "usage_usd": 1, "refunded_usd": 1 } ], "refunds": [ { "run_id": "", "session_id": "", "persona_id": "", "persona_label": "", "fault": "saram", "usage": 1, "amount_usd": 1, "refunded_at": "", "note": "" } ] }, "provider": { "model_usd_byo": 0, "complete": false, "runs": [ { "run_id": "", "model_usd_byo": 1 } ] } } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Get Usage CSV - **Method:** `GET` - **Path:** `/api/v1/usage.csv` - **Operation ID:** `get_usage_csv` - **Tags:** usage The same month as a CSV: each run oldest first with a row per session under it - its Usage, whose failure ended it and what was charged and refunded for it - then a `TOTAL` row, and below it the month's grants, top-ups, expiries and adjustments. What an invoice is written from by hand - the per-role split is in the JSON above. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Query parameters - **`period`**: `string | null` The calendar month to report, YYYY-MM, in UTC. Omitted, it is the month it is now. A month you ran nothing in answers with zero totals, not a 404. #### Responses ##### 200 One header row; each run oldest first, followed by one row per session of that run; then a TOTAL row. Columns: period, tenant, run\_id, session\_id, persona\_id, persona\_label, caller, occurred\_at, then the run's meters - steps, sessions, browser\_seconds, model\_usd\_hosted, model\_usd\_byo - then the session's persona\_steps, persona\_seconds (the time its steps took, without setup and teardown), usage, fault (none, saram, provider or target), usage\_usd (net) and refunded\_usd, then billable\_usd. A run's row leaves the session columns empty and a session's row leaves the run's empty, so every column sums to its TOTAL. A session that failed on Saram's side (fault saram) reads usage\_usd 0.0000 with its whole debit in refunded\_usd; usage\_usd and refunded\_usd are empty where the prepaid ledger moved no money for the session. Then one empty line and a second table, always with its header: period, tenant, kind (grant, top\_up, expiry or adjustment), bucket (included or purchased), amount\_usd (signed, to four places), created\_at and stripe\_ref (the Stripe invoice behind a top-up or a Pro grant) - each credit that came into or lapsed from the balance that month, oldest first. An expiry is in the month whose credit lapsed. **Content type:** `application/json` `any` **Generated example:** ```json null ``` **Content type:** `text/csv` `string` **Generated example:** ```json "" ``` ##### 403 the API key lacks the required scope ##### 422 period is not a calendar month (YYYY-MM) ### Billing Portal - **Method:** `POST` - **Path:** `/api/v1/billing/portal` - **Operation ID:** `billing_portal` - **Tags:** usage A link into Stripe's hosted customer portal for this tenant: cards, invoices, receipts. Saram holds no payment instrument and renders no payment form - what it holds is the usage the invoice is derived from, which is the two routes above. The Stripe customer is created here if this tenant has never had one (`billing.ensure_customer`), so a tenant that has not run yet can still put a card on file. Minted per call and never cached: a portal session is short-lived and single-use, and a cached link would hand the second person the first person's session. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Responses ##### 200 Successful Response **Content type:** `application/json` [BillingPortal](#scalar-schema-billingportal) A one-use link into Stripe's hosted customer portal (`POST /api/v1/billing/portal`). Where a customer manages the card Saram never sees: Saram stores no payment instrument, renders no payment form and is not in the flow at all. The link is short-lived and single-use by Stripe's design, so it is minted per click and never cached; the invoices, receipts and the subscription behind it are Stripe's screens, and what this API serves is the usage the invoice is derived from (`GET /api/v1/usage`). **Generated example:** ```json { "url": "", "return_url": "" } ``` ##### 403 this credential may not manage billing: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin ##### 502 Stripe refused the request or could not be reached; the detail is Stripe's own message ##### 503 this deployment has no Stripe key configured, so there is no billing account to open ### Billing Overview - **Method:** `GET` - **Path:** `/api/v1/billing` - **Operation ID:** `billing_overview` - **Tags:** usage This tenant's plan and its prices, the Pro subscription, prepaid balance, card on file, auto-refill and recent top-ups. Read from Saram's ledger, which is the balance (section 3.2): Stripe collects money and holds no copy of it. Answers without a Stripe key too, because the ledger is this deployment's own. The subscription is Stripe's, mirrored as its events arrive; a Pro subscription nothing has mirrored yet is read from Stripe once, here. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Responses ##### 200 Successful Response **Content type:** `application/json` [BillingOverview](#scalar-schema-billingoverview) `GET /api/v1/billing`: the tenant's plan, prepaid balance, card on file, auto-refill and recent top-ups. The balance is Saram's ledger, not Stripe's: `included` is the plan's monthly credit and expires at month end, `purchased` is top-ups and does not; `held` is what runs in flight have reserved, and `available` is what a new run can hold against. **Generated example:** ```json { "plan": "", "limits": { "max_concurrent_sessions": 1, "evidence_retention_days": 1, "max_members": 1, "automation": true }, "monthly_fee_usd": 0, "included_credit_usd": 0, "usage_price_usd": 1, "subscription": { "status": "", "cancel_at_period_end": true, "current_period_end": "" }, "members_used": 0, "billing_status": "", "included_usd": 1, "purchased_usd": 1, "balance_usd": 1, "held_usd": 1, "available_usd": 1, "card_on_file": true, "auto_refill": { "auto_refill": true, "refill_below_usd": 0, "refill_amount_usd": 10, "monthly_budget_usd": 0, "last_skipped": { "at": "", "reason": "monthly_budget", "detail": "" } }, "budget_used_usd": 1, "top_ups": [ { "id": "", "amount_usd": 1, "origin": "manual", "status": "pending", "invoice_id": "", "client_secret": "", "failure_code": "", "failure_message": "", "created_at": "", "finished_at": "" } ] } ``` ##### 403 this credential may not manage billing: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin ##### 422 an amount out of bounds, or auto-refill turned on without a threshold, amount or card ##### 502 Stripe refused the request or could not be reached; the detail is Stripe's own message ##### 503 this deployment has no Stripe key configured, or its Stripe account is not set up for what was asked, so nothing is started and nothing is charged ### Billing Auto Refill - **Method:** `POST` - **Path:** `/api/v1/billing/auto-refill` - **Operation ID:** `billing_auto_refill` - **Tags:** usage Set auto-refill and the monthly budget. Turning auto-refill on wants a threshold, an amount and a card on file (`POST /billing/setup`); if the balance is already under the threshold, the first refill starts now rather than at the next run. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Request body **Required:** `true` **Content type:** `application/json` [AutoRefill](#scalar-schema-autorefill) The tenant's auto-refill setting and monthly budget (`POST /api/v1/billing/auto-refill`, and on `GET /api/v1/billing`). With `auto_refill` on, whenever the balance less what running runs hold falls below `refill_below_usd`, Saram charges the card on file `refill_amount_usd` - never past `monthly_budget_usd` in a calendar month, manual top-ups included. A declined charge, or one the bank wants authenticated, turns auto-refill off. **Generated example:** ```json { "auto_refill": true, "refill_below_usd": 0, "refill_amount_usd": 10, "monthly_budget_usd": 0 } ``` #### Responses ##### 200 Successful Response **Content type:** `application/json` [BillingOverview](#scalar-schema-billingoverview) `GET /api/v1/billing`: the tenant's plan, prepaid balance, card on file, auto-refill and recent top-ups. The balance is Saram's ledger, not Stripe's: `included` is the plan's monthly credit and expires at month end, `purchased` is top-ups and does not; `held` is what runs in flight have reserved, and `available` is what a new run can hold against. [Generated example](#scalar-example-25) ##### 403 this credential may not manage billing: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin ##### 422 an amount out of bounds, or auto-refill turned on without a threshold, amount or card ##### 502 Stripe refused the request or could not be reached; the detail is Stripe's own message ##### 503 this deployment has no Stripe key configured, or its Stripe account is not set up for what was asked, so nothing is started and nothing is charged ### Billing Upgrade - **Method:** `POST` - **Path:** `/api/v1/billing/subscription` - **Operation ID:** `billing_upgrade` - **Tags:** usage Upgrade this tenant to Saram Pro: an `incomplete` subscription whose first invoice the Payment Element pays with `client_secret`. Asked again before paying, it answers the same checkout; asked while Pro is set to cancel, it takes the cancellation back. The plan becomes `pro` when Stripe reports it active. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Responses ##### 200 Successful Response **Content type:** `application/json` [BillingSubscription](#scalar-schema-billingsubscription) `POST` and `DELETE /api/v1/billing/subscription`: the Saram Pro subscription and, while its first invoice is unpaid, the secret the Payment Element pays it with. The tenant's plan becomes `pro` when Stripe reports the subscription active, not when this answers: an abandoned checkout leaves the tenant where it was, and expires at Stripe within a day. **Generated example:** ```json { "subscription_id": "", "status": "", "client_secret": "", "cancel_at_period_end": true } ``` ##### 403 this credential may not manage billing: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin ##### 409 the tenant is already on Saram Pro or on a subscription an operator moves; or the top-up would pass the tenant's own monthly budget ##### 422 an amount out of bounds, or auto-refill turned on without a threshold, amount or card ##### 502 Stripe refused the request or could not be reached; the detail is Stripe's own message ##### 503 this deployment has no Stripe key configured, or its Stripe account is not set up for what was asked, so nothing is started and nothing is charged ### Billing Cancel - **Method:** `DELETE` - **Path:** `/api/v1/billing/subscription` - **Operation ID:** `billing_cancel` - **Tags:** usage Cancel Saram Pro at the end of the month already paid for; the tenant then moves to Free. The month's included credit stays until it expires, because the month's fee bought it. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Responses ##### 200 Successful Response **Content type:** `application/json` [BillingSubscription](#scalar-schema-billingsubscription) `POST` and `DELETE /api/v1/billing/subscription`: the Saram Pro subscription and, while its first invoice is unpaid, the secret the Payment Element pays it with. The tenant's plan becomes `pro` when Stripe reports the subscription active, not when this answers: an abandoned checkout leaves the tenant where it was, and expires at Stripe within a day. [Generated example](#scalar-example-27) ##### 403 this credential may not manage billing: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin ##### 409 the tenant is already on Saram Pro or on a subscription an operator moves; or the top-up would pass the tenant's own monthly budget ##### 422 an amount out of bounds, or auto-refill turned on without a threshold, amount or card ##### 502 Stripe refused the request or could not be reached; the detail is Stripe's own message ##### 503 this deployment has no Stripe key configured, or its Stripe account is not set up for what was asked, so nothing is started and nothing is charged ### Billing Setup - **Method:** `POST` - **Path:** `/api/v1/billing/setup` - **Operation ID:** `billing_setup` - **Tags:** usage A SetupIntent to put a card on file, or change it (`stripe.confirmSetup`). The card becomes the one top-ups and auto-refill charge when Stripe reports the intent succeeded. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Responses ##### 200 Successful Response **Content type:** `application/json` [BillingSetup](#scalar-schema-billingsetup) `POST /api/v1/billing/setup`: a SetupIntent for a card Saram may charge off-session (auto-refill). The card becomes this tenant's card on file when Stripe reports the intent succeeded. **Generated example:** ```json { "setup_intent_id": "", "client_secret": "" } ``` ##### 403 this credential may not manage billing: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin ##### 422 an amount out of bounds, or auto-refill turned on without a threshold, amount or card ##### 502 Stripe refused the request or could not be reached; the detail is Stripe's own message ##### 503 this deployment has no Stripe key configured, or its Stripe account is not set up for what was asked, so nothing is started and nothing is charged ### Billing Top Up - **Method:** `POST` - **Path:** `/api/v1/billing/top-ups` - **Operation ID:** `billing_top_up` - **Tags:** usage Buy prepaid credit: a one-off invoice for `amount_usd` x $1.00, answered with the client secret the Payment Element pays it with (section 3.3). A `TopUpWorkflow` makes and finalizes the invoice, and credits the balance once when Stripe says it is paid - on the webhook, or on its own read ten minutes later if the webhook never comes. The browser's word is never enough. An invoice left unpaid for a day is voided. Refused with a 409 when it would take this month's charges past the monthly budget. Usually answers `open` with `client_secret`; if the invoice is slow to make it answers `pending`, and `GET /billing/top-ups/{id}` carries the secret once it exists. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Request body **Required:** `true` **Content type:** `application/json` [TopUpWrite](#scalar-schema-topupwrite) `POST /api/v1/billing/top-ups`: how much credit to buy, in whole dollars. **Generated example:** ```json { "amount_usd": 10 } ``` #### Responses ##### 201 Successful Response **Content type:** `application/json` [TopUpRecord](#scalar-schema-topuprecord) One top-up: where it stands, and while it is open, the secret the Payment Element pays it with. `pending` (the invoice is being made), `open` (awaiting payment), then `paid` (credited to the balance), `failed` (declined, or no invoice could be made), `requires_action` (an off-session charge the bank wants the person to authenticate) or `void` (unpaid after a day). Credit is added once, when Stripe says the invoice is paid - never on the browser's word. A top-up that ended unpaid says why in `failure_code` and `failure_message`. **Generated example:** ```json { "id": "", "amount_usd": 1, "origin": "manual", "status": "pending", "invoice_id": "", "client_secret": "", "failure_code": "", "failure_message": "", "created_at": "", "finished_at": "" } ``` ##### 403 this credential may not manage billing: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin ##### 409 the tenant is already on Saram Pro or on a subscription an operator moves; or the top-up would pass the tenant's own monthly budget ##### 422 an amount out of bounds, or auto-refill turned on without a threshold, amount or card ##### 502 Stripe refused the request or could not be reached; the detail is Stripe's own message ##### 503 this deployment has no Stripe key configured, or its Stripe account is not set up for what was asked, so nothing is started and nothing is charged ### Billing Top Up Status - **Method:** `GET` - **Path:** `/api/v1/billing/top-ups/{topup_id}` - **Operation ID:** `billing_top_up_status` - **Tags:** usage One of this tenant's top-ups, with its client secret while it is still open. Another tenant's id is the 404 an unknown one gets. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`topup_id` (required)**: `string` #### Responses ##### 200 Successful Response **Content type:** `application/json` [TopUpRecord](#scalar-schema-topuprecord) One top-up: where it stands, and while it is open, the secret the Payment Element pays it with. `pending` (the invoice is being made), `open` (awaiting payment), then `paid` (credited to the balance), `failed` (declined, or no invoice could be made), `requires_action` (an off-session charge the bank wants the person to authenticate) or `void` (unpaid after a day). Credit is added once, when Stripe says the invoice is paid - never on the browser's word. A top-up that ended unpaid says why in `failure_code` and `failure_message`. [Generated example](#scalar-example-30) ##### 403 this credential may not manage billing: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin ##### 422 an amount out of bounds, or auto-refill turned on without a threshold, amount or card ##### 502 Stripe refused the request or could not be reached; the detail is Stripe's own message ##### 503 this deployment has no Stripe key configured, or its Stripe account is not set up for what was asked, so nothing is started and nothing is charged ### Codex Login - **Method:** `POST` - **Path:** `/api/v1/providers/codex/login` - **Operation ID:** `codex_login` - **Tags:** providers Start connecting your ChatGPT subscription, so your runs' models can bill it: open `verification_url`, type `user_code`, then poll `GET /api/v1/providers/codex` until `state` is `connected`. A login serves only the caller that made it, and only runs that caller starts. A 409 is a login already in flight; a 403 is a deployment that does not offer ChatGPT logins to this caller. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Responses ##### 202 Successful Response **Content type:** `application/json` [CodexLoginStarted](#scalar-schema-codexloginstarted) `POST /api/v1/providers/codex/login`: open `verification_url`, type `user_code`, then poll `status_url`. **Generated example:** ```json { "caller": "", "user_code": "", "verification_url": "", "expires_in": 1, "status_url": "" } ``` ##### 403 the API key lacks the required scope ### Codex Status - **Method:** `GET` - **Path:** `/api/v1/providers/codex` - **Operation ID:** `codex_status` - **Tags:** providers Your ChatGPT login's state - `none`, `pending`, `connected`, `expired` or `error` - with its plan, when it was connected and when it expires, and the subscription's rate-limit use. Never any part of the login itself. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Responses ##### 200 Successful Response **Content type:** `application/json` [CodexStatus](#scalar-schema-codexstatus) `GET /api/v1/providers/codex`. Metadata about a login; never any part of one. **Generated example:** ```json { "caller": "", "state": "none", "pending": { "user_code": "", "verification_url": "", "expires_at": "" }, "account_id": "", "plan_type": "", "connected_at": "", "expires_at": "", "last_refresh": "", "error": "", "gateway": { "login": "", "primary_used_percent": 1, "primary_reset_at": "", "last_error": "" }, "providers": { "entries": { "additionalProperty": { "model": "", "base_url": "", "credential_ref": "" } }, "roles": { "additionalProperty": "" } } } ``` ##### 403 the API key lacks the required scope ### Codex Disconnect - **Method:** `POST` - **Path:** `/api/v1/providers/codex/disconnect` - **Operation ID:** `codex_disconnect` - **Tags:** providers Disconnect your ChatGPT login: Saram revokes it at OpenAI and forgets it, and no run can bill that subscription any more. The revocation is best effort: if OpenAI cannot be reached the login is still forgotten, and then stops serving within a minute and at OpenAI only when its token expires. Your run specs keep their `providers` entry, so connecting again is all a later run needs. A 404 when there is no login to disconnect. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Responses ##### 200 Successful Response **Content type:** `application/json` [CodexDisconnected](#scalar-schema-codexdisconnected) A ChatGPT login disconnected: this caller's `state` is now `none`. **Generated example:** ```json { "caller": "", "state": "none" } ``` ##### 403 the API key lacks the required scope ### List Credentials - **Method:** `GET` - **Path:** `/api/v1/credentials` - **Operation ID:** `list_credentials` - **Tags:** credentials This tenant's live credentials, oldest first. Metadata only; the values never come back out. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Responses ##### 200 Successful Response **Content type:** `application/json` [CredentialList](#scalar-schema-credentiallist) The tenant's own credentials, as metadata: never a value, which the API stores and cannot read back. **Generated example:** ```json { "credentials": [ { "id": "", "name": "", "kind": "", "secret_ref": "", "created_at": "", "deleted_at": "" } ] } ``` ##### 403 the API key lacks the required scope ### Create Credential - **Method:** `POST` - **Path:** `/api/v1/credentials` - **Operation ID:** `create_credential` - **Tags:** credentials This tenant's own provider key, test-account login, webhook key or GitHub token, written to Secrets Manager and named by an `awssm://` ref. The ref that comes back is what goes into a run spec's `providers.entries[].credential_ref` - or, for kind `account`, into `target.seed.accounts`, for `webhook` into `connectors.webhook.secret_ref`, and for `github` into `connectors.github.token_ref` - and only this tenant's specs may name it: another tenant's spec naming it is refused at `POST /runs` with a 422. The value is never returned, logged, put on a span, or stored anywhere but the secret. What is refused: a name that is not one readable segment, a kind no worker resolves, an empty or oversized value, and a value of the wrong shape for its kind - JSON on a kind whose credential is one string, a non-object on `google-cloud`, or an `account` that is not a valid secret-store entry (422 each); a name this tenant already has, or whose secret already exists (409). There is no replace: rotation is `DELETE` then `POST`, so a mistyped name cannot retire a working key. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Request body **Required:** `true` **Content type:** `application/json` [CredentialWrite](#scalar-schema-credentialwrite) `POST /api/v1/credentials`: the one request body in this API that carries a secret. The API stores `value` once and never answers it back: no response anywhere has a `value` field. To change a credential, delete it and write it again. **Generated example:** ```json { "name": "", "kind": "", "value": "" } ``` #### Responses ##### 201 Successful Response **Content type:** `application/json` [CredentialRecord](#scalar-schema-credentialrecord) One of this tenant's credentials, a provider key or a test account, as all three routes answer it. Metadata only. **Generated example:** ```json { "id": "", "name": "", "kind": "", "secret_ref": "", "created_at": "", "deleted_at": "" } ``` ##### 403 the API key lacks the credentials:write scope, or the request carries no tenant ##### 409 this tenant already has a credential by that name ##### 422 bad name, a kind no worker resolves, or a value of the wrong shape for its kind ### Delete Credential - **Method:** `DELETE` - **Path:** `/api/v1/credentials/{name}` - **Operation ID:** `delete_credential` - **Tags:** credentials Force-delete the secret and free the name. A name this tenant does not have is a 404 - the same 404 whether it belongs to nobody or to another tenant, because the lookup never leaves this one. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`name` (required)**: `string` #### Responses ##### 200 Successful Response **Content type:** `application/json` [CredentialRecord](#scalar-schema-credentialrecord) One of this tenant's credentials, a provider key or a test account, as all three routes answer it. Metadata only. [Generated example](#scalar-example-36) ##### 403 the API key lacks the credentials:write scope, or the request carries no tenant ##### 404 this tenant has no credential by that name ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Who Am I - **Method:** `GET` - **Path:** `/api/v1/me` - **Operation ID:** `who_am_i` - **Tags:** identity Who this credential is, which tenant it spends and reads on, and what it may do. The dashboard's first call, and deliberately the one route with no scope of its own: every credential may ask what it is, including one whose role reads nothing else, and including an ungated request - which answers as the `fleet` tenant, because that is whose runs an open dev API serves. `scopes` is the *effective* answer, not the stored one: a person's role expanded through `tenancy.ROLE_SCOPES`, a minted key's own list, and the full set for a credential that carries none. A caller deciding which buttons to draw should never have to know that an empty list meant everything. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Responses ##### 200 Successful Response **Content type:** `application/json` [Me](#scalar-schema-me) Who this credential is, which tenant it spends and reads on, and what it may do. The first call a dashboard shell makes: it decides which tenant's name is in the header, whether the members and keys screens are offered, and whether the "start a run" button is there at all. It is deliberately answerable by *every* credential - a person's token, a minted key, a break-glass entry, an ungated request - so that one screen works against every way of reaching this API. **Generated example:** ```json { "subject": "", "caller": "", "tenant": { "id": "", "slug": "", "name": "", "plan": "", "status": "", "house_models": true }, "role": "", "scopes": [ "" ], "expires_at": "", "user": { "id": "", "email": "", "display_name": "" } } ``` ### List API Keys - **Method:** `GET` - **Path:** `/api/v1/api-keys` - **Operation ID:** `list_api_keys` - **Tags:** identity This tenant's live keys, oldest first. Metadata only - no secret and no hash comes back out. `last_used_at` is the field this list exists for: it answers "is anything still using this" before a revocation, and it is stamped at most once every few minutes per key, not on every request. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Responses ##### 200 Successful Response **Content type:** `application/json` [ApiKeyList](#scalar-schema-apikeylist) The tenant's API keys, as metadata: never a key's secret, which is shown once, when it is minted. **Generated example:** ```json { "api_keys": [ { "id": "", "name": "", "scopes": [ "" ], "created_by": "", "created_at": "", "last_used_at": "", "expires_at": "", "revoked_at": "" } ] } ``` ##### 403 this credential may not mint keys: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin ### Create API Key - **Method:** `POST` - **Path:** `/api/v1/api-keys` - **Operation ID:** `create_api_key` - **Tags:** identity Mint one machine key for your own tenant. The secret is in this response and in nothing else. What it is for: a person signs in to the dashboard and needs a credential for their CI, their laptop or their coding agent. The key carries *scopes* and no role, so a CI key minted `runs:write,runs:read` starts and reads runs and can neither mint another key nor upload a provider credential. Only `sha256` of the secret is stored, so there is no route that shows it again: losing it means minting another and revoking this one, which is the same motion as a rotation and is meant to be cheap. The tenant is the one your own credential belongs to and cannot be named in the request. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Request body **Required:** `true` **Content type:** `application/json` [ApiKeyWrite](#scalar-schema-apikeywrite) `POST /api/v1/api-keys`: what a person asks for when they mint one of their tenant's machine keys. **Generated example:** ```json { "name": "", "scopes": [ "" ], "expires_in_days": 1 } ``` #### Responses ##### 201 Successful Response **Content type:** `application/json` [ApiKeyMinted](#scalar-schema-apikeyminted) What `POST /api/v1/api-keys` answers: the row, and the one and only sight of the secret. `secret` is not stored anywhere - `api_keys` holds `sha256` of it - so a caller who does not keep this response mints another and revokes this one. That is the same motion as a rotation, and it is meant to be. **Generated example:** ```json { "id": "", "name": "", "scopes": [ "" ], "created_by": "", "created_at": "", "last_used_at": "", "expires_at": "", "revoked_at": "", "secret": "" } ``` ##### 403 this credential may not mint keys: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin ##### 422 a name the table cannot hold, or a scope that is not one of ours ### Revoke API Key - **Method:** `DELETE` - **Path:** `/api/v1/api-keys/{key_id}` - **Operation ID:** `revoke_api_key` - **Tags:** identity Revoke one of this tenant's keys. The row stays and `revoked_at` is set; the key stops at the gate. Never deleted, because `runs.created_by` points at it: a revoked key that vanished would leave every run it started attributed to nothing. An id belonging to another tenant is the same 404 an id nobody ever minted gets, so this route cannot be used to learn which key ids are real. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`key_id` (required)**: `string` #### Responses ##### 200 Successful Response **Content type:** `application/json` [ApiKeySummary](#scalar-schema-apikeysummary) One of this tenant's machine keys, as the list and the revocation answer it. The secret is not here. **Generated example:** ```json { "id": "", "name": "", "scopes": [ "" ], "created_by": "", "created_at": "", "last_used_at": "", "expires_at": "", "revoked_at": "" } ``` ##### 403 this credential may not mint keys: an API key without the keys:admin scope, or a person whose role in this tenant is not owner or admin ##### 404 no key of this tenant's has that id - deliberately the same answer whether the id belongs to nobody or to another tenant, so that a 404 cannot be used to enumerate key ids ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### List GitHub Installations - **Method:** `GET` - **Path:** `/api/v1/github/installations` - **Operation ID:** `list_github_installations` - **Tags:** credentials The Saram GitHub App's installations linked to this tenant, by account: where the App writes for it. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Responses ##### 200 Successful Response **Content type:** `application/json` [GitHubInstallationList](#scalar-schema-githubinstallationlist) The Saram GitHub App's installations linked to this tenant. **Generated example:** ```json { "installations": [ { "installation_id": 1, "account": "", "linked_by": "", "linked_at": "" } ] } ``` ##### 403 the API key lacks the required scope ### List Files - **Method:** `GET` - **Path:** `/api/v1/files` - **Operation ID:** `list_files` - **Tags:** files This tenant's files, newest first. Metadata only: the API never serves an uploaded file back. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Responses ##### 200 Successful Response **Content type:** `application/json` [FileList](#scalar-schema-filelist) The files this tenant has uploaded and not deleted, as metadata. **Generated example:** ```json { "files": [ { "ref": "", "name": "", "size_bytes": 1, "media_type": "", "sha256": "", "uploaded_at": "", "expires_at": "" } ] } ``` ##### 403 the API key lacks the required scope ### Upload File - **Method:** `POST` - **Path:** `/api/v1/files` - **Operation ID:** `upload_file` - **Tags:** files One file, multipart, into this tenant's own prefix of the evidence store. The answer is a `saram-file:///` ref, which is the only form a run spec may name a file by - in a run started by any key or member of the same tenant, and by nobody else's. What is refused, and why each: more bytes than the per-file limit, 25 MiB unless the deployment sets another (413, and the body is cut off at the cap rather than read and then measured); a name that is not one path segment, a media type the deployment does not accept, or magic bytes that disagree with the declared type (422 each); and a caller that may not own files (403). Accepted by default: PNG, JPEG, WebP, GIF, PDF, CSV, plain text, JSON, and Word and Excel documents (`.docx`, `.xlsx`); not HTML, SVG, scripts, archives or executables. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Request body **Required:** `true` **Content type:** `multipart/form-data` - **`file` (required)**: `string`, format: `binary` The bytes, with the part's Content-Type declaring the media type. One file per request. - **`name`**: `string` What to store the file as; overrides the part's filename. One path segment: A-Z, a-z, 0-9, space, '.', '\_' or '-', no leading dot. **Generated example:** ```json { "file": "@filename", "name": "" } ``` #### Responses ##### 201 Successful Response **Content type:** `application/json` [FileRecord](#scalar-schema-filerecord) One file this tenant uploaded, as `POST /api/v1/files` and the two read routes answer it. Metadata only, and deliberately: the API never serves an uploaded file's bytes back. Nothing needs them - the dashboard shows names - and `GET /api/v1/blobs` stays evidence-only, which is what keeps a `files/` key unreachable there. The tenant is not a field of its own: it is the ref's first segment, and it is always the tenant of whoever asked. **Generated example:** ```json { "ref": "", "name": "", "size_bytes": 1, "media_type": "", "sha256": "", "uploaded_at": "", "expires_at": "" } ``` ##### 403 this caller may not own files, or the API key lacks the files:write scope ##### 413 larger than the most one file may be on this deployment; the detail names the limit ##### 422 bad name, a media type off the allowlist, or bytes that disagree with it ### Get File - **Method:** `GET` - **Path:** `/api/v1/files/{file_id}` - **Operation ID:** `get_file` - **Tags:** files One uploaded file's metadata: its ref, name, size, media type and when it expires. Never its bytes. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`file_id` (required)**: `string` #### Responses ##### 200 Successful Response **Content type:** `application/json` [FileRecord](#scalar-schema-filerecord) One file this tenant uploaded, as `POST /api/v1/files` and the two read routes answer it. Metadata only, and deliberately: the API never serves an uploaded file's bytes back. Nothing needs them - the dashboard shows names - and `GET /api/v1/blobs` stays evidence-only, which is what keeps a `files/` key unreachable there. The tenant is not a field of its own: it is the ref's first segment, and it is always the tenant of whoever asked. [Generated example](#scalar-example-45) ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Delete File - **Method:** `POST` - **Path:** `/api/v1/files/{file_id}/delete` - **Operation ID:** `delete_file` - **Tags:** files Delete one uploaded file. A run started afterwards that names it is refused; its ref stays in the records of runs that already used it. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`file_id` (required)**: `string` #### Responses ##### 200 Successful Response **Content type:** `application/json` [FileDeleted](#scalar-schema-filedeleted) An uploaded file deleted: a run started from now on that names `ref` is refused. **Generated example:** ```json { "ref": "", "deleted": true } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### List Personas - **Method:** `GET` - **Path:** `/api/v1/personas` - **Operation ID:** `list_personas` - **Tags:** personas The personas a run can name: each by its `id@version` address, which is what a run spec's `personas` lists, with its name, pack and tags. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Responses ##### 200 Successful Response **Content type:** `application/json` [PersonaList](#scalar-schema-personalist) The personas a run can name. `GET /api/v1/personas/{address}` answers one in full. **Generated example:** ```json { "personas": [ { "address": "", "id": "", "version": 1, "name": "", "pack": "", "tags": [ "" ] } ] } ``` ##### 403 the API key lacks the required scope ### Get Persona - **Method:** `GET` - **Path:** `/api/v1/personas/{address}` - **Operation ID:** `get_persona` - **Tags:** personas One persona in full, by `id@version` or by `id` for its latest version: its goals, priors, tolerance, constraints, voice and verdict question - everything that drives how it behaves in a run. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Path parameters - **`address` (required)**: `string` #### Responses ##### 200 Successful Response **Content type:** `application/json` [Persona](#scalar-schema-persona) A structured persona: a sampling strategy over the UI state space plus a tolerance function. Two kinds of fields: a structured core the run enforces and the dashboard diffs (`tolerance`, `constraints`, `action_policy`), and prose behavior fields the model reads (`backstory`, `vocabulary`, `facts`, `conversation`, `verdict_prompt`). Both drive what the persona does. `voice` affects narration only. **Generated example:** ```json { "id": "", "version": 1, "name": "", "pack": "", "tags": [ "" ], "backstory": "", "vocabulary": { "knows": [ "" ], "unknown": [ "" ] }, "facts": { "additionalProperty": "" }, "conversation": { "phrasing": "plain_speech", "asks_before_hunting": true, "pushes_back_on_generic": true }, "verdict_prompt": "", "goals": { "primary": "", "secondary": [ "" ], "intensity": "low" }, "priors": { "product_familiarity": "none", "domain_literacy": "low", "technical_literacy": "low", "mental_models": [ "" ], "prior_instructions": [ "" ] }, "action_policy": { "reading": "reads", "input": "mouse", "exploration": "explores", "trust_defaults": "low", "help_seeking": "never", "error_response": "retry_same" }, "tolerance": { "patience_steps": 1, "error_threshold": 0, "latency_sensitivity_ms": 0, "confusion_threshold": 1, "attempts_per_subtask": 1, "abandon_behavior": "narrates_reason" }, "constraints": { "device": "desktop", "viewport": "", "network": "fast", "accessibility": [ "screen_reader" ], "locale": "en-US", "auth_state": "anonymous" }, "voice": { "style": "", "verbosity": "terse" }, "provenance": { "generator": "", "sources": [ "" ], "generated_at": "", "notes": "" } } ``` ##### 403 the API key lacks the required scope ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Get Blob - **Method:** `GET` - **Path:** `/api/v1/blobs` - **Operation ID:** `get_blob` - **Tags:** blobs Evidence bytes, streamed from the store a chunk at a time, with `Range`, `HEAD` and conditional requests. Nothing here holds the object: `get_blob` returns a handle whose `size` answers `Content-Length` and `Content-Range`, and the body is a `StreamingResponse` over `Blob.open`, which reads `CHUNK_SIZE` at a time and asks the store for the requested byte range only. So a dashboard playing three session videos costs three chunks in this process, and seeking to the last second of a recording transfers a second of it. `HEAD` answers those headers and no body, which is how a caller (the MCP server's `get_evidence_url`) learns a blob's size without fetching it. Except a step stream: an `.ndjson` blob is read whole and served with its keystrokes withheld - a surface half's keyboard input reads `[secret]` unless that step's agent half says it was not a credential (`blobs.withhold_keystrokes`) - because a pool without `type.secret` writes a typed password into its `surface.ndjson` verbatim, and so does a replay. Its `Content-Length`, ranges and `ETag` describe the bytes served, not the bytes stored. Every answer with bytes carries the blob's `ETag` and `Last-Modified`, from the metadata read that gave its size, and the route answers the conditional requests a browser cache and a media player make with them (RFC 9110 section 13): `If-None-Match` or `If-Modified-Since` that still match are a 304 with the `ETag` and no body, and a `Range` under an `If-Range` that no longer matches is ignored for the whole blob with a 200, so a player never splices a stale half onto a new one. The 304 is decided before the range, which is the order RFC 9110 gives, and is the same for `HEAD`. The generator handed to `StreamingResponse` is deliberately synchronous: Starlette pulls a sync iterator through its threadpool, so the store's blocking read never sits on the event loop, and the `with` inside it closes the file handle or the S3 body even when the client disconnects mid-stream. Signed S3 URLs would be the other way to get the bytes out of this process, and are refused for now: a presigned URL is bearer authority on the object, so it leaves both the API key gate and the run-ownership check behind and cannot be narrowed to the caller who asked. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Query parameters - **`ref` (required)**: `string` a `file://` or `s3://` evidence URI as recorded in a trace #### Header parameters - **`Range`**: `string | null` - **`If-None-Match`**: `string | null` ETags the caller already holds, or \*. If one matches the blob's (weak comparison), the answer is 304 with no body. When present, If-Modified-Since is not evaluated. - **`If-Modified-Since`**: `string | null` An HTTP-date. If the blob has not been written since, the answer is 304 with no body. Ignored when If-None-Match is present, or when it is not a valid date. - **`If-Range`**: `string | null` An ETag or an HTTP-date the caller's partial copy was fetched under. If it still matches the blob (strong comparison), Range is honoured; if not, Range is ignored and the whole blob comes back with a 200. #### Responses ##### 200 Successful Response **Headers:** - **`ETag`** a strong entity tag for the blob's bytes; send it back as If-None-Match or If-Range `string` - **`Last-Modified`** when the blob was last written, as an HTTP-date; send it back as If-Modified-Since `string` **Content type:** `application/octet-stream` ##### 206 Partial Content **Headers:** - **`ETag`** a strong entity tag for the blob's bytes; send it back as If-None-Match or If-Range `string` - **`Last-Modified`** when the blob was last written, as an HTTP-date; send it back as If-Modified-Since `string` ##### 304 Not Modified: If-None-Match or If-Modified-Since matched, so the caller's copy is current **Headers:** - **`ETag`** a strong entity tag for the blob's bytes; send it back as If-None-Match or If-Range `string` ##### 403 a ref this API never serves: outside the evidence store, or a bucket that is not its run's ##### 404 nothing here for this key: the blob is gone, the ref names no run, or it names a run belonging to another tenant - deliberately one answer, so that a 403 can never confirm a run id exists ##### 416 Range Not Satisfiable ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ### Head Blob - **Method:** `HEAD` - **Path:** `/api/v1/blobs` - **Operation ID:** `head_blob` - **Tags:** blobs Evidence bytes, streamed from the store a chunk at a time, with `Range`, `HEAD` and conditional requests. Nothing here holds the object: `get_blob` returns a handle whose `size` answers `Content-Length` and `Content-Range`, and the body is a `StreamingResponse` over `Blob.open`, which reads `CHUNK_SIZE` at a time and asks the store for the requested byte range only. So a dashboard playing three session videos costs three chunks in this process, and seeking to the last second of a recording transfers a second of it. `HEAD` answers those headers and no body, which is how a caller (the MCP server's `get_evidence_url`) learns a blob's size without fetching it. Except a step stream: an `.ndjson` blob is read whole and served with its keystrokes withheld - a surface half's keyboard input reads `[secret]` unless that step's agent half says it was not a credential (`blobs.withhold_keystrokes`) - because a pool without `type.secret` writes a typed password into its `surface.ndjson` verbatim, and so does a replay. Its `Content-Length`, ranges and `ETag` describe the bytes served, not the bytes stored. Every answer with bytes carries the blob's `ETag` and `Last-Modified`, from the metadata read that gave its size, and the route answers the conditional requests a browser cache and a media player make with them (RFC 9110 section 13): `If-None-Match` or `If-Modified-Since` that still match are a 304 with the `ETag` and no body, and a `Range` under an `If-Range` that no longer matches is ignored for the whole blob with a 200, so a player never splices a stale half onto a new one. The 304 is decided before the range, which is the order RFC 9110 gives, and is the same for `HEAD`. The generator handed to `StreamingResponse` is deliberately synchronous: Starlette pulls a sync iterator through its threadpool, so the store's blocking read never sits on the event loop, and the `with` inside it closes the file handle or the S3 body even when the client disconnects mid-stream. Signed S3 URLs would be the other way to get the bytes out of this process, and are refused for now: a presigned URL is bearer authority on the object, so it leaves both the API key gate and the run-ownership check behind and cannot be narrowed to the caller who asked. **Servers:** [Inherited servers](#scalar-context-global-servers) #### Authentication - **HTTPBearer**: HTTP bearer a Saram API key (`sk_saram__...`) or a person's AuthKit access token, as `Authorization: Bearer `. Both are told apart by shape and produce the same principal. #### Query parameters - **`ref` (required)**: `string` a `file://` or `s3://` evidence URI as recorded in a trace #### Header parameters - **`Range`**: `string | null` - **`If-None-Match`**: `string | null` ETags the caller already holds, or \*. If one matches the blob's (weak comparison), the answer is 304 with no body. When present, If-Modified-Since is not evaluated. - **`If-Modified-Since`**: `string | null` An HTTP-date. If the blob has not been written since, the answer is 304 with no body. Ignored when If-None-Match is present, or when it is not a valid date. - **`If-Range`**: `string | null` An ETag or an HTTP-date the caller's partial copy was fetched under. If it still matches the blob (strong comparison), Range is honoured; if not, Range is ignored and the whole blob comes back with a 200. #### Responses ##### 200 Successful Response **Headers:** - **`ETag`** a strong entity tag for the blob's bytes; send it back as If-None-Match or If-Range `string` - **`Last-Modified`** when the blob was last written, as an HTTP-date; send it back as If-Modified-Since `string` **Content type:** `application/octet-stream` ##### 206 Partial Content **Headers:** - **`ETag`** a strong entity tag for the blob's bytes; send it back as If-None-Match or If-Range `string` - **`Last-Modified`** when the blob was last written, as an HTTP-date; send it back as If-Modified-Since `string` ##### 304 Not Modified: If-None-Match or If-Modified-Since matched, so the caller's copy is current **Headers:** - **`ETag`** a strong entity tag for the blob's bytes; send it back as If-None-Match or If-Range `string` ##### 403 a ref this API never serves: outside the evidence store, or a bucket that is not its run's ##### 404 nothing here for this key: the blob is gone, the ref names no run, or it names a run belonging to another tenant - deliberately one answer, so that a 403 can never confirm a run id exists ##### 416 Range Not Satisfiable ##### 422 Validation Error **Content type:** `application/json` [HTTPValidationError](#scalar-schema-httpvalidationerror) [Generated example](#scalar-example-4) ## Schemas ### A **Type:** `object` The first of the two statements that conflict: what the product said (`text`), and the session and step where it said it. - **`session_id`**: `string | null` - **`step_id`**: `integer | null` - **`text`**: `string | null` **Generated example:** ```json { "step_id": 1, "session_id": "", "text": "" } ``` ### AbandonBehavior **Type:** `string`, possible values: `"silent", "narrates_reason"` What the persona's diary says when they give up, on one task or on the whole session. `narrates_reason` (the default): they say why before they stop. `silent`: they simply stop. ### AccessibilityEnum **Type:** `string`, possible values: `"screen_reader", "keyboard_only", "low_vision", "reduced_motion", "color_blind"` One accessibility need the persona has. ### ActionPolicy **Type:** `object`, no additional properties How the persona works a page: how much of it they read, what they work it with, whether they wander or head straight for the goal, and how they treat defaults, help and errors. Each value becomes a line of the persona's briefing. - **`exploration` (required)**: `string`, possible values: `"explores", "beeline"` Schema: `Exploration`. Wanders vs. heads straight for the goal - **`input` (required)**: `string`, possible values: `"mouse", "keyboard", "touch"` Schema: `Input`. What the persona works the page with. `mouse`: a computer with a mouse and a keyboard. `keyboard`: tabs between fields and presses Enter rather than reach for the mouse. `touch`: a phone, tapping and swiping. This shapes the persona's habits; whether the browser itself is a touch device is `constraints.device`. - **`reading` (required)**: `string`, possible values: `"reads", "skims", "ignores"` Schema: `Reading`. How much on-screen text they consume - **`error_response`**: `string | null`, possible values: `"retry_same", "retry_different", "back_out", "abandon"` Schema: `ErrorResponse`. First reaction to an error - **`help_seeking`**: `string | null`, possible values: `"never", "after_error", "eager"` Schema: `HelpSeeking`. Uses tooltips, help links, search - **`trust_defaults`**: `string | null`, possible values: `"low", "medium", "high"` Schema: `TrustDefaults`. Accepts prefilled/default choices vs. checks everything **Generated example:** ```json { "reading": "reads", "input": "mouse", "exploration": "explores", "trust_defaults": "low", "help_seeking": "never", "error_response": "retry_same" } ``` ### ActionStep **Type:** `object`, no additional properties The step the claim is about and the replay is judged at - the action that failed, not the look that saw it or the retry after it. Written by reproduction, which picks it (`_judged_path`); absent when nothing replayed this issue. - **`session_id` (required)**: `string` - **`step_id` (required)**: `integer` **Generated example:** ```json { "session_id": "", "step_id": 1 } ``` ### AlignedStep **Type:** `object`, no additional properties One step, both halves: what the persona decided (the agent half) and what the browser did (the surface half), joined on `step_id`. A half that is missing is flagged (`has_agent`, `has_surface`), not invented; the trailing `abandon`/`complete` step is agent-only by design (`terminal`). On an `in_flight` page (`StepsPage.status`) both halves are there for every step the browser has answered. What waits for the session to end is the video, which is stored when the browser closes: a live step carries its `video_ref` and window but no `video_url` yet. - **`has_agent` (required)**: `boolean` - **`has_surface` (required)**: `boolean` - **`step_id` (required)**: `integer` - **`terminal` (required)**: `boolean` - **`action_args`**: `object` - **`action_type`**: `string | null` - **`console_errors`**: `array of object` - **`dialogs`**: `array of object` Native dialogs the page opened, or that were answered, at this step (`surface_trace.dialogs`): type, message verbatim, and how the step left it - `pending` on the step that opened one, `accept`, `dismiss` or `auto_accepted_beforeunload` on the step that answered it. A dialog floats above the page and is in no screenshot, so this is the only place a reader sees it. Empty when the pool saw none. - **`dom_mutation_count`**: `integer | null` - **`downloads`**: `array of object` Files the browser saved at this step (`surface_trace.downloads`): filename, media type, size, hash and the stored copy's ref when the pool kept the bytes - never a path. A download moves no pixel and no url, so without this the step reads as a click that did nothing. Empty when the pool saw none. - **`end_reason`**: `string | null` - **`field`**: `object | null` The focused text-entry control after this step, as the persona was told it (`agent_trace.field`): its kind, accessible name, the value it really holds (cut at 200 characters), the value's full length, and the hint it shows while empty - never part of the value. A password field, and any field holding one of the session's credentials, is `masked`: the length is there and the value is not. Present on a `click`, `type` or `key` step whose focus landed in a text control, on a pool that reports it; absent otherwise. On the agent half, so it is readable on an `in_flight` page. - **`files_attached`**: `array of object` Files the body handed to the page at this step (`surface_trace.files_attached`): name, size, media type, hash and whether it went through the file picker or straight into a file input - never a path. Empty on every step but an `upload`. - **`inputs`**: `array of object` What the body dispatched at this step (`surface_trace.inputs_dispatched`): pointer and keyboard input, with the selector and text each resolved onto. On a `type` the persona made with the session's credential (`action_args.secret`), every keyboard input's `detail.text` and `resolved_text` is `[secret]`, whatever the pool recorded: the password is on no step a reader is served. - **`latency_ms`**: `integer | null` - **`model`**: `string | null` - **`narration`**: `string | null` SUBJECTIVE: the persona's diary line said before this action. Quote it as the persona's, never as a finding. - **`network_failures`**: `array of object` - **`observation_mode`**: `string | null` - **`perceived_state`**: `string | null` - **`reasoning_summary`**: `string | null` - **`result`**: `string | null` On the terminal step, the artifact result it implies (`completed`, `abandoned`, `budget_exhausted`). - **`route`**: `string | null` - **`screenshot_ref`**: `string | null` - **`screenshot_url`**: `string | null` - **`target_description`**: `string | null` - **`timing`**: [StepTiming](#scalar-schema-steptiming) How long the browser took over one step, in milliseconds. Each is absent when the browser did not measure it. - **`tokens`**: [StepTokens](#scalar-schema-steptokens) | `null` The model tokens behind one step's decision: prompt, reply, and the part of the prompt read from the cache. Each is absent when the provider did not report it. - **`tolerance_state`**: `object` - **`ts_agent`**: `string | null`, format: `date-time` - **`ts_end_s`**: `number | null` - **`ts_start_s`**: `number | null` Where this step starts in the session video, seconds from its first frame. - **`ts_surface`**: `string | null`, format: `date-time` - **`url`**: `string | null` - **`video_ref`**: `string | null` - **`video_url`**: `string | null` - **`viewport`**: `string | null` **Generated example:** ```json { "step_id": 1, "has_agent": true, "has_surface": true, "terminal": true, "ts_agent": "", "action_type": "", "target_description": "", "action_args": { "additionalProperty": "anything" }, "narration": "", "reasoning_summary": "", "perceived_state": "", "observation_mode": "", "tolerance_state": { "additionalProperty": "anything" }, "latency_ms": 1, "tokens": { "input": 1, "output": 1, "cached": 1 }, "model": "", "end_reason": "", "result": "", "field": { "additionalProperty": "anything" }, "ts_surface": "", "url": "", "route": "", "viewport": "", "inputs": [ { "additionalProperty": "anything" } ], "files_attached": [ { "additionalProperty": "anything" } ], "dialogs": [ { "additionalProperty": "anything" } ], "downloads": [ { "additionalProperty": "anything" } ], "network_failures": [ { "additionalProperty": "anything" } ], "console_errors": [ { "additionalProperty": "anything" } ], "dom_mutation_count": 1, "timing": { "action_to_settle_ms": 1, "longest_task_ms": 1, "navigation_ms": 1 }, "screenshot_ref": "", "screenshot_url": "", "video_ref": "", "video_url": "", "ts_start_s": 1, "ts_end_s": 1 } ``` ### ApiKeyList **Type:** `object`, no additional properties The tenant's API keys, as metadata: never a key's secret, which is shown once, when it is minted. - **`api_keys` (required)**: array of [ApiKeySummary](#scalar-schema-apikeysummary) This tenant's live keys, oldest first. A revoked one is not listed. **Generated example:** ```json { "api_keys": [ { "id": "", "name": "", "scopes": [ "" ], "created_by": "", "created_at": "", "last_used_at": "", "expires_at": "", "revoked_at": "" } ] } ``` ### ApiKeyMinted **Type:** `object`, no additional properties What `POST /api/v1/api-keys` answers: the row, and the one and only sight of the secret. `secret` is not stored anywhere - `api_keys` holds `sha256` of it - so a caller who does not keep this response mints another and revokes this one. That is the same motion as a rotation, and it is meant to be. - **`created_at` (required)**: `string`, format: `date-time` - **`created_by` (required)**: `string` usr\_... for a person who minted it from the dashboard, operator for the CLI. - **`id` (required)**: `string` - **`name` (required)**: `string` - **`scopes` (required)**: `array of string` - **`secret` (required)**: `string` sk\_saram\_\_<40 characters>. Shown once, here, and never again by any route: only its hash is stored. Treat it as you would a password - it carries this tenant's authority until it is revoked. - **`expires_at`**: `string | null`, format: `date-time` - **`last_used_at`**: `string | null`, format: `date-time` When this key last got through the gate, to a few minutes: it answers whether anything is still using the key before you revoke it, which does not need the last second. - **`revoked_at`**: `string | null`, format: `date-time` Set on the record DELETE answers with. The row stays; the key stops working. **Generated example:** ```json { "id": "", "name": "", "scopes": [ "" ], "created_by": "", "created_at": "", "last_used_at": "", "expires_at": "", "revoked_at": "", "secret": "" } ``` ### ApiKeySummary **Type:** `object`, no additional properties One of this tenant's machine keys, as the list and the revocation answer it. The secret is not here. - **`created_at` (required)**: `string`, format: `date-time` - **`created_by` (required)**: `string` usr\_... for a person who minted it from the dashboard, operator for the CLI. - **`id` (required)**: `string` - **`name` (required)**: `string` - **`scopes` (required)**: `array of string` - **`expires_at`**: `string | null`, format: `date-time` - **`last_used_at`**: `string | null`, format: `date-time` When this key last got through the gate, to a few minutes: it answers whether anything is still using the key before you revoke it, which does not need the last second. - **`revoked_at`**: `string | null`, format: `date-time` Set on the record DELETE answers with. The row stays; the key stops working. **Generated example:** ```json { "id": "", "name": "", "scopes": [ "" ], "created_by": "", "created_at": "", "last_used_at": "", "expires_at": "", "revoked_at": "" } ``` ### ApiKeyWrite **Type:** `object`, no additional properties `POST /api/v1/api-keys`: what a person asks for when they mint one of their tenant's machine keys. - **`name` (required)**: `string` Which of this tenant's credentials this is: lowercase letters, digits, underscore and dash, up to 64 characters - ci, dana-laptop. Not unique, because a rotation is two live keys under one name until the caller has moved. - **`expires_in_days`**: `integer | null`, minimum: `1` Refuse this key at the gate after this many days. Omitted, it does not expire on its own. - **`scopes`**: `(array of string) | null` A subset of runs:write, runs:read, files:write, credentials:write, keys:admin. Omitted, the key gets runs:write and runs:read, which is what a CI key needs and nothing more. **Generated example:** ```json { "name": "", "scopes": [ "" ], "expires_in_days": 1 } ``` ### Attribution **Type:** `string` Known values, open (more may be added): `product`, `tester`, `undetermined`. Whose failure this is, asked before an issue is a product finding: could the persona's own action explain it? `product`: no - the failure is the product's to own. `tester`: yes - a mistyped credential, the wrong element, placeholder text read as a value; the record moves to `tester_errors[]` with a `tester_error` block, is never a ticket and never counted as an issue. `undetermined`: the harness could not tell (a persona handed stale credentials by the mission author typed exactly what it was told; an attribution call that failed), and a human must; it caps `status` at `marker_matched`. Absent means never asked - every issue before this release - and a reader treats absent as `undetermined`. A classification: nothing acts on it directly, the producer derives `status` from it, so a later member (`setup`) is additive and an older reader buckets it as `unknown`. **Example:** ```json "product" ``` **Example:** ```json "tester" ``` **Example:** ```json "undetermined" ``` ### AuthState **Type:** `string`, possible values: `"anonymous", "new_user", "returning_user", "admin"` The kind of visitor the persona arrives as: `anonymous` (the default), a `new_user`, a `returning_user` or an `admin`. Descriptive, and listed in each issue's `preconditions`; it signs nobody in. A session is signed in by the account it is handed from the run spec's `target.seed.accounts`. ### AutoRefill **Type:** `object`, no additional properties The tenant's auto-refill setting and monthly budget (`POST /api/v1/billing/auto-refill`, and on `GET /api/v1/billing`). With `auto_refill` on, whenever the balance less what running runs hold falls below `refill_below_usd`, Saram charges the card on file `refill_amount_usd` - never past `monthly_budget_usd` in a calendar month, manual top-ups included. A declined charge, or one the bank wants authenticated, turns auto-refill off. - **`auto_refill` (required)**: `boolean` - **`monthly_budget_usd`**: `number | null`, minimum: `0` The most Saram may charge the card in a calendar month; null is no budget. - **`refill_amount_usd`**: `integer | null`, minimum: `10`, maximum: `500` - **`refill_below_usd`**: `number | null`, minimum: `0` **Generated example:** ```json { "auto_refill": true, "refill_below_usd": 0, "refill_amount_usd": 10, "monthly_budget_usd": 0 } ``` ### AutoRefillState **Type:** `object`, no additional properties The auto-refill setting as `GET /api/v1/billing` reads it: the setting, and the refill it last did not charge. Only the setting is written back (`POST /api/v1/billing/auto-refill` takes `AutoRefill`). - **`auto_refill` (required)**: `boolean` - **`last_skipped`**: [RefillSkip](#scalar-schema-refillskip) | `null` The latest refill that was due and not charged, so a screen can say 'Auto-refill is on, but this month's budget is spent'. Null once a refill has been started since, once the setting has been changed (by POST /billing/auto-refill, or by auto-refill turning itself off after a declined card), and for a monthly\_budget skip once its calendar month is over, because the new month's budget has room again. - **`monthly_budget_usd`**: `number | null`, minimum: `0` The most Saram may charge the card in a calendar month; null is no budget. - **`refill_amount_usd`**: `integer | null`, minimum: `10`, maximum: `500` - **`refill_below_usd`**: `number | null`, minimum: `0` **Generated example:** ```json { "auto_refill": true, "refill_below_usd": 0, "refill_amount_usd": 10, "monthly_budget_usd": 0, "last_skipped": { "at": "", "reason": "monthly_budget", "detail": "" } } ``` ### B **Type:** `object` The second of the two statements that conflict: what the product said (`text`), and the session and step where it said it. - **`session_id`**: `string | null` - **`step_id`**: `integer | null` - **`text`**: `string | null` **Generated example:** ```json { "step_id": 1, "session_id": "", "text": "" } ``` ### BaselineStatus **Type:** `string` Known values, open (more may be added): `new`, `persisting`, `resolved`, `unknown`. How this finding compares with the run named as the baseline. `new`: the baseline reached the same page and did not have it (for an issue, only one that is `reproducible`). `persisting`: the baseline has it too. `unknown`: nothing could say - no baseline was compared, the baseline never reached the page, or an issue the baseline does not have was not verified. `resolved` is never stamped on a finding of this run: a baseline issue this run no longer has is counted in `summary.issues_resolved_vs_baseline`. More values may be added; read one you do not know as `unknown`. **Example:** ```json "new" ``` **Example:** ```json "persisting" ``` **Example:** ```json "resolved" ``` **Example:** ```json "unknown" ``` ### BillingOverview **Type:** `object`, no additional properties `GET /api/v1/billing`: the tenant's plan, prepaid balance, card on file, auto-refill and recent top-ups. The balance is Saram's ledger, not Stripe's: `included` is the plan's monthly credit and expires at month end, `purchased` is top-ups and does not; `held` is what runs in flight have reserved, and `available` is what a new run can hold against. - **`auto_refill` (required)**: [AutoRefillState](#scalar-schema-autorefillstate) The auto-refill setting as `GET /api/v1/billing` reads it: the setting, and the refill it last did not charge. Only the setting is written back (`POST /api/v1/billing/auto-refill` takes `AutoRefill`). - **`available_usd` (required)**: `number` - **`balance_usd` (required)**: `number` - **`billing_status` (required)**: `string` - **`budget_used_usd` (required)**: `number` This month's top-ups, paid or still in flight. - **`card_on_file` (required)**: `boolean` - **`held_usd` (required)**: `number` - **`included_usd` (required)**: `number` - **`plan` (required)**: `string` - **`purchased_usd` (required)**: `number` - **`top_ups` (required)**: array of [TopUpRecord](#scalar-schema-topuprecord) The newest twenty, newest first. No client secrets. - **`included_credit_usd`**: `number`, default: `0` The credit your plan includes each month, which expires at month end: $1 on Free, $10 on Pro. What this month has left of it is included\_usd. - **`limits`**: [PlanLimits](#scalar-schema-planlimits) What your plan allows, from the plan's row in force now. - **`members_used`**: `integer`, default: `0` How many people are in your workspace now, every role counted: see limits.max\_members. - **`monthly_fee_usd`**: `number`, default: `0` Your plan's monthly fee, before tax: $0 on Free, $20 on Pro. From the plan's row now. - **`subscription`**: [SubscriptionState](#scalar-schema-subscriptionstate) | `null` Your Saram Pro subscription, when there is one: whether it renews or ends, and when. Absent on a tenant that never started a Pro checkout. - **`usage_price_usd`**: `number | null` USD per Usage on your plan now: available\_usd divided by this is 'about N Usage'. Absent on a plan billed in steps, which has no Usage price. **Generated example:** ```json { "plan": "", "limits": { "max_concurrent_sessions": 1, "evidence_retention_days": 1, "max_members": 1, "automation": true }, "monthly_fee_usd": 0, "included_credit_usd": 0, "usage_price_usd": 1, "subscription": { "status": "", "cancel_at_period_end": true, "current_period_end": "" }, "members_used": 0, "billing_status": "", "included_usd": 1, "purchased_usd": 1, "balance_usd": 1, "held_usd": 1, "available_usd": 1, "card_on_file": true, "auto_refill": { "auto_refill": true, "refill_below_usd": 0, "refill_amount_usd": 10, "monthly_budget_usd": 0, "last_skipped": { "at": "", "reason": "monthly_budget", "detail": "" } }, "budget_used_usd": 1, "top_ups": [ { "id": "", "amount_usd": 1, "origin": "manual", "status": "pending", "invoice_id": "", "client_secret": "", "failure_code": "", "failure_message": "", "created_at": "", "finished_at": "" } ] } ``` ### BillingPortal **Type:** `object`, no additional properties A one-use link into Stripe's hosted customer portal (`POST /api/v1/billing/portal`). Where a customer manages the card Saram never sees: Saram stores no payment instrument, renders no payment form and is not in the flow at all. The link is short-lived and single-use by Stripe's design, so it is minted per click and never cached; the invoices, receipts and the subscription behind it are Stripe's screens, and what this API serves is the usage the invoice is derived from (`GET /api/v1/usage`). - **`url` (required)**: `string` Where to send the customer. Short-lived and single-use; mint a new one per click. - **`return_url`**: `string | null` Where Stripe sends them back to afterwards, when this deployment names one. **Generated example:** ```json { "url": "", "return_url": "" } ``` ### BillingSetup **Type:** `object`, no additional properties `POST /api/v1/billing/setup`: a SetupIntent for a card Saram may charge off-session (auto-refill). The card becomes this tenant's card on file when Stripe reports the intent succeeded. - **`client_secret` (required)**: `string` What the Payment Element confirms in the browser (stripe.confirmPayment / confirmSetup), where SCA happens. Present only while there is something to confirm. Saram never sees the card. - **`setup_intent_id` (required)**: `string` **Generated example:** ```json { "setup_intent_id": "", "client_secret": "" } ``` ### BillingSubscription **Type:** `object`, no additional properties `POST` and `DELETE /api/v1/billing/subscription`: the Saram Pro subscription and, while its first invoice is unpaid, the secret the Payment Element pays it with. The tenant's plan becomes `pro` when Stripe reports the subscription active, not when this answers: an abandoned checkout leaves the tenant where it was, and expires at Stripe within a day. - **`cancel_at_period_end` (required)**: `boolean` Pro ends when the month already paid for does; the tenant then moves to Free. - **`status` (required)**: `string` Stripe's: `incomplete` until the first invoice is paid, then `active`; `past_due` when a renewal failed; `canceled` once it has ended. - **`subscription_id` (required)**: `string` - **`client_secret`**: `string | null` What the Payment Element confirms in the browser (stripe.confirmPayment / confirmSetup), where SCA happens. Present only while there is something to confirm. Saram never sees the card. **Generated example:** ```json { "subscription_id": "", "status": "", "client_secret": "", "cancel_at_period_end": true } ``` ### Check **Type:** `object`, no additional properties The claim check: one narrow question asked of the evidence at the cited steps after a replay matched a marker - does this evidence show the product doing what the title says? Descriptive: nothing acts on it directly; the producer derives `status` from `verdict` and `attribution`. Absent when the check never ran, which is every issue before this release and every issue no marker matched. - **`verdict` (required)**: `string` Schema: `Verdict1`. Known values, open (more may be added): `supported`, `unsupported`, `ungrounded`, `skipped`. `supported`: the checker answered yes with a quote that gates against the ledger text of the cited steps - the only verdict that lets `status` read `verified`. `unsupported`: the checker answered no; the title claims more than the quoted line shows. `ungrounded`: the checker answered yes but its quote is not in the ledger, so the answer is discarded the way an ungrounded diary quote is and recorded in the gate ledger. `skipped`: no marker matched, or the check did not run. A classification: a later verdict is additive. - **`checked_by`**: `string | null` Schema: `CheckedBy`. Known values, open (more may be added): `judge`, `code`, `manual`. Which instrument answered: `judge` for the observer's checker model call under its own instructions, `code` for a mechanical rule, `manual` for a human. A classification. - **`quote`**: `string | null` - **`quote_step`**: [QuoteStep](#scalar-schema-quotestep) | `null` The step whose ledger block the quote came from. - **`reason`**: `string | null` **Generated example:** ```json { "verdict": "supported", "quote": "", "quote_step": { "session_id": "", "step_id": 1 }, "reason": "", "checked_by": "judge" } ``` ### CheckedBy **Type:** `string` Known values, open (more may be added): `judge`, `code`, `manual`. Which instrument answered: `judge` for the observer's checker model call under its own instructions, `code` for a mechanical rule, `manual` for a human. A classification. **Example:** ```json "judge" ``` **Example:** ```json "code" ``` **Example:** ```json "manual" ``` ### Claim **Type:** `object`, no additional properties What this issue asserts, and what checked it. A replay matching a failure marker at a step does not say the marker was the thing the title is about, and in practice it often is not; since 0.5 that match is `verification.status: marker_matched`, and `verified` is reserved for a claim a second check (`check`) found supported by a verbatim quote from the cited steps and whose failure is the product's (`attribution`). An issue with no `claim` block, or with none of `verification`, is `unverified`: never read the absence of this object as a confirmation. - **`action_step`**: [ActionStep](#scalar-schema-actionstep) | `null` The step the claim is about and the replay is judged at - the action that failed, not the look that saw it or the retry after it. Written by reproduction, which picks it (`_judged_path`); absent when nothing replayed this issue. - **`attribution`**: `string | null` Schema: `Attribution`. Known values, open (more may be added): `product`, `tester`, `undetermined`. Whose failure this is, asked before an issue is a product finding: could the persona's own action explain it? `product`: no - the failure is the product's to own. `tester`: yes - a mistyped credential, the wrong element, placeholder text read as a value; the record moves to `tester_errors[]` with a `tester_error` block, is never a ticket and never counted as an issue. `undetermined`: the harness could not tell (a persona handed stale credentials by the mission author typed exactly what it was told; an attribution call that failed), and a human must; it caps `status` at `marker_matched`. Absent means never asked - every issue before this release - and a reader treats absent as `undetermined`. A classification: nothing acts on it directly, the producer derives `status` from it, so a later member (`setup`) is additive and an older reader buckets it as `unknown`. - **`check`**: [Check](#scalar-schema-check) | `null` The claim check: one narrow question asked of the evidence at the cited steps after a replay matched a marker - does this evidence show the product doing what the title says? Descriptive: nothing acts on it directly; the producer derives `status` from `verdict` and `attribution`. Absent when the check never ran, which is every issue before this release and every issue no marker matched. - **`expected`**: `string | null` - **`observed`**: `string | null` - **`verification`**: [Verification](#scalar-schema-verification) | `null` Whether the claim was checked, and by what. The two are separate questions and were conflated once already: `status` is the answer a reader acts on, `method` only says which instrument produced it. Consumers show `status`. **Generated example:** ```json { "action_step": { "session_id": "", "step_id": 1 }, "expected": "", "observed": "", "verification": { "status": "verified", "method": "marker", "markers": [ "" ], "note": "" }, "attribution": "product", "check": { "verdict": "supported", "quote": "", "quote_step": { "session_id": "", "step_id": 1 }, "reason": "", "checked_by": "judge" } } ``` ### CodexDisconnected **Type:** `object`, no additional properties A ChatGPT login disconnected: this caller's `state` is now `none`. - **`caller` (required)**: `string` - **`state` (required)**: `string`, possible values: `"none", "pending", "connected", "expired", "error"` **Generated example:** ```json { "caller": "", "state": "none" } ``` ### CodexGateway **Type:** `object`, no additional properties What the ChatGPT gateway reports about this caller's slot: whether it can serve, and the consumer rate limit's state from the last `x-codex-*` headers it saw. Never a token and never a routing key. - **`last_error`**: `string | null` - **`login`**: `string | null` - **`primary_reset_at`**: `string | null`, format: `date-time` - **`primary_used_percent`**: `number | null` **Generated example:** ```json { "login": "", "primary_used_percent": 1, "primary_reset_at": "", "last_error": "" } ``` ### CodexLoginStarted **Type:** `object`, no additional properties `POST /api/v1/providers/codex/login`: open `verification_url`, type `user_code`, then poll `status_url`. - **`caller` (required)**: `string` - **`expires_in` (required)**: `integer` - **`status_url` (required)**: `string` - **`user_code` (required)**: `string` - **`verification_url` (required)**: `string` **Generated example:** ```json { "caller": "", "user_code": "", "verification_url": "", "expires_in": 1, "status_url": "" } ``` ### CodexPending **Type:** `object`, no additional properties A device login in flight. The `device_auth_id` that redeems it is not here and never leaves the pod. - **`expires_at` (required)**: `string`, format: `date-time` - **`user_code` (required)**: `string` - **`verification_url` (required)**: `string` **Generated example:** ```json { "user_code": "", "verification_url": "", "expires_at": "" } ``` ### CodexProviders **Type:** `object`, no additional properties The `providers` block to paste into a run spec, secret name included, so the caller never has to type one. The ref is the routing key's name, never its value. - **`entries` (required)**: `object` **Additional properties:** [CodexProvidersEntry](#scalar-schema-codexprovidersentry) One `providers` entry of a run spec, routed through this caller's ChatGPT login. - **`roles` (required)**: `object` **Additional properties:** `string` **Generated example:** ```json { "entries": { "additionalProperty": { "model": "", "base_url": "", "credential_ref": "" } }, "roles": { "additionalProperty": "" } } ``` ### CodexProvidersEntry **Type:** `object`, no additional properties One `providers` entry of a run spec, routed through this caller's ChatGPT login. - **`base_url` (required)**: `string` - **`credential_ref` (required)**: `string` - **`model` (required)**: `string` **Generated example:** ```json { "model": "", "base_url": "", "credential_ref": "" } ``` ### CodexStatus **Type:** `object`, no additional properties `GET /api/v1/providers/codex`. Metadata about a login; never any part of one. - **`caller` (required)**: `string` - **`state` (required)**: `string`, possible values: `"none", "pending", "connected", "expired", "error"` - **`account_id`**: `string | null` - **`connected_at`**: `string | null`, format: `date-time` - **`error`**: `string | null` Why the last login attempt failed, when `state` is `error` and no gateway call was involved. The OAuth `error_description`, which cannot carry a token. - **`expires_at`**: `string | null`, format: `date-time` - **`gateway`**: [CodexGateway](#scalar-schema-codexgateway) | `null` What the ChatGPT gateway reports about this caller's slot: whether it can serve, and the consumer rate limit's state from the last `x-codex-*` headers it saw. Never a token and never a routing key. - **`last_refresh`**: `string | null`, format: `date-time` - **`pending`**: [CodexPending](#scalar-schema-codexpending) | `null` A device login in flight. The `device_auth_id` that redeems it is not here and never leaves the pod. - **`plan_type`**: `string | null` - **`providers`**: [CodexProviders](#scalar-schema-codexproviders) | `null` The `providers` block to paste into a run spec, secret name included, so the caller never has to type one. The ref is the routing key's name, never its value. **Generated example:** ```json { "caller": "", "state": "none", "pending": { "user_code": "", "verification_url": "", "expires_at": "" }, "account_id": "", "plan_type": "", "connected_at": "", "expires_at": "", "last_refresh": "", "error": "", "gateway": { "login": "", "primary_used_percent": 1, "primary_reset_at": "", "last_error": "" }, "providers": { "entries": { "additionalProperty": { "model": "", "base_url": "", "credential_ref": "" } }, "roles": { "additionalProperty": "" } } } ``` ### Constraints **Type:** `object`, no additional properties The browser the persona uses: the kind of device, the screen size, the connection, the language, accessibility needs and the signed-in state they start in. Most of these set up the browser session rather than the persona's briefing. A replay of an issue runs with the same ones, and the issue lists them in `preconditions`. - **`device` (required)**: `string`, possible values: `"desktop", "mobile", "tablet"` Schema: `Device`. The kind of device the browser presents as. `mobile` and `tablet` open a mobile, touch-enabled browser; `desktop` a desktop one. The screen size is `viewport`, set separately. - **`viewport` (required)**: `string`, pattern: `^[0-9]+x[0-9]+$` - **`accessibility`**: `array | null` **Items:** `string`, possible values: `"screen_reader", "keyboard_only", "low_vision", "reduced_motion", "color_blind"` Schema: `AccessibilityEnum`. One accessibility need the persona has. - **`auth_state`**: `string | null`, possible values: `"anonymous", "new_user", "returning_user", "admin"`, default: `"anonymous"` Schema: `AuthState`. The kind of visitor the persona arrives as: `anonymous` (the default), a `new_user`, a `returning_user` or an `admin`. Descriptive, and listed in each issue's `preconditions`; it signs nobody in. A session is signed in by the account it is handed from the run spec's `target.seed.accounts`. - **`locale`**: `string | null`, default: `"en-US"` - **`network`**: `string | null`, possible values: `"fast", "regular_4g", "slow_3g", "offline_flaky"`, default: `"fast"` Schema: `Network`. The connection the browser runs on. `fast` (the default): not throttled. `regular_4g` and `slow_3g`: bandwidth limited and latency added, like those mobile connections. `offline_flaky` is not supported yet, and a session that asks for it does not start. **Generated example:** ```json { "device": "desktop", "viewport": "", "network": "fast", "accessibility": [ "screen_reader" ], "locale": "en-US", "auth_state": "anonymous" } ``` ### Contradiction **Type:** `object`, no additional properties Populated for type=contradiction: the two statements/surfaces that conflict, each pinned to a step. - **`a`**: [A](#scalar-schema-a) | `null` The first of the two statements that conflict: what the product said (`text`), and the session and step where it said it. - **`b`**: [B](#scalar-schema-b) | `null` The second of the two statements that conflict: what the product said (`text`), and the session and step where it said it. **Generated example:** ```json { "a": { "step_id": 1, "session_id": "", "text": "" }, "b": { "step_id": 1, "session_id": "", "text": "" } } ``` ### Conversation **Type:** `object`, no additional properties How the persona talks to an in-product assistant or chat surface. Matters when the target is itself an AI product. - **`asks_before_hunting`**: `boolean | null` - **`phrasing`**: `string | null`, possible values: `"plain_speech", "commands", "mixed"` Schema: `Phrasing`. Asks like they would ask a person, vs. writes instructions - **`pushes_back_on_generic`**: `boolean | null` **Generated example:** ```json { "phrasing": "plain_speech", "asks_before_hunting": true, "pushes_back_on_generic": true } ``` ### CostCeiling **Type:** `object`, no additional properties What this run would cost: the most it can bill on your plan, as a number and the arithmetic behind it. A ceiling, not a forecast. Given on every plan and whatever the run gate is doing, because "what would it cost?" has an answer on every plan: the most Usage the spec's own budgets allow, priced the way your plan bills. - **`basis` (required)**: `string` One line saying how usd was reached: the sessions, the steps, the price, and what is included. Quote it with usd. - **`billed_in` (required)**: `string`, possible values: `"usage", "steps", "nothing"` What your plan bills: usage (prepaid Usage, the Free and Pro plans), steps (persona steps, the older per-step plans), or nothing. - **`model_calls` (required)**: `string` Who bills the run's model calls, per model role: your own provider key (billed to you by that provider), your ChatGPT login (within that subscription), or Saram's credential (not billed separately). Never part of usd. - **`plan` (required)**: `string` The plan the figure is priced on. - **`sessions` (required)**: `integer` Sessions the spec plans, one per persona per mission. - **`steps_per_session` (required)**: `integer` - **`usage` (required)**: `number` The run's Usage ceiling, sessions x usage\_per\_session, whatever your plan bills in. - **`usage_per_session` (required)**: `number` The most Usage one session can use. - **`usd` (required)**: `number` The most Saram can bill for this run on your plan, in US dollars: a ceiling, since every step is counted at the most it can be charged. A normal run bills well under it (a typical 5-second step is about 1 Usage, against the 3.5 a step can reach). 0 when your plan bills nothing. Model calls on your own provider are never in it: model\_calls says who bills those. - **`price_usd`**: `number | null` Dollars per unit of billed\_in: per Usage, or per persona step. Absent when billed\_in is nothing. - **`wall_clock_s_per_session`**: `integer | null` The spec's own wall-clock budget, which bounds the seconds a session's steps are charged for. Absent when it names none. **Generated example:** ```json { "usd": 1, "billed_in": "usage", "plan": "", "price_usd": 1, "sessions": 1, "steps_per_session": 1, "wall_clock_s_per_session": 1, "usage_per_session": 1, "usage": 1, "basis": "", "model_calls": "" } ``` ### CredentialList **Type:** `object`, no additional properties The tenant's own credentials, as metadata: never a value, which the API stores and cannot read back. - **`credentials` (required)**: array of [CredentialRecord](#scalar-schema-credentialrecord) This tenant's live credentials, oldest first. A deleted one is not listed. **Generated example:** ```json { "credentials": [ { "id": "", "name": "", "kind": "", "secret_ref": "", "created_at": "", "deleted_at": "" } ] } ``` ### CredentialRecord **Type:** `object`, no additional properties One of this tenant's credentials, a provider key or a test account, as all three routes answer it. Metadata only. - **`created_at` (required)**: `string`, format: `date-time` - **`id` (required)**: `string` - **`kind` (required)**: `string` - **`name` (required)**: `string` - **`secret_ref` (required)**: `string` The awssm:// ref to paste into a run spec as providers.entries\[].credential\_ref, for an account into target.seed.accounts, as connectors.webhook.secret\_ref for a webhook key, or as connectors.github.token\_ref for a github token. Only this tenant's specs may name it. - **`deleted_at`**: `string | null`, format: `date-time` Set on the record `DELETE` answers with, absent on a live one: the secret is gone and the name is free. **Generated example:** ```json { "id": "", "name": "", "kind": "", "secret_ref": "", "created_at": "", "deleted_at": "" } ``` ### CredentialWrite **Type:** `object`, no additional properties `POST /api/v1/credentials`: the one request body in this API that carries a secret. The API stores `value` once and never answers it back: no response anywhere has a `value` field. To change a credential, delete it and write it again. - **`kind` (required)**: `string` Which provider this key is for, as the model string's prefix names it: anthropic, bedrock, openai, openrouter, azure, google, google-cloud, litellm. A run spec entry naming this credential must be on the same kind. Or account: a test-account login for a persona, named in a run spec's target.seed.accounts (or target.auth.secret\_ref) instead. Or webhook: the key a run spec's connectors.webhook is signed with, named there as secret\_ref. Or github: a token that may write issues on one repository, named as connectors.github.token\_ref and used only where the Saram GitHub App is not installed on that repository. - **`name` (required)**: `string` What to call this credential: lowercase letters, digits, underscore and dash, up to 64 characters. The last segment of the secret's name and part of the ref you will paste into a run spec, so pick something a person can read - anthropic, openai-judge. - **`value` (required)**: `string`, format: `password`, writeOnly The provider key itself. Written to Secrets Manager and never returned, logged or echoed; for google-cloud it is the service-account JSON document, and for account it is one secret-store entry as a JSON object (email, password, inbox, notes, cookies, basic, headers). **Generated example:** ```json { "name": "", "kind": "", "value": "" } ``` ### DecidedBy **Type:** `string` Known values, open (more may be added): `code`, `judge`. `code`: a mechanical comparison decided it (the credential check). `judge`: the observer's attribution model call under its own instructions decided it. A classification. **Example:** ```json "code" ``` **Example:** ```json "judge" ``` ### Decision **Type:** `string`, possible values: `"positive", "negative", "undecided"` Would keep paying / would recommend / would text 'it works' = positive ### Device **Type:** `string`, possible values: `"desktop", "mobile", "tablet"` The kind of device the browser presents as. `mobile` and `tablet` open a mobile, touch-enabled browser; `desktop` a desktop one. The screen size is `viewport`, set separately. ### DisplaySplit **Type:** `object`, no additional properties The cohort floors, and which of the run's opportunities clear them. The split is the whole run's, so every surface - here, on GitHub, in the MCP answer - leads with the same record. Nothing is dropped: `folded` is a single-persona note, one click away. - **`folded` (required)**: `array of string` - **`min_affected_sessions` (required)**: `integer` - **`min_consensus` (required)**: `number` - **`note` (required)**: `string` - **`shown` (required)**: `array of string` - **`strong_consensus` (required)**: `number` **Generated example:** ```json { "note": "", "min_consensus": 1, "min_affected_sessions": 1, "strong_consensus": 1, "shown": [ "" ], "folded": [ "" ] } ``` ### DomainLiteracy **Type:** `string`, possible values: `"low", "medium", "high"` How fluent the persona is in the language of the product's industry. `low`: does not know its terms and does not pretend to. `medium`: knows enough to follow along. `high`: fluent, and notices when a term is used loosely. ### ErrorResponse **Type:** `string`, possible values: `"retry_same", "retry_different", "back_out", "abandon"` First reaction to an error ### Evidence **Type:** `object`, no additional properties What the feedback rests on: the steps where personas raised it (`step_refs`), lines from their diaries at those steps, each checked to appear there verbatim (`quotes`), and screenshots of what they were looking at. - **`step_refs` (required)**: array of [StepRef](#scalar-schema-stepref), minItems: `1` - **`quotes`**: `(array of string) | null` - **`screenshots`**: `array | null` **Items:** `string`, format: `uri`, minLength: `1` **Generated example:** ```json { "step_refs": [ { "session_id": "", "step_id": 1 } ], "quotes": [ "" ], "screenshots": [ "" ] } ``` ### Evidence1 **Type:** `object`, no additional properties What the issue rests on: the steps where sessions met it (`step_refs`) and, when the run kept them, screenshots, a clip of the session's video, a snapshot of the page, and the network requests and console messages around it. - **`step_refs` (required)**: array of [StepRef](#scalar-schema-stepref), minItems: `1` - **`console`**: `(array of object) | null` - **`dom_snapshot`**: `string | null`, format: `uri`, minLength: `1` - **`network`**: `(array of object) | null` - **`screenshots`**: `array | null` **Items:** `string`, format: `uri`, minLength: `1` - **`video`**: [Video](#scalar-schema-video) | `null` A clip of a session's video that shows the issue: where the video is stored (`ref`), and where the clip starts and ends, in seconds from the start of the recording. **Generated example:** ```json { "step_refs": [ { "session_id": "", "step_id": 1 } ], "screenshots": [ "" ], "video": { "ref": "", "ts_start_s": 1, "ts_end_s": 1 }, "dom_snapshot": "", "network": [ { "additionalProperty": "anything" } ], "console": [ { "additionalProperty": "anything" } ] } ``` ### EvidenceRefs **Type:** `object`, no additional properties The store URIs on one issue, gathered where a consumer that wants to *fetch* evidence looks for them. `screenshots` are what the MCP `get_screenshot` tool accepts. The other two are not - it serves images only - and are fetched over `GET /api/v1/blobs?ref=` with the same key, which is why they are named here rather than left for a caller to dig out of `issue.evidence`. - **`dom_snapshot`**: `string | null` - **`screenshots`**: `array of string` - **`video`**: `string | null` **Generated example:** ```json { "screenshots": [ "" ], "dom_snapshot": "", "video": "" } ``` ### Exploration **Type:** `string`, possible values: `"explores", "beeline"` Wanders vs. heads straight for the goal ### FileDeleted **Type:** `object`, no additional properties An uploaded file deleted: a run started from now on that names `ref` is refused. - **`ref` (required)**: `string` - **`deleted`**: `boolean`, default: `true` **Generated example:** ```json { "ref": "", "deleted": true } ``` ### FileList **Type:** `object`, no additional properties The files this tenant has uploaded and not deleted, as metadata. - **`files` (required)**: array of [FileRecord](#scalar-schema-filerecord) This tenant's files, newest first. **Generated example:** ```json { "files": [ { "ref": "", "name": "", "size_bytes": 1, "media_type": "", "sha256": "", "uploaded_at": "", "expires_at": "" } ] } ``` ### FileRecord **Type:** `object`, no additional properties One file this tenant uploaded, as `POST /api/v1/files` and the two read routes answer it. Metadata only, and deliberately: the API never serves an uploaded file's bytes back. Nothing needs them - the dashboard shows names - and `GET /api/v1/blobs` stays evidence-only, which is what keeps a `files/` key unreachable there. The tenant is not a field of its own: it is the ref's first segment, and it is always the tenant of whoever asked. - **`expires_at` (required)**: `string` When the file is deleted: its upload time plus how long this deployment keeps uploads. A run naming a file past this is refused at `POST /runs`. - **`media_type` (required)**: `string` - **`name` (required)**: `string` - **`ref` (required)**: `string` `saram-file:///`: what a run spec's `files[].ref` names, and the only handle a caller ever gets on the object. A file uploaded before files were a tenant's keeps the caller name it was uploaded under in that segment. - **`sha256` (required)**: `string` - **`size_bytes` (required)**: `integer` - **`uploaded_at` (required)**: `string` **Generated example:** ```json { "ref": "", "name": "", "size_bytes": 1, "media_type": "", "sha256": "", "uploaded_at": "", "expires_at": "" } ``` ### GitHubInstallationList **Type:** `object`, no additional properties The Saram GitHub App's installations linked to this tenant. - **`installations` (required)**: array of [GitHubInstallationRecord](#scalar-schema-githubinstallationrecord) By account. Empty until somebody links one: the App then writes for this tenant nowhere. **Generated example:** ```json { "installations": [ { "installation_id": 1, "account": "", "linked_by": "", "linked_at": "" } ] } ``` ### GitHubInstallationRecord **Type:** `object`, no additional properties One installation of the Saram GitHub App linked to this tenant: the App writes there for this tenant only. - **`account` (required)**: `string` The GitHub user or organisation the App is installed on, lowercased: the owner half of a run spec's `connectors.github.repo` that this installation writes to. - **`installation_id` (required)**: `integer` GitHub's id for the installation. - **`linked_at` (required)**: `string`, format: `date-time` - **`linked_by` (required)**: `string` Who linked it: the person and the GitHub user who came back from installing it, or Saram's operator. **Generated example:** ```json { "installation_id": 1, "account": "", "linked_by": "", "linked_at": "" } ``` ### Goals **Type:** `object`, no additional properties What the persona wants out of the visit, in their own terms: the `primary` goal, anything `secondary` they also want if they get that far, and how much they want it (`intensity`). All three go into the persona's briefing beside the mission's goal; what counts as success for the run is the mission's `success_criteria`. - **`intensity` (required)**: `string`, possible values: `"low", "medium", "high"` Schema: `Intensity`. How much they want it; interacts with tolerance - **`primary` (required)**: `string` - **`secondary`**: `(array of string) | null` **Generated example:** ```json { "primary": "", "secondary": [ "" ], "intensity": "low" } ``` ### HTTPValidationError **Type:** `object` - **`detail`**: array of [ValidationError](#scalar-schema-validationerror) **Generated example:** ```json { "detail": [ { "loc": [ "" ], "msg": "", "type": "", "input": null, "ctx": {} } ] } ``` ### HelpSeeking **Type:** `string`, possible values: `"never", "after_error", "eager"` Uses tooltips, help links, search ### Input **Type:** `string`, possible values: `"mouse", "keyboard", "touch"` What the persona works the page with. `mouse`: a computer with a mouse and a keyboard. `keyboard`: tabs between fields and presses Enter rather than reach for the mouse. `touch`: a phone, tapping and swiping. This shapes the persona's habits; whether the browser itself is a touch device is `constraints.device`. ### Intensity **Type:** `string`, possible values: `"low", "medium", "high"` How much they want it; interacts with tolerance ### Issue **Type:** `object`, no additional properties A product defect the run found: what the product did wrong, where, in how many of the sessions that got there, with the evidence and the steps that reproduce it. `claim.verification.status` says what checked it, and only a `verified` issue - `reproducible: true` - may be filed as a ticket on its own. `description` is objective; `persona_rationale` is the persona's voice. - **`affected_sessions` (required)**: `array of string` - **`confidence` (required)**: `number`, minimum: `0`, maximum: `1` - **`description` (required)**: `string` - **`evidence` (required)**: [Evidence1](#scalar-schema-evidence1) What the issue rests on: the steps where sessions met it (`step_refs`) and, when the run kept them, screenshots, a clip of the session's video, a snapshot of the page, and the network requests and console messages around it. - **`id` (required)**: `string` - **`location` (required)**: [Location1](#scalar-schema-location1) Where the defect is: the page (`route`), the element in words (`element_description`) or as a selector (`selector`), and the screen size it was seen at (`viewport`, WIDTHxHEIGHT). - **`reproducible` (required)**: `boolean` - **`severity` (required)**: `string`, possible values: `"low", "medium", "high", "critical"` Schema: `Severity`. How much the defect costs the people who meet it, from least to most: `low`, `medium`, `high`, `critical`. Rated by the judge from the sessions, and lowered one step when the replay did not reproduce the issue and it stands as persona-specific friction. The GitHub connector files only issues at or above its `min_severity_for_issue`. - **`type` (required)**: `string` Schema: `Type`. Known values, open (more may be added): `blocker`, `friction`, `confusion`, `bug`, `contradiction`. Defect classes only. A moment that worked well is an opportunity of kind `delight`, never an issue: it has no failure for reproduction to replay. - **`affected_count`**: `integer | null` - **`affected_fraction`**: `number | null`, minimum: `0`, maximum: `1` - **`affected_personas`**: `(array of string) | null` - **`baseline_status`**: `string | null` Schema: `BaselineStatus`. Known values, open (more may be added): `new`, `persisting`, `resolved`, `unknown`. How this finding compares with the run named as the baseline. `new`: the baseline reached the same page and did not have it (for an issue, only one that is `reproducible`). `persisting`: the baseline has it too. `unknown`: nothing could say - no baseline was compared, the baseline never reached the page, or an issue the baseline does not have was not verified. `resolved` is never stamped on a finding of this run: a baseline issue this run no longer has is counted in `summary.issues_resolved_vs_baseline`. More values may be added; read one you do not know as `unknown`. - **`claim`**: [Claim](#scalar-schema-claim) | `null` What this issue asserts, and what checked it. A replay matching a failure marker at a step does not say the marker was the thing the title is about, and in practice it often is not; since 0.5 that match is `verification.status: marker_matched`, and `verified` is reserved for a claim a second check (`check`) found supported by a verbatim quote from the cited steps and whose failure is the product's (`attribution`). An issue with no `claim` block, or with none of `verification`, is `unverified`: never read the absence of this object as a confirmation. - **`connector_refs`**: `object | null` **Additional properties:** `string` - **`contradiction`**: [Contradiction](#scalar-schema-contradiction) | `null` Populated for type=contradiction: the two statements/surfaces that conflict, each pinned to a step. - **`first_seen_step`**: `integer | null` - **`persona_rationale`**: `string | null` - **`persona_specific`**: `boolean | null` - **`preconditions`**: `(array of string) | null` - **`reached_sessions`**: `(array of string) | null` - **`repro_steps`**: array of [ReproStep](#scalar-schema-reprostep) | `null` - **`reproduction`**: [Reproduction1](#scalar-schema-reproduction1) | `null` Whether Saram replayed the issue without the persona, and what the replay showed: whether one was `attempted`, its `result`, how many `attempts` it took, and `notes` on it. A replay that saw the failure again is not on its own enough to file a ticket; `reproducible` says whether this issue may be. - **`suggested_owner`**: `string | null` - **`tester_error`**: [TesterError](#scalar-schema-testererror) | `null` What the persona did that explains this failure. Present exactly when `claim.attribution` is `tester`, and such an issue is in `tester_errors[]`, never in `issues[]`. The quote is the evidence line that shows the persona's action - the input echo, the step head with its args, a diary line, a browser line - copied exactly from the ledger and gated the same way a diary quote is; a `tester` answer whose quote does not gate falls back to `undetermined` and this block is not written. - **`title`**: `string | null`, maxLength: `120` **Generated example:** ```json { "id": "", "type": "blocker", "severity": "low", "reproducible": true, "reproduction": { "attempted": true, "result": "reproduced", "attempts": 1, "notes": "" }, "persona_specific": true, "claim": { "action_step": { "session_id": "", "step_id": 1 }, "expected": "", "observed": "", "verification": { "status": "verified", "method": "marker", "markers": [ "" ], "note": "" }, "attribution": "product", "check": { "verdict": "supported", "quote": "", "quote_step": { "session_id": "", "step_id": 1 }, "reason": "", "checked_by": "judge" } }, "tester_error": { "kind": "mistyped_credential", "quote": "", "quote_step": { "session_id": "", "step_id": 1 }, "decided_by": "code", "reason": "" }, "affected_sessions": [ "" ], "affected_personas": [ "" ], "affected_count": 1, "affected_fraction": 0, "reached_sessions": [ "" ], "first_seen_step": 1, "location": { "route": "", "selector": "", "element_description": "", "viewport": "" }, "title": "", "description": "", "persona_rationale": "", "contradiction": { "a": { "step_id": 1, "session_id": "", "text": "" }, "b": { "step_id": 1, "session_id": "", "text": "" } }, "evidence": { "step_refs": [ { "session_id": "", "step_id": 1 } ], "screenshots": [ "" ], "video": { "ref": "", "ts_start_s": 1, "ts_end_s": 1 }, "dom_snapshot": "", "network": [ { "additionalProperty": "anything" } ], "console": [ { "additionalProperty": "anything" } ] }, "repro_steps": [ { "action": "", "target": "", "value": "", "replace": true, "expect": "" } ], "preconditions": [ "" ], "baseline_status": "new", "confidence": 0, "suggested_owner": "", "connector_refs": { "additionalProperty": "" } } ``` ### IssueDetail **Type:** `object`, no additional properties One issue with its reading and its evidence refs beside it: `GET /runs/{id}/issues/{issue_id}` and the MCP `get_issue` answer. `issue` is the contract object verbatim and `reading` is the entry `IssueList.reading[]` carries for the same issue, computed by the same function, so a surface that shows one issue - an issue page, or an agent that calls `get_issue` first - reads whether it may be filed alone, and how to read its persona-voiced fields, without a second call and cannot disagree with the list about them. - **`evidence_refs` (required)**: [EvidenceRefs](#scalar-schema-evidencerefs) The store URIs on one issue, gathered where a consumer that wants to *fetch* evidence looks for them. `screenshots` are what the MCP `get_screenshot` tool accepts. The other two are not - it serves images only - and are fetched over `GET /api/v1/blobs?ref=` with the same key, which is why they are named here rather than left for a caller to dig out of `issue.evidence`. - **`issue` (required)**: [Issue](#scalar-schema-issue) A product defect the run found: what the product did wrong, where, in how many of the sessions that got there, with the evidence and the steps that reproduce it. `claim.verification.status` says what checked it, and only a `verified` issue - `reproducible: true` - may be filed as a ticket on its own. `description` is objective; `persona_rationale` is the persona's voice. - **`reading` (required)**: [IssueReading](#scalar-schema-issuereading) How one issue of `IssueList.issues` may be acted on: whether it may be filed as a ticket on its own, and which of its fields are the persona's voice rather than observed behaviour. - **`run_id` (required)**: `string` **Generated example:** ```json { "run_id": "", "issue": { "id": "", "type": "blocker", "severity": "low", "reproducible": true, "reproduction": { "attempted": true, "result": "reproduced", "attempts": 1, "notes": "" }, "persona_specific": true, "claim": { "action_step": { "session_id": "", "step_id": 1 }, "expected": "", "observed": "", "verification": { "status": "verified", "method": "marker", "markers": [ "" ], "note": "" }, "attribution": "product", "check": { "verdict": "supported", "quote": "", "quote_step": { "session_id": "", "step_id": 1 }, "reason": "", "checked_by": "judge" } }, "tester_error": { "kind": "mistyped_credential", "quote": "", "quote_step": { "session_id": "", "step_id": 1 }, "decided_by": "code", "reason": "" }, "affected_sessions": [ "" ], "affected_personas": [ "" ], "affected_count": 1, "affected_fraction": 0, "reached_sessions": [ "" ], "first_seen_step": 1, "location": { "route": "", "selector": "", "element_description": "", "viewport": "" }, "title": "", "description": "", "persona_rationale": "", "contradiction": { "a": { "step_id": 1, "session_id": "", "text": "" }, "b": { "step_id": 1, "session_id": "", "text": "" } }, "evidence": { "step_refs": [ { "session_id": "", "step_id": 1 } ], "screenshots": [ "" ], "video": { "ref": "", "ts_start_s": 1, "ts_end_s": 1 }, "dom_snapshot": "", "network": [ { "additionalProperty": "anything" } ], "console": [ { "additionalProperty": "anything" } ] }, "repro_steps": [ { "action": "", "target": "", "value": "", "replace": true, "expect": "" } ], "preconditions": [ "" ], "baseline_status": "new", "confidence": 0, "suggested_owner": "", "connector_refs": { "additionalProperty": "" } }, "reading": { "issue_id": "", "may_file_ticket_alone": true, "subjective_fields": [ "" ], "advisory": "" }, "evidence_refs": { "screenshots": [ "" ], "dom_snapshot": "", "video": "" } } ``` ### IssueList **Type:** `object`, no additional properties A run's issues: product defects the run found, each with its evidence, and beside them how each may be acted on (`reading`). Only an issue whose reading says `may_file_ticket_alone` is a confirmed bug. - **`issues` (required)**: array of [Issue](#scalar-schema-issue) - **`reading` (required)**: array of [IssueReading](#scalar-schema-issuereading) One entry per issue, in the same order and naming its `issue_id`. Always present, so a consumer reads the gate rather than re-deriving it. - **`run_id` (required)**: `string` - **`status` (required)**: `string` The run's status. An empty `issues` is only a result once this is terminal. **Generated example:** ```json { "run_id": "", "status": "", "issues": [ { "id": "", "type": "blocker", "severity": "low", "reproducible": true, "reproduction": { "attempted": true, "result": "reproduced", "attempts": 1, "notes": "" }, "persona_specific": true, "claim": { "action_step": { "session_id": "", "step_id": 1 }, "expected": "", "observed": "", "verification": { "status": "verified", "method": "marker", "markers": [ "" ], "note": "" }, "attribution": "product", "check": { "verdict": "supported", "quote": "", "quote_step": { "session_id": "", "step_id": 1 }, "reason": "", "checked_by": "judge" } }, "tester_error": { "kind": "mistyped_credential", "quote": "", "quote_step": { "session_id": "", "step_id": 1 }, "decided_by": "code", "reason": "" }, "affected_sessions": [ "" ], "affected_personas": [ "" ], "affected_count": 1, "affected_fraction": 0, "reached_sessions": [ "" ], "first_seen_step": 1, "location": { "route": "", "selector": "", "element_description": "", "viewport": "" }, "title": "", "description": "", "persona_rationale": "", "contradiction": { "a": { "step_id": 1, "session_id": "", "text": "" }, "b": { "step_id": 1, "session_id": "", "text": "" } }, "evidence": { "step_refs": [ { "session_id": "", "step_id": 1 } ], "screenshots": [ "" ], "video": { "ref": "", "ts_start_s": 1, "ts_end_s": 1 }, "dom_snapshot": "", "network": [ { "additionalProperty": "anything" } ], "console": [ { "additionalProperty": "anything" } ] }, "repro_steps": [ { "action": "", "target": "", "value": "", "replace": true, "expect": "" } ], "preconditions": [ "" ], "baseline_status": "new", "confidence": 0, "suggested_owner": "", "connector_refs": { "additionalProperty": "" } } ], "reading": [ { "issue_id": "", "may_file_ticket_alone": true, "subjective_fields": [ "" ], "advisory": "" } ] } ``` ### IssueReading **Type:** `object`, no additional properties How one issue of `IssueList.issues` may be acted on: whether it may be filed as a ticket on its own, and which of its fields are the persona's voice rather than observed behaviour. - **`issue_id` (required)**: `string` - **`may_file_ticket_alone` (required)**: `boolean` True only when reproduction cleared this issue to be filed as a confirmed bug on its own. False means report it with its evidence and mark it unconfirmed. - **`subjective_fields` (required)**: `array of string` Fields on the issue that are the persona's voice rather than observed behaviour. Quote, never state. - **`advisory`**: `string | null` How to read those fields, present only when there are any. **Generated example:** ```json { "issue_id": "", "may_file_ticket_alone": true, "subjective_fields": [ "" ], "advisory": "" } ``` ### JudgeDecision **Type:** `string`, possible values: `"positive", "negative", "undecided"` The decision the judge returned, before the gate discarded it. The judge's and not the persona's: no diary line at the steps it cites supports it. Named apart from `verdict.decision` so that nothing reading a row by field name takes it for the persona's. ### Kind **Type:** `string` Known values, open (more may be added): `unmet_expectation`, `missing_capability`, `value_gap`, `trust_gap`, `delight`. What sort of feedback this is. `unmet_expectation`: the product did not do what the personas expected of it. `missing_capability`: something they looked for is not there. `value_gap`: they judged what it offers not worth what it asks of them. `trust_gap`: something made them hesitate to trust it. `delight`: a moment that worked well. More kinds may be added; read one you do not know as unknown. **Example:** ```json "unmet_expectation" ``` **Example:** ```json "missing_capability" ``` **Example:** ```json "value_gap" ``` **Example:** ```json "trust_gap" ``` **Example:** ```json "delight" ``` ### Kind1 **Type:** `string` Known values, open (more may be added): `mistyped_credential`, `mistyped_input`, `wrong_element`, `placeholder_as_value`, `ignored_instruction`, `other`. `mistyped_credential`: typed an email or password that differs from the one the account gave it (decided by code, comparing the typed text with the account; no secret enters a prompt or this document). `mistyped_input`: typed something other than what the brief or the screen asked for. `wrong_element`: acted on an element other than the one the title is about, or a non-interactive one. `placeholder_as_value`: read placeholder text as a pre-filled value and tried to clear or fix it. `ignored_instruction`: skipped something the screen visibly asked for first. `other`: something else the persona did that explains it. Not a tester error, by rule: a persona misreading the product's intent when a reasonable customer would misread it the same way - that is `confusion`, and it is the product's. A classification: a later kind is additive. **Example:** ```json "mistyped_credential" ``` **Example:** ```json "mistyped_input" ``` **Example:** ```json "wrong_element" ``` **Example:** ```json "placeholder_as_value" ``` **Example:** ```json "ignored_instruction" ``` **Example:** ```json "other" ``` ### LiveStatus **Type:** `object`, no additional properties The run's state as its workflow reports it while the run is open. Absent on a closed run. - **`status` (required)**: `string` - **`paused`**: `boolean`, default: `false` - **`paused_reason`**: `string | null` Why: `signal` for a human pause, `budget_cost` when `budget.total_cost_usd` was reached. Either way only a `resume` releases the run. - **`sessions`**: `object` Session count per state while the run is open: pending (the run has not started it yet), running, waiting\_for\_seat, and each artifact result. A session the run has started is counted in running once it has a browser seat and in waiting\_for\_seat while it is queued for one - taken from each session's own `waiting_for_seat`, so the two add up to the sessions in flight. A session whose live state cannot be read is counted running. **Additional properties:** `integer` **Generated example:** ```json { "status": "", "paused": false, "paused_reason": "", "sessions": { "additionalProperty": 1 } } ``` ### Location **Type:** `object` Where in the product the feedback is about: the page (`route`) and, when the personas pointed at one, the element in words (`element_description`). - **`element_description`**: `string | null` - **`route`**: `string | null` **Generated example:** ```json { "route": "", "element_description": "" } ``` ### Location1 **Type:** `object`, no additional properties Where the defect is: the page (`route`), the element in words (`element_description`) or as a selector (`selector`), and the screen size it was seen at (`viewport`, WIDTHxHEIGHT). - **`route` (required)**: `string` - **`element_description`**: `string | null` - **`selector`**: `string | null` - **`viewport`**: `string | null` **Generated example:** ```json { "route": "", "selector": "", "element_description": "", "viewport": "" } ``` ### Me **Type:** `object`, no additional properties Who this credential is, which tenant it spends and reads on, and what it may do. The first call a dashboard shell makes: it decides which tenant's name is in the header, whether the members and keys screens are offered, and whether the "start a run" button is there at all. It is deliberately answerable by *every* credential - a person's token, a minted key, a break-glass entry, an ungated request - so that one screen works against every way of reaching this API. - **`scopes` (required)**: `array of string` What this credential may actually do, already resolved: a person's role expanded to the scopes it allows, a minted key's own scopes, and the full set for a credential that carries none (a break-glass entry or an ungated request). Never an empty list - read it as the answer, not as what was stored. An operator key minted for Saram's own operations carries operator scopes instead, ops:read and on the operator page's key ops:list, which no tenant can hold, and nothing beside them. - **`subject` (required)**: `string` What runs.created\_by will record for this credential: usr\_... for a person, key\_... for a minted key, operator for a break-glass entry or an ungated request. - **`tenant` (required)**: [MeTenant](#scalar-schema-metenant) The tenant the request's credential belongs to, as `GET /api/v1/me` reports it. Everything but `id` and `slug` is optional because the gate can answer without the row: a break-glass entry and an ungated request are the `fleet` tenant by construction, and on a machine with no database there is nothing to read the name and the plan out of. - **`caller`**: `string | null` The human-readable label this credential is logged and attributed under: an email or the provider subject for a person, cookai/ci for a minted key, the bare name for a break-glass entry. Absent only when no gate is configured at all. - **`expires_at`**: `string | null`, format: `date-time` When this minted key stops getting through the gate, the expires\_at it was minted with. Absent for a key minted without one, for a person, whose sign-in the identity provider ends, and for a break-glass entry or an ungated request. Read here because a key that may not list keys, an operator key among them, can still ask when its own lapses. - **`role`**: `string | null` owner, admin, member or viewer for a person; absent for a machine credential, which carries scopes instead. - **`user`**: [MeUser](#scalar-schema-meuser) | `null` The person behind an access token. Absent from `GET /api/v1/me` when the credential is a machine's. **Generated example:** ```json { "subject": "", "caller": "", "tenant": { "id": "", "slug": "", "name": "", "plan": "", "status": "", "house_models": true }, "role": "", "scopes": [ "" ], "expires_at": "", "user": { "id": "", "email": "", "display_name": "" } } ``` ### MeTenant **Type:** `object`, no additional properties The tenant the request's credential belongs to, as `GET /api/v1/me` reports it. Everything but `id` and `slug` is optional because the gate can answer without the row: a break-glass entry and an ungated request are the `fleet` tenant by construction, and on a machine with no database there is nothing to read the name and the plan out of. - **`id` (required)**: `string` - **`slug` (required)**: `string` - **`house_models`**: `boolean | null` True when Saram has agreed to carry this tenant's model spend: your runs may name a Saram-held credential for the actor, the judge and the reproduction check even though your plan is a bring-your-own-keys one, and your steps still bill at your plan's price. It is set by Saram per tenant and by no request; absent means the tenant row was not read. - **`name`**: `string | null` - **`plan`**: `string | null` - **`status`**: `string | null` **Generated example:** ```json { "id": "", "slug": "", "name": "", "plan": "", "status": "", "house_models": true } ``` ### MeUser **Type:** `object`, no additional properties The person behind an access token. Absent from `GET /api/v1/me` when the credential is a machine's. - **`id` (required)**: `string` - **`display_name`**: `string | null` - **`email`**: `string | null` Absent until the identity provider tells us one: an AuthKit access token carries no email claim unless the deployment adds one through a JWT template, and the address otherwise arrives with the user webhook. **Generated example:** ```json { "id": "", "email": "", "display_name": "" } ``` ### Method **Type:** `string` Known values, open (more may be added): `marker`, `assertion`, `manual`. Which check ran, when one did; absent when `status` is `unverified`. `marker`: a replay compared the failure marker at `action_step` - name it in `markers`. A marker matching is evidence the step failed again, not that it failed for the reason the title gives, so on its own it produces `marker_matched`, and `verified` only with `check.verdict: supported` and `attribution: product`. `assertion`: a state assertion checked the claim itself (reserved; not produced yet). `manual`: a human checked it. A classification: nothing acts on it - every surface shows `status` - so a reader on an older release buckets an instrument it does not know as `unknown`, and adding one is additive. **Example:** ```json "marker" ``` **Example:** ```json "assertion" ``` **Example:** ```json "manual" ``` ### MonthlyCap **Type:** `object`, no additional properties The monthly cap a run was refused by: the month, what it has billed, and the cap it has reached. - **`billed_usd` (required)**: `number` What this month has billed so far, to the cent: the billable\_usd GET /api/v1/usage answers. - **`cap_usd` (required)**: `number` The tenant's monthly cap, which billed\_usd has reached: no run starts until it is raised or the month turns. - **`period` (required)**: `string` The calendar month the cap is counted over, YYYY-MM, in UTC. **Generated example:** ```json { "period": "", "billed_usd": 1, "cap_usd": 1 } ``` ### Network **Type:** `string`, possible values: `"fast", "regular_4g", "slow_3g", "offline_flaky"` The connection the browser runs on. `fast` (the default): not throttled. `regular_4g` and `slow_3g`: bandwidth limited and latency added, like those mobile connections. `offline_flaky` is not supported yet, and a session that asks for it does not start. ### Opportunity **Type:** `object`, no additional properties Behavior-grounded persona feedback that is not a defect: an unmet expectation, a missing capability, a value or trust gap, or a moment of delight. Scored by consensus across the sessions that reached the area, never by reproduction, and never a ticket on its own. `description` is the objective account; `persona_rationale` and `suggested_change` are opinion. - **`affected_sessions` (required)**: `array of string` - **`consensus` (required)**: `number`, minimum: `0`, maximum: `1` - **`description` (required)**: `string` - **`evidence` (required)**: [Evidence](#scalar-schema-evidence) What the feedback rests on: the steps where personas raised it (`step_refs`), lines from their diaries at those steps, each checked to appear there verbatim (`quotes`), and screenshots of what they were looking at. - **`id` (required)**: `string` - **`kind` (required)**: `string` Schema: `Kind`. Known values, open (more may be added): `unmet_expectation`, `missing_capability`, `value_gap`, `trust_gap`, `delight`. What sort of feedback this is. `unmet_expectation`: the product did not do what the personas expected of it. `missing_capability`: something they looked for is not there. `value_gap`: they judged what it offers not worth what it asks of them. `trust_gap`: something made them hesitate to trust it. `delight`: a moment that worked well. More kinds may be added; read one you do not know as unknown. - **`affected_personas`**: `(array of string) | null` - **`baseline_status`**: `string | null` Schema: `BaselineStatus`. Known values, open (more may be added): `new`, `persisting`, `resolved`, `unknown`. How this finding compares with the run named as the baseline. `new`: the baseline reached the same page and did not have it (for an issue, only one that is `reproducible`). `persisting`: the baseline has it too. `unknown`: nothing could say - no baseline was compared, the baseline never reached the page, or an issue the baseline does not have was not verified. `resolved` is never stamped on a finding of this run: a baseline issue this run no longer has is counted in `summary.issues_resolved_vs_baseline`. More values may be added; read one you do not know as `unknown`. - **`connector_refs`**: `object | null` **Additional properties:** `string` - **`location`**: [Location](#scalar-schema-location) | `null` Where in the product the feedback is about: the page (`route`) and, when the personas pointed at one, the element in words (`element_description`). - **`persona_rationale`**: `string | null` - **`reached_sessions`**: `(array of string) | null` - **`solicited_by`**: `string | null` - **`suggested_change`**: `string | null` - **`title`**: `string | null`, maxLength: `120` **Generated example:** ```json { "id": "", "kind": "unmet_expectation", "title": "", "description": "", "persona_rationale": "", "suggested_change": "", "affected_sessions": [ "" ], "affected_personas": [ "" ], "reached_sessions": [ "" ], "consensus": 0, "solicited_by": "", "location": { "route": "", "element_description": "" }, "evidence": { "step_refs": [ { "session_id": "", "step_id": 1 } ], "quotes": [ "" ], "screenshots": [ "" ] }, "baseline_status": "new", "connector_refs": { "additionalProperty": "" } } ``` ### OpportunityList **Type:** `object`, no additional properties A run's opportunities: behaviour-grounded persona feedback that is not a defect - unmet expectations, missing capabilities, value gaps, delight - scored by how many personas share it. Never a ticket on its own. - **`advisory` (required)**: `string` How to read this whole answer: behaviour-grounded persona feedback, consensus-scored, never a ticket on its own. - **`display` (required)**: [DisplaySplit](#scalar-schema-displaysplit) The run's shown/folded split, present even when the run raised nothing: the floors are the answer to "why is this list short", and they are the same whether or not anything cleared them. - **`opportunities` (required)**: array of [Opportunity](#scalar-schema-opportunity) - **`run_id` (required)**: `string` - **`status` (required)**: `string` The run's status. An empty `opportunities` is only a result once this is terminal. - **`subjective_fields` (required)**: `array of string` Fields on each opportunity that are opinion. `description` is deliberately not among them. **Generated example:** ```json { "run_id": "", "status": "", "opportunities": [ { "id": "", "kind": "unmet_expectation", "title": "", "description": "", "persona_rationale": "", "suggested_change": "", "affected_sessions": [ "" ], "affected_personas": [ "" ], "reached_sessions": [ "" ], "consensus": 0, "solicited_by": "", "location": { "route": "", "element_description": "" }, "evidence": { "step_refs": [ { "session_id": "", "step_id": 1 } ], "quotes": [ "" ], "screenshots": [ "" ] }, "baseline_status": "new", "connector_refs": { "additionalProperty": "" } } ], "advisory": "", "subjective_fields": [ "" ], "display": { "note": "", "min_consensus": 1, "min_affected_sessions": 1, "strong_consensus": 1, "shown": [ "" ], "folded": [ "" ] } } ``` ### PaymentRequired **Type:** `object`, no additional properties No run is started, because the tenant's plan cannot pay for it: `POST /runs`' 402 body, and the structured content of the `start_run` tool's refusal for the same reason. - **`detail` (required)**: `string` Why, in a sentence: the month's billing against the monthly cap on a plan that bills steps, or the run's Usage ceiling against the balance on a plan that prices Usage, with what to change. - **`monthly_cap`**: [MonthlyCap](#scalar-schema-monthlycap) | `null` The numbers behind the detail when the month has billed the tenant's monthly cap, on a plan that bills steps: the month, what it has billed and the cap. Absent when the refusal is the run gate's. - **`usage_ceiling`**: [UsageCeiling](#scalar-schema-usageceiling) | `null` The numbers behind the detail when the balance does not cover the run's Usage ceiling: the usage\_ceiling POST /api/v1/runs/validate answers for the same spec at the same moment, with covered false. Absent when the refusal is the monthly cap's. **Generated example:** ```json { "detail": "", "usage_ceiling": { "sessions": 1, "steps_per_session": 1, "wall_clock_s_per_session": 1, "usage_per_session": 1, "usage": 1, "usage_usd": 1, "usage_price_usd": 1, "available_usd": 1, "covered": true, "steps_per_session_that_fit": 1, "enforced": true }, "monthly_cap": { "period": "", "billed_usd": 1, "cap_usd": 1 } } ``` ### Persona **Type:** `object`, no additional properties A structured persona: a sampling strategy over the UI state space plus a tolerance function. Two kinds of fields: a structured core the run enforces and the dashboard diffs (`tolerance`, `constraints`, `action_policy`), and prose behavior fields the model reads (`backstory`, `vocabulary`, `facts`, `conversation`, `verdict_prompt`). Both drive what the persona does. `voice` affects narration only. - **`action_policy` (required)**: [ActionPolicy](#scalar-schema-actionpolicy) How the persona works a page: how much of it they read, what they work it with, whether they wander or head straight for the goal, and how they treat defaults, help and errors. Each value becomes a line of the persona's briefing. - **`constraints` (required)**: [Constraints](#scalar-schema-constraints) The browser the persona uses: the kind of device, the screen size, the connection, the language, accessibility needs and the signed-in state they start in. Most of these set up the browser session rather than the persona's briefing. A replay of an issue runs with the same ones, and the issue lists them in `preconditions`. - **`goals` (required)**: [Goals](#scalar-schema-goals) What the persona wants out of the visit, in their own terms: the `primary` goal, anything `secondary` they also want if they get that far, and how much they want it (`intensity`). All three go into the persona's briefing beside the mission's goal; what counts as success for the run is the mission's `success_criteria`. - **`id` (required)**: `string`, pattern: `^[a-z0-9_]+$` - **`priors` (required)**: [Priors](#scalar-schema-priors) What the persona knows before they arrive: how well they know this product, the language of its industry and technology in general, the expectations they bring from other products, and what they were told beforehand. Each goes into the persona's briefing and shapes what they understand, expect and notice. - **`tolerance` (required)**: [Tolerance](#scalar-schema-tolerance) How much the persona puts up with before giving up. Saram counts, step by step, the steps without visible progress, the errors and slow responses, and the moments with no clear next action, and ends the session `abandoned` the moment one crosses its threshold here; the model playing the persona cannot argue with the count. The persona is also told these limits in their own terms. - **`version` (required)**: `integer`, minimum: `1` - **`backstory`**: `string | null` - **`conversation`**: [Conversation](#scalar-schema-conversation) | `null` How the persona talks to an in-product assistant or chat surface. Matters when the target is itself an AI product. - **`facts`**: `object | null` **Additional properties:** `string` - **`name`**: `string | null` - **`pack`**: `string | null` - **`provenance`**: [Provenance](#scalar-schema-provenance) | `null` How this persona was grounded. Generators fill this. - **`tags`**: `(array of string) | null` - **`verdict_prompt`**: `string | null` - **`vocabulary`**: [Vocabulary](#scalar-schema-vocabulary) | `null` Terms the persona does and does not understand. More specific than the literacy enums in `priors`; the renderer tells the actor to say so when an unknown term appears rather than guess. - **`voice`**: [Voice](#scalar-schema-voice) | `null` Narration style only. It never influences what the persona does. **Generated example:** ```json { "id": "", "version": 1, "name": "", "pack": "", "tags": [ "" ], "backstory": "", "vocabulary": { "knows": [ "" ], "unknown": [ "" ] }, "facts": { "additionalProperty": "" }, "conversation": { "phrasing": "plain_speech", "asks_before_hunting": true, "pushes_back_on_generic": true }, "verdict_prompt": "", "goals": { "primary": "", "secondary": [ "" ], "intensity": "low" }, "priors": { "product_familiarity": "none", "domain_literacy": "low", "technical_literacy": "low", "mental_models": [ "" ], "prior_instructions": [ "" ] }, "action_policy": { "reading": "reads", "input": "mouse", "exploration": "explores", "trust_defaults": "low", "help_seeking": "never", "error_response": "retry_same" }, "tolerance": { "patience_steps": 1, "error_threshold": 0, "latency_sensitivity_ms": 0, "confusion_threshold": 1, "attempts_per_subtask": 1, "abandon_behavior": "narrates_reason" }, "constraints": { "device": "desktop", "viewport": "", "network": "fast", "accessibility": [ "screen_reader" ], "locale": "en-US", "auth_state": "anonymous" }, "voice": { "style": "", "verbosity": "terse" }, "provenance": { "generator": "", "sources": [ "" ], "generated_at": "", "notes": "" } } ``` ### PersonaList **Type:** `object`, no additional properties The personas a run can name. `GET /api/v1/personas/{address}` answers one in full. - **`personas` (required)**: array of [PersonaSummary](#scalar-schema-personasummary) **Generated example:** ```json { "personas": [ { "address": "", "id": "", "version": 1, "name": "", "pack": "", "tags": [ "" ] } ] } ``` ### PersonaSummary **Type:** `object`, no additional properties One persona a run can name, by its address, with its name, pack and tags. - **`address` (required)**: `string` `id@version`, what a run spec and a `sessions.persona_id` carry. - **`id` (required)**: `string` - **`version` (required)**: `integer` - **`name`**: `string | null` - **`pack`**: `string | null` - **`tags`**: `array of string` **Generated example:** ```json { "address": "", "id": "", "version": 1, "name": "", "pack": "", "tags": [ "" ] } ``` ### Phrasing **Type:** `string`, possible values: `"plain_speech", "commands", "mixed"` Asks like they would ask a person, vs. writes instructions ### PlanLimits **Type:** `object`, no additional properties What the tenant's plan allows, as the plan stands now. Each limit is `null` when the plan sets none, and then the platform's own bound applies instead. - **`automation`**: `boolean`, default: `true` Whether your plan includes Saram's built-in automation: filing a run's issues on GitHub and commenting on the pull request (connectors.github), the run-finished webhook (connectors.webhook), and the CI action that posts a run spec with them. False on Free and true on Pro. On a plan without it, a run spec naming either connector is refused when it is validated or started, never run without it; the API and MCP are open on every plan. - **`evidence_retention_days`**: `integer | null` How many days your plan keeps a run's evidence - its videos, screenshots and step traces - counted from the run's creation: 14 on Free, 90 on Pro. A run keeps the longer of this and what your plan kept when the run was created, and says the date it applies as evidence\_expires\_at; the run's findings are kept after it. Null when your plan sets no number, and then this deployment's own number applies. - **`max_concurrent_sessions`**: `integer | null` How many sessions of one run your plan runs at the same time: 2 on Free, 8 on Pro. A run whose budget.max\_concurrent\_sessions asks for more runs this many at once, and a run that names none runs as many as the browser pool has seats, up to this. Null when your plan sets no limit. - **`max_members`**: `integer | null` How many people your workspace holds, every role counted: 2 on Free, 10 on Pro. An invitation accepted once members\_used has reached it does not become a member, and the person is told why when they sign in; nobody already in is removed. Null when your plan sets no limit. **Generated example:** ```json { "max_concurrent_sessions": 1, "evidence_retention_days": 1, "max_members": 1, "automation": true } ``` ### PlannedSession **Type:** `object`, no additional properties One session `POST /runs` would create for this spec, planned the way the run itself plans, so the dry run cannot promise sessions the run would not make. - **`entry_url` (required)**: `string` The mission's entry URL (or the target's) with this session's partition on it (`?saram_sid=`). - **`mission_id` (required)**: `string` - **`persona_id` (required)**: `string` - **`session_id` (required)**: `string` `.sess_NN_`. When the spec names no run, the prefix reads `` literally - `POST /runs` is what mints the run id, and it is part of the session id. That is the same case as `SpecValidation.run_id` being absent. - **`account_secret_ref`**: `string | null` The seed account this session would be handed. Absent when the target's seed accounts ran out, and then the persona is told they have no login. **Generated example:** ```json { "session_id": "", "persona_id": "", "mission_id": "", "entry_url": "", "account_secret_ref": "" } ``` ### Priors **Type:** `object`, no additional properties What the persona knows before they arrive: how well they know this product, the language of its industry and technology in general, the expectations they bring from other products, and what they were told beforehand. Each goes into the persona's briefing and shapes what they understand, expect and notice. - **`domain_literacy`**: `string | null`, possible values: `"low", "medium", "high"` Schema: `DomainLiteracy`. How fluent the persona is in the language of the product's industry. `low`: does not know its terms and does not pretend to. `medium`: knows enough to follow along. `high`: fluent, and notices when a term is used loosely. - **`mental_models`**: `(array of string) | null` - **`prior_instructions`**: `(array of string) | null` - **`product_familiarity`**: `string | null`, possible values: `"none", "low", "medium", "high"` Schema: `ProductFamiliarity`. How well the persona knows this product before the session. `none`: never used it and not sure what it is. `low`: seen it once and remembers almost nothing. `medium`: knows this kind of product, not this one in particular. `high`: knows it well and expects things where they left them. - **`technical_literacy`**: `string | null`, possible values: `"low", "medium", "high"` Schema: `TechnicalLiteracy`. How comfortable the persona is with technology. `low`: not technical; keys, tokens and APIs mean nothing to them. `medium`: finds their way around an app's settings without being technical. `high`: technical, and notices when a key or a token is handled badly. **Generated example:** ```json { "product_familiarity": "none", "domain_literacy": "low", "technical_literacy": "low", "mental_models": [ "" ], "prior_instructions": [ "" ] } ``` ### ProductFamiliarity **Type:** `string`, possible values: `"none", "low", "medium", "high"` How well the persona knows this product before the session. `none`: never used it and not sure what it is. `low`: seen it once and remembers almost nothing. `medium`: knows this kind of product, not this one in particular. `high`: knows it well and expects things where they left them. ### Provenance **Type:** `object`, no additional properties How this persona was grounded. Generators fill this. - **`generated_at`**: `string | null`, format: `date-time` - **`generator`**: `string | null` - **`notes`**: `string | null` - **`sources`**: `(array of string) | null` **Generated example:** ```json { "generator": "", "sources": [ "" ], "generated_at": "", "notes": "" } ``` ### ProviderRun **Type:** `object`, no additional properties One run's model spend on your own provider key, estimated at list price. - **`model_usd_byo` (required)**: `number` - **`run_id` (required)**: `string` **Generated example:** ```json { "run_id": "", "model_usd_byo": 1 } ``` ### ProviderSpend **Type:** `object`, no additional properties What your model provider billed you for this month's runs, estimated: never Saram's charge, and never to be added to it (section 2.5). The heading a screen puts over it is "Your model provider (billed by them, not by Saram)". - **`complete`**: `boolean`, default: `false` Whether it covers every model caller in a run: the persona loop, the judge and the reproduction check, each under its own role. A run metered before the judge was priced (2026-09-25) carries the persona loop's spend alone, so a month reaching back past that is a floor for those runs. - **`model_usd_byo`**: `number`, default: `0` Model spend on your own provider keys, estimated at the provider's list price: their invoice, not a charge from Saram. A ChatGPT login's calls are included in that plan and are not priced here. - **`runs`**: array of [ProviderRun](#scalar-schema-providerrun) Per run with any spend, oldest first. **Generated example:** ```json { "model_usd_byo": 0, "complete": false, "runs": [ { "run_id": "", "model_usd_byo": 1 } ] } ``` ### QuoteStep **Type:** `object`, no additional properties The step whose ledger block the quote came from. - **`session_id` (required)**: `string` - **`step_id` (required)**: `integer` **Generated example:** ```json { "session_id": "", "step_id": 1 } ``` ### Reading **Type:** `string`, possible values: `"reads", "skims", "ignores"` How much on-screen text they consume ### RefillSkip **Type:** `object`, no additional properties A refill that was due and was not charged: the balance less what runs hold was under `refill_below_usd` with auto-refill on, and Saram did not charge the card. Nothing is retried until the next settlement or run finds a refill due again, and once the balance is gone new runs are refused. - **`at` (required)**: `string`, format: `date-time` When the latest skipped refill was decided. Each skip moves it. - **`detail` (required)**: `string` The same in a sentence: for the budget, what was already charged and what would still fit. - **`reason` (required)**: `string`, possible values: `"monthly_budget", "no_card"` `monthly_budget`: the refill would take this month's top-ups, manual and automatic, past monthly\_budget\_usd, so it waits for a higher budget or the next calendar month. `no_card`: there is no card on file to charge, so it waits for one to be saved through POST /billing/setup. **Generated example:** ```json { "at": "", "reason": "monthly_budget", "detail": "" } ``` ### ReproStep **Type:** `object` One step of a replay a person or a coding agent can run without the persona, in order from an `open` of the entry page. `action` is a browser action such as `click`, `type`, `key`, `scroll`, `back`, `reload`, `upload` or `wait`; `target` is what it acts on (the address for `open`, `x,y` screen coordinates for a `click`); `value` is what it types, presses or scrolls by; `expect` is what should hold after it, such as the address the page should be on. - **`action` (required)**: `string` - **`expect`**: `string | null` - **`replace`**: `boolean | null` - **`target`**: `string | null` - **`value`**: `string | null` **Generated example:** ```json { "action": "", "target": "", "value": "", "replace": true, "expect": "" } ``` ### Reproduction **Type:** `object`, no additional properties The counts behind `confirmation_rate`, so a reader can see its denominator instead of trusting it. Funnel stages and readings of one set of verdicts, not a partition: `candidates` are the per-session findings the judge raised, merging folds them into `merged`, reproduction ruled on `evaluated` of those and confirmed `confirmed`. The two overlaps are deliberate. A `flaky` finding is in `evaluated` (the experiment ran) and in `inconclusive` (it settled nothing). `excluded` is a subset of the unconfirmed - the ones dropped for having no persona voice left once the replay did not confirm them - and they are exactly the findings this artifact no longer carries, so `merged - excluded` is `issues_total`. - **`candidates` (required)**: `integer` - **`confirmed` (required)**: `integer` - **`evaluated` (required)**: `integer` - **`excluded` (required)**: `integer` - **`inconclusive` (required)**: `integer` - **`merged` (required)**: `integer` **Generated example:** ```json { "candidates": 1, "merged": 1, "evaluated": 1, "confirmed": 1, "inconclusive": 1, "excluded": 1 } ``` ### Reproduction1 **Type:** `object`, no additional properties Whether Saram replayed the issue without the persona, and what the replay showed: whether one was `attempted`, its `result`, how many `attempts` it took, and `notes` on it. A replay that saw the failure again is not on its own enough to file a ticket; `reproducible` says whether this issue may be. - **`attempted`**: `boolean | null` - **`attempts`**: `integer | null` - **`notes`**: `string | null` - **`result`**: `string | null` Schema: `Result1`. Known values, open (more may be added): `reproduced`, `not_reproduced`, `flaky`, `skipped`. What the replay showed. `reproduced`: the failure happened again. `not_reproduced`: it did not. `flaky`: the attempts disagreed. `skipped`: no replay ran. More values may be added; read one you do not know as unknown. **Generated example:** ```json { "attempted": true, "result": "reproduced", "attempts": 1, "notes": "" } ``` ### Result1 **Type:** `string` Known values, open (more may be added): `reproduced`, `not_reproduced`, `flaky`, `skipped`. What the replay showed. `reproduced`: the failure happened again. `not_reproduced`: it did not. `flaky`: the attempts disagreed. `skipped`: no replay ran. More values may be added; read one you do not know as unknown. **Example:** ```json "reproduced" ``` **Example:** ```json "not_reproduced" ``` **Example:** ```json "flaky" ``` **Example:** ```json "skipped" ``` ### RetestAttempt **Type:** `object`, no additional properties One replay attempt of a retest: the session it ran in, what it expected at the judged step, and what it saw. - **`attempt_id` (required)**: `string` - **`expected` (required)**: `object` The outcome signature the run recorded at the judged step: `url_after` and the failure `markers`. - **`issue_id` (required)**: `string` - **`outcome` (required)**: `string` `reproduced`, `not_reproduced`, `path_diverged`, `target_moved` or `error`. - **`session_id` (required)**: `string` The throwaway body session the attempt ran in: `.__a`, never one the run's own reproduction used, so its evidence sits beside the original's rather than over it. - **`notes`**: `string`, default: `""` - **`observed`**: `object | null` - **`screenshot_refs`**: `array of string` - **`surface_trace_ref`**: `string | null` **Generated example:** ```json { "attempt_id": "", "issue_id": "", "session_id": "", "outcome": "", "expected": { "additionalProperty": "anything" }, "observed": { "additionalProperty": "anything" }, "notes": "", "surface_trace_ref": "", "screenshot_refs": [ "" ] } ``` ### RetestList **Type:** `object`, no additional properties Every retest asked for on one issue, newest first. - **`issue_id` (required)**: `string` - **`retests` (required)**: array of [RetestRecord](#scalar-schema-retestrecord) Newest first. - **`run_id` (required)**: `string` **Generated example:** ```json { "run_id": "", "issue_id": "", "retests": [ { "retest_id": "", "run_id": "", "issue_id": "", "status": "pending", "entry_url": "", "requested_by": "", "workflow_id": "", "result": "reproduced", "verification": { "status": "verified", "method": "marker", "markers": [ "" ], "note": "" }, "check": { "verdict": "supported", "quote": "", "quote_step": { "session_id": "", "step_id": 1 }, "reason": "", "checked_by": "judge" }, "attempts": [ { "attempt_id": "", "issue_id": "", "session_id": "", "outcome": "", "expected": { "additionalProperty": "anything" }, "observed": { "additionalProperty": "anything" }, "notes": "", "surface_trace_ref": "", "screenshot_refs": [ "" ] } ], "gate_discards": 1, "gate_ref": "", "error": "", "created_at": "", "started_at": "", "finished_at": "" } ] } ``` ### RetestRecord **Type:** `object`, no additional properties One retest of one issue: a later replay a person asked for, kept beside what the run concluded. The issue itself - `GET /runs/{id}/issues/{issue_id}` - still says what the run's own reproduction found, and so does the artifact: a retest never rewrites the run's record. Every retest is its own record. - **`created_at` (required)**: `string`, format: `date-time` - **`issue_id` (required)**: `string` - **`retest_id` (required)**: `string` - **`run_id` (required)**: `string` - **`status` (required)**: `string`, possible values: `"pending", "running", "completed", "failed"` `pending` until the replay has a pool seat (it queues for one like any session), `running` while it replays, then `completed` with a `result`, or `failed` with an `error` and no result. - **`workflow_id` (required)**: `string` - **`attempts`**: array of [RetestAttempt](#scalar-schema-retestattempt) - **`check`**: [Check](#scalar-schema-check) | `null` The claim check, present only when a marker matched and it was asked. - **`entry_url`**: `string | null` The deployment this replay opened, when it was not the run's own. - **`error`**: `string | null` Why a `failed` retest has no result, in a sentence for the caller: the target's reset hook failing or refused before a replay, quoted with `target: ` in front; that Saram could not read the target's credential; or that Saram could not start or finish the retest. Never a finding about the product. Absent on every other status. - **`finished_at`**: `string | null`, format: `date-time` - **`gate_discards`**: `integer | null` How many claims this replay's gate threw away: claim-check quotes the cited evidence does not contain, each of which leaves `check` `ungrounded`, and attempts whose path diverged before the judged step. Zero is the expected value. Absent until `status` is `completed`, and on retests completed before this was recorded. - **`gate_ref`**: `string | null` The records behind `gate_discards`, fetched through `GET /api/v1/blobs`: `{"run_id", "retest_id", "issue_id", "discards"}`, one discard per claim or attempt with its `session_id`, `kind`, `reason` and a `payload` naming the `issue_id` and the `quote` or the step the path diverged at. This retest's own, never the run's reproduction's. Written even when empty, so an absent ref is a retest that has not completed, never "nothing thrown away". - **`requested_by`**: `string | null` The subject of the credential that asked: `key_...`, a person's user id, or `operator`. - **`result`**: `string | null` Schema: `Result1`. Known values, open (more may be added): `reproduced`, `not_reproduced`, `flaky`, `skipped`. What this replay concluded, in `issue.reproduction.result`'s words. `not_reproduced`: the failure the run saw at the judged step did not happen again on either attempt - the answer to "is it fixed?" that is yes as far as this replay can tell, and not a proof the product works. `reproduced`: it happened again on both. `flaky`: on one of two. `skipped`: the replay could not rule (no browser pool, a session it could not open, a control that moved). Absent until `status` is `completed`. - **`started_at`**: `string | null`, format: `date-time` - **`verification`**: [Verification](#scalar-schema-verification) | `null` The `claim.verification` block this replay would have written onto the issue: `verified` only when the failure happened again and the claim check supported the title. **Generated example:** ```json { "retest_id": "", "run_id": "", "issue_id": "", "status": "pending", "entry_url": "", "requested_by": "", "workflow_id": "", "result": "reproduced", "verification": { "status": "verified", "method": "marker", "markers": [ "" ], "note": "" }, "check": { "verdict": "supported", "quote": "", "quote_step": { "session_id": "", "step_id": 1 }, "reason": "", "checked_by": "judge" }, "attempts": [ { "attempt_id": "", "issue_id": "", "session_id": "", "outcome": "", "expected": { "additionalProperty": "anything" }, "observed": { "additionalProperty": "anything" }, "notes": "", "surface_trace_ref": "", "screenshot_refs": [ "" ] } ], "gate_discards": 1, "gate_ref": "", "error": "", "created_at": "", "started_at": "", "finished_at": "" } ``` ### RetestRequest **Type:** `object`, no additional properties The optional body of `POST /runs/{id}/issues/{issue_id}/retest`. - **`entry_url`**: `string | null`, maxLength: `2048`, pattern: `^https?://[^/?#\s]+` Where the replay opens instead of the run's own entry url - a new deployment of the same app, such as a preview. Every url the run recorded on its own origin is replayed on this url's origin, path for path, so the replay compares like with like; a path the new deployment no longer has reads as the path diverging, not as a fix. Absent replays against the run's own target. **Generated example:** ```json { "entry_url": "" } ``` ### RunDeleted **Type:** `object`, no additional properties A run its owner deleted: its evidence, its recordings, and its record - the run, its sessions, issues, verdicts, opportunities, retests and artifact - are gone, and every route now answers the run with the 404 an unknown run gets. Asking again answers this same body, so a retried delete is safe. What is kept is what the run was billed: the usage ledger outlives a deleted run as it outlives expired evidence, so a month's usage still counts it. - **`deleted_at` (required)**: `string`, format: `date-time` When it was deleted: the first time it was asked, however often since. - **`objects_deleted` (required)**: `integer` How many stored objects went with it - traces, screenshots, video, checkpoints and the run's own documents. 0 for a run that had not recorded anything. - **`run_id` (required)**: `string` The run that was deleted. **Generated example:** ```json { "run_id": "", "deleted_at": "", "objects_deleted": 1 } ``` ### RunDetail **Type:** `object`, no additional properties One run in full: everything `RunSummary` carries, plus its budgets, the files it hands its personas, its live state while it is open, and every session. - **`created_at` (required)**: `string`, format: `date-time` - **`evidence_expires_at` (required)**: `string`, format: `date-time` When this run's step-by-step evidence is deleted: `created_at` plus the days its plan keeps evidence - 14 on Free, 90 on Pro, this deployment's default on a plan that sets none - or the days its plan kept when the run was created, whichever is longer, so a plan change never moves this date earlier and an upgrade moves it later. The screenshots, the video, the agent and surface step-event streams, the checkpoints and the per-session observation documents go at this date, in every bucket they live in. Earlier when they went earlier: a run whose recordings the storage had already removed is found by the retention sweep, which takes the rest of its evidence and records when, and that moment is this date. The run's *record* is kept and has no expiry: this response, its issues, its verdicts, its opportunities, the spec it ran and the assembled artifact. A reader that wants a screenshot after that date must keep the bytes, not the ref. - **`run_id` (required)**: `string` - **`run_spec_ref` (required)**: `string` - **`status` (required)**: `string` `pending`, `running`, `observing`, then one terminal value. `completed` means at least one session reached an ending its persona got to (`completed`, `abandoned`, `blocked`, `budget_exhausted`), so there are results to read. `failed` covers both a run that broke - a rejected spec, a reset hook or the observer - and a run whose every session `errored`, which came back with no persona outcome at all; `status_reason` says which. `cancelled` is a `cancel` signal. Poll until the value is terminal, and treat only `completed` as results-ready. - **`target` (required)**: `string` - **`actor_cost_usd`**: `number | null` Of `cost_usd`, the persona loop's model calls: the sum of the priced sessions' `cost_usd`. Absent when no session's model had a price. - **`artifact_ref`**: `string | null` - **`baseline_run_id`**: `string | null` - **`billable_usd`**: `number | null` What this run costs you, to the cent: `billed_steps` at your plan's step price - or your own agreed price - in force when the run ran. This is Saram's charge; `cost_usd` beside it is model spend and is not. LIVE with `billed_steps` while the run is open, so it is what the run has cost you so far. Absent means nobody has priced your tenant, which is not the same as free - never read a missing price as $0. Your plan's included steps and monthly fee are not in it: they are properties of a month, and the invoice line with the allowance applied is this run's `billable_usd` on `GET /api/v1/usage`. - **`billed_steps`**: `integer`, default: `0` What this run is charged for: the steps its personas took - one browser action each - never the reproduction replay's, which are included at no charge. LIVE: a session is recorded when it ends, so while the run is open this is the finished sessions' counts plus each unfinished session's last `heartbeat_step`, and it climbs until the run does end. - **`budgets`**: `object` - **`caller`**: `string | null` Who started this run, in words: the caller name the API key gate identified at `POST /runs` - the operator's own name for a break-glass entry, a name like `acme/ci` for an API key. Absent when nobody was identified, which is the gate being off, a run an operator started in-process, or a run created before runs recorded their caller; the three are deliberately indistinguishable rather than dressed up with an invented name. Attribution, beside the authorization `tenant_id` carries. - **`confirmation_rate`**: `number | null` Confirmed issues over the issues reproduction ruled on. Available as soon as reproduction finishes, which is before `summary.confirmation_rate` exists; the two are the same number computed from the same lineage. - **`cost_usd`**: `number | null` What this run's model calls cost: `actor_cost_usd` and `judge_cost_usd` together. NOT what Saram charges you - `billable_usd` is that: on your own keys this is your provider's bill, and on Saram's models it is Saram's own cost, shown so your Saram usage and your provider's invoice add up. Written when the run finishes. Absent means nothing priced the models, which is not the same as free. On a run finished before the judge was priced it is the persona loop's alone, and `judge_cost_usd` is absent beside it. - **`created_by`**: `string | null` Which credential started it: `key_...` for an API key, `operator` for a break-glass entry or a run an operator started in-process. The id of the key rather than its name, so rotating a key leaves the runs it started still pointing at it. - **`evidence_expired`**: `boolean`, default: `false` Whether that date has passed. `GET /api/v1/blobs` on this run's session evidence answers 404 once it has, and that 404 means "expired", not "never existed" - which is why the date is published beside it. - **`files`**: array of [RunFile](#scalar-schema-runfile) The files the run hands its personas, in the spec's order: its `files[]`, without the rest of the spec. Empty for a run that names none. - **`finished_at`**: `string | null`, format: `date-time` - **`held_usd`**: `number | null` What this run still reserves from your balance: the most its spec's budgets can use (the Usage ceiling), less each finished session's share. Released when the run's last session is settled. Absent when the run holds nothing - it has finished, or the run gate did not hold for it. - **`judge_cost_usd`**: `number | null` Of `cost_usd`, the observer's model calls: the judge's reading of each session, attribution, the merge, and the reproduction's claim check and tie-break - a failed attempt included, because it was paid for. On today's models this is often the larger half. A retest you ask for later is not in it. Absent when none of those calls had a price, and on a run finished before they were priced. - **`live`**: [LiveStatus](#scalar-schema-livestatus) | `null` The run's state as its workflow reports it while the run is open. Absent on a closed run. - **`packs`**: `array of string` The distinct persona packs this run's sessions were drawn from, sorted: the cohorts it covers. A run whose personas have no pack lists none rather than an empty cohort. - **`refunded_usd`**: `number | null` Of this run's Usage, the dollars given back because a session failed on Saram's side (the pool, a lost worker, a setup step in Saram's code: fault `saram`). Not in `usage_usd`. Absent wherever `usage_usd` is. - **`repro_attempts`**: `integer | null` - **`repro_gate_discards`**: `integer | null` How many things reproduction's gate threw away, of two kinds: claim-check quotes the cited evidence does not contain, each of which left its issue `ungrounded`, and replay attempts whose path diverged before the judged step, each of which ruled on nothing and left its issue `skipped`. Read beside `confirmation_rate`: that says how much of what the judge said survived the replay, this says how often the replay's own checker cited loosely or its path stopped being walkable. Zero is the expected value. Absent until reproduction has run, and on runs reproduced before this was recorded. - **`repro_gate_ref`**: `string | null` The records behind `repro_gate_discards`, fetched through `GET /api/v1/blobs`: `{"run_id", "discards"}`, one discard each with its `session_id`, `kind`, `reason` and a `payload` naming the `issue_id`. A `claim_quote` payload carries the `quote`; a `path_diverged` one carries the `attempt`, the `attempt_session_id`, the original `step_id` it diverged at (null for the entry url), that step's `action` and `expect`, and the `url` the replay was on instead. Written even when empty, so an absent ref is reproduction that has not run, never "nothing thrown away". - **`session_counts`**: `object` Session count per status for this run: pending, running, the artifact results, or cancelled. **Additional properties:** `integer` - **`sessions`**: array of [SessionRow](#scalar-schema-sessionrow) - **`sessions_per_hour`**: `number | null` Sessions that ran a step, per hour of the execution phase (run start to last session finish). - **`started_at`**: `string | null`, format: `date-time` - **`status_reason`**: `string | null` Why a run ended `failed` or `cancelled`, in a sentence for the caller: the spec's refusal or the target's reset hook's, quoted; that Saram could not start the run, or could not finish judging its sessions; that every session errored; or `cancelled by signal`. Absent on a `completed` run. - **`summary`**: [Summary](#scalar-schema-summary) | `null` The artifact's `summary` block, once the artifact is assembled. - **`target_name`**: `string | null` - **`tenant_id`**: `string | null` Who owns this run, and so who may read it: every route filters by the tenant the request's key belongs to, and a run outside it is a 404 rather than a 403. Always your own tenant, which is what makes it safe to publish - a response can only ever carry the id you are already authenticated as. Absent on runs created before runs recorded their tenant; those are readable by this deployment's own fleet tenant and by nobody else. - **`usage`**: `number | null` This run's Usage: persona steps weighted by how long each took, summed over its sessions, to 0.001. LIVE while `usage_state` is `live`: settled sessions read what the ledger debited, and a session in flight its last heartbeat. Final once `usage_state` is `settled` or `refunded`, when it is the credit ledger's own figure. Absent on a finished run none of whose sessions was measured, which is not the same as none used. - **`usage_state`**: `string | null`, possible values: `"live", "held", "settled", "refunded"` How final `usage` and `usage_usd` are. `held`: the run is open and holds `held_usd`, and no step is measured yet. `live`: the run is open, or has just finished and is being settled - the figures are so far and still rise. `settled`: every session is settled and the figures are the credit ledger's. `refunded`: settled, and every dollar of its Usage was given back because it failed on Saram's side. - **`usage_usd`**: `number | null` What this run's Usage costs you in dollars, at your plan's Usage price when each session ran: charged from your prepaid balance, refunds already taken off - so a run that failed on Saram's side reads 0 and the money given back is `refunded_usd`. Moves with `usage` while the run is live. This is Saram's charge on a plan that prices Usage (Free, Pro); your model provider's bill is never in it. Absent when nothing prices this run's Usage - a plan billed in steps, where `billable_usd` is the charge - which is not the same as free. - **`workflow_id`**: `string | null` **Generated example:** ```json { "run_id": "", "status": "", "status_reason": "", "target": "", "target_name": "", "baseline_run_id": "", "tenant_id": "", "created_by": "", "caller": "", "confirmation_rate": 1, "repro_attempts": 1, "repro_gate_discards": 1, "repro_gate_ref": "", "billed_steps": 0, "billable_usd": 1, "cost_usd": 1, "actor_cost_usd": 1, "judge_cost_usd": 1, "usage": 1, "usage_usd": 1, "refunded_usd": 1, "held_usd": 1, "usage_state": "live", "sessions_per_hour": 1, "artifact_ref": "", "summary": { "personas_run": 1, "missions": 1, "sessions": 1, "completed": 1, "abandoned": 1, "blocked": 1, "errored": 1, "budget_exhausted": 0, "issues_total": 1, "issues_reproducible": 1, "issues_marker_matched": 1, "tester_errors": 1, "issues_new_vs_baseline": 1, "issues_resolved_vs_baseline": 1, "issues_persisting_vs_baseline": 1, "opportunities_total": 1, "verdicts": { "positive": 1, "negative": 1, "undecided": 1, "top_reasons": [ "" ] }, "confirmation_rate": 0, "reproduction": { "candidates": 1, "merged": 1, "evaluated": 1, "confirmed": 1, "inconclusive": 1, "excluded": 1 }, "gate_discards": 0, "path_diversity": 0, "cost_usd": 1, "actor_cost_usd": 0, "judge_cost_usd": 0, "wall_clock_s": 1 }, "session_counts": { "additionalProperty": 1 }, "packs": [ "" ], "created_at": "", "started_at": "", "finished_at": "", "evidence_expires_at": "", "evidence_expired": false, "workflow_id": "", "budgets": { "additionalProperty": "anything" }, "run_spec_ref": "", "files": [ { "ref": "", "name": "", "description": "", "assign": { "personas": [ "" ], "missions": [ "" ] } } ], "live": { "status": "", "paused": false, "paused_reason": "", "sessions": { "additionalProperty": 1 } }, "sessions": [ { "session_id": "", "persona_id": "", "pack": "", "tags": [ "" ], "mission_id": "", "status": "", "result": "", "end_reason": "", "fault": "none", "steps": 1, "end_step": 1, "abandon_step": 1, "tokens_in": 1, "tokens_out": 1, "tokens_cache_read": 0, "tokens_cache_write": 0, "model": "", "cost_usd": 1, "price_source": "", "wall_clock_s": 1, "uncertain_steps": [ 1 ], "resumed_at_steps": [ 1 ], "verdict_decision": "", "outcome_read": "", "observer_error": "", "verdict_discarded": { "judge_decision": "positive", "gate_reason": "" }, "heartbeat_step": 1, "last_heartbeat_at": "", "heartbeat_usage": 1, "usage": 1, "usage_usd": 1, "refunded_usd": 1, "waiting_for_seat": true, "waiting_since": "", "workflow_id": "", "browser_session_id": "", "agent_trace_ref": "", "surface_trace_ref": "", "video_ref": "", "video_url": "", "recorded_under_mask": false, "created_at": "", "finished_at": "" } ] } ``` ### RunFile **Type:** `object`, no additional properties One file a run hands its personas: the run spec's `files[]` entry as the run was started with it. Served on `RunDetail` so the run page can list a run's handouts without the spec document itself, which also carries its providers, connectors and seed-account refs. Read from the run's own row, never from the store: the spec is immutable once the run is created, and the run row keeps the copy that was validated. - **`description` (required)**: `string` What the persona was told about it, verbatim. - **`name` (required)**: `string` What the persona calls it, and the filename the target receives. Unique within the run. - **`ref` (required)**: `string` `saram-file:///`, as `FileRecord.ref`. The file may since have been deleted or have expired: this is what the run was handed, not what the tenant still has. - **`assign`**: [RunFileAssign](#scalar-schema-runfileassign) | `null` Which sessions carry it. Absent means every session. **Generated example:** ```json { "ref": "", "name": "", "description": "", "assign": { "personas": [ "" ], "missions": [ "" ] } } ``` ### RunFileAssign **Type:** `object`, no additional properties Which of a run's sessions carry a file: those matching every list given (`files[].assign` in the run spec). - **`missions`**: `(array of string) | null` Mission ids. Absent means any mission. - **`personas`**: `(array of string) | null` Persona addresses, as the spec's `personas` names them. Absent means any persona. **Generated example:** ```json { "personas": [ "" ], "missions": [ "" ] } ``` ### RunList **Type:** `object`, no additional properties Your runs, newest first. - **`runs` (required)**: array of [RunSummary](#scalar-schema-runsummary) **Generated example:** ```json { "runs": [ { "run_id": "", "status": "", "status_reason": "", "target": "", "target_name": "", "baseline_run_id": "", "tenant_id": "", "created_by": "", "caller": "", "confirmation_rate": 1, "repro_attempts": 1, "repro_gate_discards": 1, "repro_gate_ref": "", "billed_steps": 0, "billable_usd": 1, "cost_usd": 1, "actor_cost_usd": 1, "judge_cost_usd": 1, "usage": 1, "usage_usd": 1, "refunded_usd": 1, "held_usd": 1, "usage_state": "live", "sessions_per_hour": 1, "artifact_ref": "", "summary": { "personas_run": 1, "missions": 1, "sessions": 1, "completed": 1, "abandoned": 1, "blocked": 1, "errored": 1, "budget_exhausted": 0, "issues_total": 1, "issues_reproducible": 1, "issues_marker_matched": 1, "tester_errors": 1, "issues_new_vs_baseline": 1, "issues_resolved_vs_baseline": 1, "issues_persisting_vs_baseline": 1, "opportunities_total": 1, "verdicts": { "positive": 1, "negative": 1, "undecided": 1, "top_reasons": [ "" ] }, "confirmation_rate": 0, "reproduction": { "candidates": 1, "merged": 1, "evaluated": 1, "confirmed": 1, "inconclusive": 1, "excluded": 1 }, "gate_discards": 0, "path_diversity": 0, "cost_usd": 1, "actor_cost_usd": 0, "judge_cost_usd": 0, "wall_clock_s": 1 }, "session_counts": { "additionalProperty": 1 }, "packs": [ "" ], "created_at": "", "started_at": "", "finished_at": "", "evidence_expires_at": "", "evidence_expired": false } ] } ``` ### RunSignalled **Type:** `object`, no additional properties A pause, resume or cancel delivered to a run. - **`run_id` (required)**: `string` The run the signal was delivered to. - **`signal` (required)**: `string` The signal delivered: pause, resume or cancel. **Generated example:** ```json { "run_id": "", "signal": "" } ``` ### RunSummary **Type:** `object`, no additional properties A run as `GET /api/v1/runs` lists it: where it stands, what it found in summary, and what it cost and billed. `GET /api/v1/runs/{run_id}` answers the same fields and more (`RunDetail`). - **`created_at` (required)**: `string`, format: `date-time` - **`evidence_expires_at` (required)**: `string`, format: `date-time` When this run's step-by-step evidence is deleted: `created_at` plus the days its plan keeps evidence - 14 on Free, 90 on Pro, this deployment's default on a plan that sets none - or the days its plan kept when the run was created, whichever is longer, so a plan change never moves this date earlier and an upgrade moves it later. The screenshots, the video, the agent and surface step-event streams, the checkpoints and the per-session observation documents go at this date, in every bucket they live in. Earlier when they went earlier: a run whose recordings the storage had already removed is found by the retention sweep, which takes the rest of its evidence and records when, and that moment is this date. The run's *record* is kept and has no expiry: this response, its issues, its verdicts, its opportunities, the spec it ran and the assembled artifact. A reader that wants a screenshot after that date must keep the bytes, not the ref. - **`run_id` (required)**: `string` - **`status` (required)**: `string` `pending`, `running`, `observing`, then one terminal value. `completed` means at least one session reached an ending its persona got to (`completed`, `abandoned`, `blocked`, `budget_exhausted`), so there are results to read. `failed` covers both a run that broke - a rejected spec, a reset hook or the observer - and a run whose every session `errored`, which came back with no persona outcome at all; `status_reason` says which. `cancelled` is a `cancel` signal. Poll until the value is terminal, and treat only `completed` as results-ready. - **`target` (required)**: `string` - **`actor_cost_usd`**: `number | null` Of `cost_usd`, the persona loop's model calls: the sum of the priced sessions' `cost_usd`. Absent when no session's model had a price. - **`artifact_ref`**: `string | null` - **`baseline_run_id`**: `string | null` - **`billable_usd`**: `number | null` What this run costs you, to the cent: `billed_steps` at your plan's step price - or your own agreed price - in force when the run ran. This is Saram's charge; `cost_usd` beside it is model spend and is not. LIVE with `billed_steps` while the run is open, so it is what the run has cost you so far. Absent means nobody has priced your tenant, which is not the same as free - never read a missing price as $0. Your plan's included steps and monthly fee are not in it: they are properties of a month, and the invoice line with the allowance applied is this run's `billable_usd` on `GET /api/v1/usage`. - **`billed_steps`**: `integer`, default: `0` What this run is charged for: the steps its personas took - one browser action each - never the reproduction replay's, which are included at no charge. LIVE: a session is recorded when it ends, so while the run is open this is the finished sessions' counts plus each unfinished session's last `heartbeat_step`, and it climbs until the run does end. - **`caller`**: `string | null` Who started this run, in words: the caller name the API key gate identified at `POST /runs` - the operator's own name for a break-glass entry, a name like `acme/ci` for an API key. Absent when nobody was identified, which is the gate being off, a run an operator started in-process, or a run created before runs recorded their caller; the three are deliberately indistinguishable rather than dressed up with an invented name. Attribution, beside the authorization `tenant_id` carries. - **`confirmation_rate`**: `number | null` Confirmed issues over the issues reproduction ruled on. Available as soon as reproduction finishes, which is before `summary.confirmation_rate` exists; the two are the same number computed from the same lineage. - **`cost_usd`**: `number | null` What this run's model calls cost: `actor_cost_usd` and `judge_cost_usd` together. NOT what Saram charges you - `billable_usd` is that: on your own keys this is your provider's bill, and on Saram's models it is Saram's own cost, shown so your Saram usage and your provider's invoice add up. Written when the run finishes. Absent means nothing priced the models, which is not the same as free. On a run finished before the judge was priced it is the persona loop's alone, and `judge_cost_usd` is absent beside it. - **`created_by`**: `string | null` Which credential started it: `key_...` for an API key, `operator` for a break-glass entry or a run an operator started in-process. The id of the key rather than its name, so rotating a key leaves the runs it started still pointing at it. - **`evidence_expired`**: `boolean`, default: `false` Whether that date has passed. `GET /api/v1/blobs` on this run's session evidence answers 404 once it has, and that 404 means "expired", not "never existed" - which is why the date is published beside it. - **`finished_at`**: `string | null`, format: `date-time` - **`held_usd`**: `number | null` What this run still reserves from your balance: the most its spec's budgets can use (the Usage ceiling), less each finished session's share. Released when the run's last session is settled. Absent when the run holds nothing - it has finished, or the run gate did not hold for it. - **`judge_cost_usd`**: `number | null` Of `cost_usd`, the observer's model calls: the judge's reading of each session, attribution, the merge, and the reproduction's claim check and tie-break - a failed attempt included, because it was paid for. On today's models this is often the larger half. A retest you ask for later is not in it. Absent when none of those calls had a price, and on a run finished before they were priced. - **`packs`**: `array of string` The distinct persona packs this run's sessions were drawn from, sorted: the cohorts it covers. A run whose personas have no pack lists none rather than an empty cohort. - **`refunded_usd`**: `number | null` Of this run's Usage, the dollars given back because a session failed on Saram's side (the pool, a lost worker, a setup step in Saram's code: fault `saram`). Not in `usage_usd`. Absent wherever `usage_usd` is. - **`repro_attempts`**: `integer | null` - **`repro_gate_discards`**: `integer | null` How many things reproduction's gate threw away, of two kinds: claim-check quotes the cited evidence does not contain, each of which left its issue `ungrounded`, and replay attempts whose path diverged before the judged step, each of which ruled on nothing and left its issue `skipped`. Read beside `confirmation_rate`: that says how much of what the judge said survived the replay, this says how often the replay's own checker cited loosely or its path stopped being walkable. Zero is the expected value. Absent until reproduction has run, and on runs reproduced before this was recorded. - **`repro_gate_ref`**: `string | null` The records behind `repro_gate_discards`, fetched through `GET /api/v1/blobs`: `{"run_id", "discards"}`, one discard each with its `session_id`, `kind`, `reason` and a `payload` naming the `issue_id`. A `claim_quote` payload carries the `quote`; a `path_diverged` one carries the `attempt`, the `attempt_session_id`, the original `step_id` it diverged at (null for the entry url), that step's `action` and `expect`, and the `url` the replay was on instead. Written even when empty, so an absent ref is reproduction that has not run, never "nothing thrown away". - **`session_counts`**: `object` Session count per status for this run: pending, running, the artifact results, or cancelled. **Additional properties:** `integer` - **`sessions_per_hour`**: `number | null` Sessions that ran a step, per hour of the execution phase (run start to last session finish). - **`started_at`**: `string | null`, format: `date-time` - **`status_reason`**: `string | null` Why a run ended `failed` or `cancelled`, in a sentence for the caller: the spec's refusal or the target's reset hook's, quoted; that Saram could not start the run, or could not finish judging its sessions; that every session errored; or `cancelled by signal`. Absent on a `completed` run. - **`summary`**: [Summary](#scalar-schema-summary) | `null` The artifact's `summary` block, once the artifact is assembled. - **`target_name`**: `string | null` - **`tenant_id`**: `string | null` Who owns this run, and so who may read it: every route filters by the tenant the request's key belongs to, and a run outside it is a 404 rather than a 403. Always your own tenant, which is what makes it safe to publish - a response can only ever carry the id you are already authenticated as. Absent on runs created before runs recorded their tenant; those are readable by this deployment's own fleet tenant and by nobody else. - **`usage`**: `number | null` This run's Usage: persona steps weighted by how long each took, summed over its sessions, to 0.001. LIVE while `usage_state` is `live`: settled sessions read what the ledger debited, and a session in flight its last heartbeat. Final once `usage_state` is `settled` or `refunded`, when it is the credit ledger's own figure. Absent on a finished run none of whose sessions was measured, which is not the same as none used. - **`usage_state`**: `string | null`, possible values: `"live", "held", "settled", "refunded"` How final `usage` and `usage_usd` are. `held`: the run is open and holds `held_usd`, and no step is measured yet. `live`: the run is open, or has just finished and is being settled - the figures are so far and still rise. `settled`: every session is settled and the figures are the credit ledger's. `refunded`: settled, and every dollar of its Usage was given back because it failed on Saram's side. - **`usage_usd`**: `number | null` What this run's Usage costs you in dollars, at your plan's Usage price when each session ran: charged from your prepaid balance, refunds already taken off - so a run that failed on Saram's side reads 0 and the money given back is `refunded_usd`. Moves with `usage` while the run is live. This is Saram's charge on a plan that prices Usage (Free, Pro); your model provider's bill is never in it. Absent when nothing prices this run's Usage - a plan billed in steps, where `billable_usd` is the charge - which is not the same as free. **Generated example:** ```json { "run_id": "", "status": "", "status_reason": "", "target": "", "target_name": "", "baseline_run_id": "", "tenant_id": "", "created_by": "", "caller": "", "confirmation_rate": 1, "repro_attempts": 1, "repro_gate_discards": 1, "repro_gate_ref": "", "billed_steps": 0, "billable_usd": 1, "cost_usd": 1, "actor_cost_usd": 1, "judge_cost_usd": 1, "usage": 1, "usage_usd": 1, "refunded_usd": 1, "held_usd": 1, "usage_state": "live", "sessions_per_hour": 1, "artifact_ref": "", "summary": { "personas_run": 1, "missions": 1, "sessions": 1, "completed": 1, "abandoned": 1, "blocked": 1, "errored": 1, "budget_exhausted": 0, "issues_total": 1, "issues_reproducible": 1, "issues_marker_matched": 1, "tester_errors": 1, "issues_new_vs_baseline": 1, "issues_resolved_vs_baseline": 1, "issues_persisting_vs_baseline": 1, "opportunities_total": 1, "verdicts": { "positive": 1, "negative": 1, "undecided": 1, "top_reasons": [ "" ] }, "confirmation_rate": 0, "reproduction": { "candidates": 1, "merged": 1, "evaluated": 1, "confirmed": 1, "inconclusive": 1, "excluded": 1 }, "gate_discards": 0, "path_diversity": 0, "cost_usd": 1, "actor_cost_usd": 0, "judge_cost_usd": 0, "wall_clock_s": 1 }, "session_counts": { "additionalProperty": 1 }, "packs": [ "" ], "created_at": "", "started_at": "", "finished_at": "", "evidence_expires_at": "", "evidence_expired": false } ``` ### SessionList **Type:** `object`, no additional properties A run's sessions, one per persona per mission. - **`run_id` (required)**: `string` - **`sessions` (required)**: array of [SessionRow](#scalar-schema-sessionrow) **Generated example:** ```json { "run_id": "", "sessions": [ { "session_id": "", "persona_id": "", "pack": "", "tags": [ "" ], "mission_id": "", "status": "", "result": "", "end_reason": "", "fault": "none", "steps": 1, "end_step": 1, "abandon_step": 1, "tokens_in": 1, "tokens_out": 1, "tokens_cache_read": 0, "tokens_cache_write": 0, "model": "", "cost_usd": 1, "price_source": "", "wall_clock_s": 1, "uncertain_steps": [ 1 ], "resumed_at_steps": [ 1 ], "verdict_decision": "", "outcome_read": "", "observer_error": "", "verdict_discarded": { "judge_decision": "positive", "gate_reason": "" }, "heartbeat_step": 1, "last_heartbeat_at": "", "heartbeat_usage": 1, "usage": 1, "usage_usd": 1, "refunded_usd": 1, "waiting_for_seat": true, "waiting_since": "", "workflow_id": "", "browser_session_id": "", "agent_trace_ref": "", "surface_trace_ref": "", "video_ref": "", "video_url": "", "recorded_under_mask": false, "created_at": "", "finished_at": "" } ] } ``` ### SessionRow **Type:** `object`, no additional properties One session of a run: one persona on one mission, in its own browser. What it ended as and whose fault an error was, the tokens, cost and Usage it took, where it has got to while it runs, and the refs to its step-by-step traces and video. - **`created_at` (required)**: `string`, format: `date-time` - **`end_step` (required)**: `integer` - **`mission_id` (required)**: `string` - **`persona_id` (required)**: `string` - **`session_id` (required)**: `string` - **`status` (required)**: `string` `pending`, `running`, an artifact result, or `cancelled`. Nothing reports `running` today: a session is recorded once, when it ends, so a session in flight still reads `pending` - see `heartbeat_step`, and `waiting_for_seat` for one that is queued for a browser seat rather than on a step. - **`steps` (required)**: `integer` - **`tokens_in` (required)**: `integer` The whole prompt plus the reply: `tokens_in` already includes both cache halves below. - **`tokens_out` (required)**: `integer` - **`wall_clock_s` (required)**: `number` - **`abandon_step`**: `integer | null` - **`agent_trace_ref`**: `string | null` - **`browser_session_id`**: `string | null` - **`cost_usd`**: `number | null` What this session's model calls cost. Absent when nothing prices its model; never 0 to mean "unknown". - **`end_reason`**: `string | null` How the session ended: the loop's word for an ending the persona reached (`success_criteria_met`, `patience_exhausted`, `budget_steps`), and on an `errored` one a sentence that says what kind of failure it was - the target's, the model provider's (with the provider's own message), or Saram's - with `fault` beside it. - **`fault`**: `string | null`, possible values: `"none", "saram", "provider", "target"` Whose failure ended the session. `none` for every ending the persona or the loop reached, and for a session a person cancelled; on an `errored` one, `saram` (the harness: the pool, a setup step, a lost worker), `provider` (the model API: an HTTP error or a transport failure after the retries, or a reply the loop could not act on) or `target` (the product: its entry page did not load, or its reset hook refused the session). What decides whether an errored session is charged. `null` while the session is open, and on a session recorded before this was classified - never read it as `none`. - **`finished_at`**: `string | null`, format: `date-time` - **`heartbeat_step`**: `integer | null` LIVE. The `step_id` the persona last reported, read live: a session is recorded once, when it ends, so this is the only field that says where a session in flight has got to. Best effort - absent on a finished session, and on a running one whose live state cannot be read. Compare it with the steps page's `last_step_id` to see how far the readable trace lags the browser: the trace records a step after the browser answers, so a step in flight shows here first. - **`heartbeat_usage`**: `number | null` LIVE. This session's Usage so far, to 0.001, from the step times on its last heartbeat: persona steps weighted by how long each one took, measured the way the session's Usage will be when it ends. A report, not a charge. Best effort, as `heartbeat_step` - and also absent for a session on a worker that does not report step times. - **`last_heartbeat_at`**: `string | null`, format: `date-time` When that heartbeat reached the server: how stale `heartbeat_step` is. Absent whenever it is. - **`model`**: `string | null` - **`observer_error`**: `string | null` Why nobody read this session, when nobody did, in Saram's words: the judge failure that left the session read as `unclear` - the model provider's status and own message when the provider refused, the `providers` entry's refusal when it did not resolve, Saram's sentence otherwise. Absent on every session a judge pass read, including one it found nothing in - which is the only way a reader tells a clean session from one no model ever saw. - **`outcome_read`**: `string | null` What the judge read the session's outcome as, independent of what the loop reported. - **`pack`**: `string | null` The persona's pack as the registry had it when the run was snapshotted - the session's cohort. Absent for a persona that declares none, and for a session recorded before sessions carried one. - **`price_source`**: `string | null` Which price table produced `cost_usd`: this deployment's own, `env` (a bare model id's row) or `env/` (a row for the model on one provider kind), else `genai-prices//`. - **`recorded_under_mask`**: `boolean`, default: `false` Whether the browser pool recorded this session under its mask: every field the persona typed into, every one-time-code field and every `data-saram-mask` element painted over in every screenshot and video frame it stored. Only such a session of yours can be shared by link while `ShareList.mask_required` is `true`: a share of any other is refused with a 409, so read the two together before offering one. `false` for every session recorded before the pool masked, and by a pool that does not. - **`refunded_usd`**: `number | null` What was given back for this session because it failed on Saram's side (fault `saram`): its whole debit. 0 otherwise. Absent wherever `usage_usd` is. - **`result`**: `string | null` The artifact result once the loop reported; absent before that and on a session that never started. - **`resumed_at_steps`**: `(array of integer) | null` The first step of each resumed browser execution, one per resume: the re-orienting `look` the loop takes when its activity is retried. `null` and `[]` read as above. A browser checkpoint carries a url and a storage state and nothing else, so each of these is where a new execution picked the experiment up, not where the same page carried on. - **`surface_trace_ref`**: `string | null` - **`tags`**: `array of string` The persona's tags at snapshot time, beside `pack`. - **`tokens_cache_read`**: `integer`, default: `0` Of `tokens_in`, read from the prompt cache - billed at a tenth of the input rate on Anthropic. - **`tokens_cache_write`**: `integer`, default: `0` Of `tokens_in`, written to the prompt cache. - **`uncertain_steps`**: `(array of integer) | null` The steps of this session whose effect on the product nothing can attest: the call was dispatched, the response was lost, and the resume reconciled against the pool's step id rather than re-issuing it. `null` means nobody looked - a session recorded before the harness produced this, or one whose activity was lost before it or its checkpoint could report - and `[]` means the loop checked and every step is attested. Never read `null` as "none": it is the same three-way reading as `artifact.outcomes[].uncertain_steps`. - **`usage`**: `number | null` This session's Usage, to 0.001: the credit ledger's figure once it is settled, measured from its recorded step times before that, and on a session in flight its last heartbeat (`heartbeat_usage`). Absent on a session that has not taken a step, and on one recorded without step times. - **`usage_usd`**: `number | null` What this session's Usage costs you, net of any refund (`refunded_usd`). Absent when nothing prices it: a plan billed in steps. - **`verdict_decision`**: `string | null` The judge's verdict decision once it has read the session: positive, negative, or undecided. - **`verdict_discarded`**: [VerdictDiscarded](#scalar-schema-verdictdiscarded) | `null` NOT A VERDICT. The answer the judge gave about this session that the quote gate threw away, and why: set only when `verdict_decision` is absent on a session a judge read. Absent when the judge returned no verdict, which is the difference it is here for - both leave no decision. The decision is the judge's with no line of the persona's diary behind it, so it is never the persona's verdict and is never counted as one. Absent on a session observed before the control plane recorded it, whose gate ledger is the only record. - **`video_ref`**: `string | null` - **`video_url`**: `string | null` - **`waiting_for_seat`**: `boolean | null` LIVE. `true` while this session is queued for a browser seat: the run has started it, but every seat in the browser pool is taken, so it has no browser and takes no steps until one frees - a run with more sessions than the pool has seats shows this on the rest, and they start in turn without spending an attempt. `false` while the session is not waiting: setting up, on a seat, or paused. Read live, best effort as `heartbeat_step` - absent on a finished session, on one the run has not started yet (its `max_concurrent_sessions` holds it back), and on one whose live state cannot be read. A session resuming after a pause or a pool failure queues the same way, so it can be `true` beside a `heartbeat_step`. - **`waiting_since`**: `string | null`, format: `date-time` When the session was queued for the seat it is waiting for: how long it has waited. Absent whenever `waiting_for_seat` is not `true`. - **`workflow_id`**: `string | null` **Generated example:** ```json { "session_id": "", "persona_id": "", "pack": "", "tags": [ "" ], "mission_id": "", "status": "", "result": "", "end_reason": "", "fault": "none", "steps": 1, "end_step": 1, "abandon_step": 1, "tokens_in": 1, "tokens_out": 1, "tokens_cache_read": 0, "tokens_cache_write": 0, "model": "", "cost_usd": 1, "price_source": "", "wall_clock_s": 1, "uncertain_steps": [ 1 ], "resumed_at_steps": [ 1 ], "verdict_decision": "", "outcome_read": "", "observer_error": "", "verdict_discarded": { "judge_decision": "positive", "gate_reason": "" }, "heartbeat_step": 1, "last_heartbeat_at": "", "heartbeat_usage": 1, "usage": 1, "usage_usd": 1, "refunded_usd": 1, "waiting_for_seat": true, "waiting_since": "", "workflow_id": "", "browser_session_id": "", "agent_trace_ref": "", "surface_trace_ref": "", "video_ref": "", "video_url": "", "recorded_under_mask": false, "created_at": "", "finished_at": "" } ``` ### Severity **Type:** `string`, possible values: `"low", "medium", "high", "critical"` How much the defect costs the people who meet it, from least to most: `low`, `medium`, `high`, `critical`. Rated by the judge from the sessions, and lowered one step when the replay did not reproduce the issue and it stands as persona-specific friction. The GitHub connector files only issues at or above its `min_severity_for_issue`. ### ShareCreated **Type:** `object`, no additional properties A new public link to one session's replay, and the one and only sight of its URL. Anyone holding `url` can watch the session's recording, which hides only what the browser pool masked when it recorded it (`masked`): whatever else the browser showed is in it. Only a hash of the link's token is stored, so no route shows the URL again; a lost link is replaced by minting another and revoking this one with `POST /api/v1/shares/{id}/revoke`. - **`capped_by_evidence` (required)**: `boolean` True when the run's evidence lifetime, and not the days asked for, set expires\_at: the link ends with the recording. - **`created_at` (required)**: `string`, format: `date-time` - **`created_by` (required)**: `string` key\_... for an API key, usr\_... for a person, operator for the operator. - **`expires_at` (required)**: `string`, format: `date-time` - **`id` (required)**: `string` shr\_...: the link's id, for listing and revoking it. Not the link itself. - **`masked` (required)**: `boolean` True when the browser pool recorded the session under its mask: the value of every field the persona typed into, every one-time-code field and every data-saram-mask element are painted over in every frame. False only for a session of Saram's own, which is shared unmasked. - **`run_id` (required)**: `string` - **`session_id` (required)**: `string` - **`url` (required)**: `string` The public link. Shown once, here, and never again by any route. Anyone who has it can watch this session until it expires or is revoked, with no sign-in. - **`warning` (required)**: `string` What anyone holding the link will see, what in the recording is masked and what is not, and how to revoke it. Show it to whoever posts the link. **Generated example:** ```json { "id": "", "url": "", "run_id": "", "session_id": "", "created_at": "", "created_by": "", "expires_at": "", "capped_by_evidence": true, "masked": true, "warning": "" } ``` ### ShareList **Type:** `object`, no additional properties A run's public links, revoked and expired ones included, newest first. - **`mask_required` (required)**: `boolean` Whether a session of yours is shared only when the browser pool recorded it under its mask (SessionRow\.recorded\_under\_mask): a share of any other is refused with a 409. True for every customer; false only for Saram's own sessions, which are shared unmasked. - **`public_shares` (required)**: `boolean` Whether your tenant may publish sessions at all. Off, a new link is refused with a 403 and no existing link plays; their rows are kept and they play again, until their own expiry, once it is back on. - **`run_id` (required)**: `string` - **`shares` (required)**: array of [ShareSummary](#scalar-schema-sharesummary) **Generated example:** ```json { "run_id": "", "public_shares": true, "mask_required": true, "shares": [ { "id": "", "run_id": "", "session_id": "", "token_hint": "", "state": "live", "created_at": "", "created_by": "", "caller": "", "expires_at": "", "revoked_at": "", "revoked_by": "", "view_count": 1, "last_viewed_at": "" } ] } ``` ### ShareSummary **Type:** `object`, no additional properties One public link to a session's replay, as the list and the revocation answer it. Never the link itself. - **`created_at` (required)**: `string`, format: `date-time` - **`created_by` (required)**: `string` - **`expires_at` (required)**: `string`, format: `date-time` - **`id` (required)**: `string` - **`run_id` (required)**: `string` - **`session_id` (required)**: `string` - **`state` (required)**: `string`, possible values: `"live", "expired", "revoked"` live plays, expired and revoked do not. Read it beside ShareList.public\_shares: while your tenant's sharing is off no link plays, whatever its state. - **`token_hint` (required)**: `string` The link's last four characters, to tell your links apart. - **`view_count` (required)**: `integer` How many times the replay was opened. Nothing about who opened it is kept. - **`caller`**: `string | null` Who minted it, in words, when the credential had a name. - **`last_viewed_at`**: `string | null`, format: `date-time` - **`revoked_at`**: `string | null`, format: `date-time` - **`revoked_by`**: `string | null` Who revoked it: any principal of your tenant that may write runs can. **Generated example:** ```json { "id": "", "run_id": "", "session_id": "", "token_hint": "", "state": "live", "created_at": "", "created_by": "", "caller": "", "expires_at": "", "revoked_at": "", "revoked_by": "", "view_count": 1, "last_viewed_at": "" } ``` ### ShareWrite **Type:** `object`, no additional properties The optional body of `POST /runs/{run_id}/sessions/{session_id}/shares`. - **`expires_in_days`**: `integer | null`, minimum: `1`, maximum: `90` How many days the link plays, 1 to 90; 30 when omitted. Never past the run's evidence\_expires\_at, whatever you ask for: a link ends when the recording it plays is deleted. There is no link that does not expire, and no extending one: mint a new link instead. **Generated example:** ```json { "expires_in_days": 1 } ``` ### SpecValidation **Type:** `object`, no additional properties `POST /api/v1/runs/validate`: what `POST /runs` would make of this spec, without making it. Everything `POST /runs` would refuse is the same 422 here: the contract, the cross-references inside `providers`, and the caller's own model provider - including a ChatGPT login the gateway no longer holds. A 200 means the spec is accepted as written. - **`sessions` (required)**: array of [PlannedSession](#scalar-schema-plannedsession) - **`spec` (required)**: `object` The spec as the run would store it. - **`cost_ceiling`**: [CostCeiling](#scalar-schema-costceiling) | `null` What the run would cost: the most it can bill on your plan (usd), with a one-line basis and who bills its model calls. A ceiling, not a forecast. On every plan; absent only for a caller this deployment has no tenant for. - **`run_id`**: `string | null` The run id the spec carries. Absent means `POST /runs` would assign one. - **`usage_ceiling`**: [UsageCeiling](#scalar-schema-usageceiling) | `null` The most Usage this run can use and the balance it is held against. Absent when this deployment's run gate is off, or your plan is not billed in Usage. - **`warnings`**: `array of string` What the dry run can say about the spec but does not refuse it for, one sentence each, the ones about your ChatGPT login first. Today: providers entries on a ChatGPT login whose access token has lapsed (the gateway refreshes it on the run's first model call, and the run fails if that refresh is refused) or whose gateway this API could not ask; a providers entry whose model this platform's price table has no price for, which is either a model too new for the table or a misspelt id that fails at the run's first model call; and a budget.max\_concurrent\_sessions above what your plan runs at once, which the run honours by running fewer sessions at a time. **Generated example:** ```json { "run_id": "", "spec": { "additionalProperty": "anything" }, "sessions": [ { "session_id": "", "persona_id": "", "mission_id": "", "entry_url": "", "account_secret_ref": "" } ], "warnings": [ "" ], "cost_ceiling": { "usd": 1, "billed_in": "usage", "plan": "", "price_usd": 1, "sessions": 1, "steps_per_session": 1, "wall_clock_s_per_session": 1, "usage_per_session": 1, "usage": 1, "basis": "", "model_calls": "" }, "usage_ceiling": { "sessions": 1, "steps_per_session": 1, "wall_clock_s_per_session": 1, "usage_per_session": 1, "usage": 1, "usage_usd": 1, "usage_price_usd": 1, "available_usd": 1, "covered": true, "steps_per_session_that_fit": 1, "enforced": true } } ``` ### StartedRun **Type:** `object`, no additional properties A run accepted and started. Follow it by its `run_id`: `GET /api/v1/runs/{run_id}`, or the MCP tools `get_run_status` and `wait_for_run`. - **`run_id` (required)**: `string` The run's id: the handle every other run route and tool takes. - **`workflow_id` (required)**: `string` The id of the workflow executing the run, for support requests. **Generated example:** ```json { "run_id": "", "workflow_id": "" } ``` ### Status **Type:** `string`, possible values: `"verified", "marker_matched", "inconclusive", "unverified"` Best-supported first. `verified`: a replay matched the failure marker at `action_step` AND the claim check answered `supported` with a verbatim quote from the cited steps (`check`) AND the failure is attributed to the product (`attribution`) - or `method` is `assertion` or `manual` and the same two conditions hold. This and only this may file a ticket alone, and `reproducible` is true exactly when this reads `verified`. `marker_matched` (0.5): a replay matched the failure marker at `action_step`; the claim as titled is unchecked, unsupported, or the check could not quote. The step failed again; whether it failed for the stated reason is not established, so it never files alone. `inconclusive`: a check ran and settled nothing (the marker did not appear again, the two attempts disagreed) - not a refutation. `unverified`: nothing checked it, because there was nothing to check, reproduction never ruled, or the finding is a tester error. A wrong total or a select that resets is `unverified` by construction, not disproven. Default and safe reading when the block is absent. This enum drives an action - it is the ticket-filing gate, like `issue.severity` - so it stays closed for readers too and adding a member is a breaking change: a gate must refuse a value it cannot interpret rather than guess at one, which is why `marker_matched` made 0.5 a new family. ### StepRef **Type:** `object` One step of one session: the session's id and the step's number within it. A finding points at the steps it rests on, and that session's step events say what happened at each. - **`session_id` (required)**: `string` - **`step_id` (required)**: `integer` **Generated example:** ```json { "session_id": "", "step_id": 1 } ``` ### StepTiming **Type:** `object`, no additional properties How long the browser took over one step, in milliseconds. Each is absent when the browser did not measure it. - **`action_to_settle_ms`**: `integer | null` - **`longest_task_ms`**: `integer | null` - **`navigation_ms`**: `integer | null` **Generated example:** ```json { "action_to_settle_ms": 1, "longest_task_ms": 1, "navigation_ms": 1 } ``` ### StepTokens **Type:** `object`, no additional properties The model tokens behind one step's decision: prompt, reply, and the part of the prompt read from the cache. Each is absent when the provider did not report it. - **`cached`**: `integer | null` - **`input`**: `integer | null` - **`output`**: `integer | null` **Generated example:** ```json { "input": 1, "output": 1, "cached": 1 } ``` ### StepsPage **Type:** `object`, no additional properties A session's steps, aligned. Readable while the session runs, not only after it ends. One step is written as the persona takes it, so a caller polling `?after=` tails a live session: each call returns the steps written since the last one, and `status` says whether more are coming. `heartbeat_step` beside it is where the browser has got to, which is at or one ahead of `last_step_id` while a step is in flight. - **`mission_id` (required)**: `string` - **`persona_id` (required)**: `string` - **`run_id` (required)**: `string` - **`session_id` (required)**: `string` - **`status` (required)**: `string` `in_flight` while the session has not been recorded yet: the steps below are what the persona and the browser have written so far, both halves joined, and more will follow. The one thing a live page lacks is the session video, which is stored when the browser closes: `video_ref` and each step's window are there, `video_url` is absent until the page is final. A step whose surface half is missing on a live page is one the browser has not answered yet or a real gap, as on a final one. Otherwise the session's own status (`completed`, `abandoned`, `blocked`, `errored`, `budget_exhausted`, `cancelled`), and the page is final. - **`steps` (required)**: array of [AlignedStep](#scalar-schema-alignedstep) - **`total_steps` (required)**: `integer` Steps in the whole trace, before the `from`/`to` window - not the count of `steps`. - **`agent_trace_ref`**: `string | null` - **`first_step_id`**: `integer | null` - **`from_step`**: `integer | null` The inclusive lower bound in effect, whether it came from `from` or `after` + 1. - **`heartbeat_step`**: `integer | null` LIVE. The `step_id` the persona last reported: where the browser has got to, which is at or one ahead of `last_step_id` while a step is in flight - a step is written after the browser answers, so a step in flight shows here first. Only asked for while `status` is `in_flight`: absent on a final page, and on a live one whose live state cannot be read. - **`last_heartbeat_at`**: `string | null`, format: `date-time` When that heartbeat reached the server: how stale `heartbeat_step` is. Absent whenever it is. - **`last_step_id`**: `integer | null` The last step in the whole trace. On an `in_flight` page this is what a tailing caller passes back as `after` to get the next batch; absent means the session has written no step yet. - **`surface_trace_ref`**: `string | null` - **`to_step`**: `integer | null` - **`video_ref`**: `string | null` - **`video_url`**: `string | null` - **`waiting_for_seat`**: `boolean | null` LIVE. `true` while the session is queued for a browser seat, which is why a live page can have no steps at all: every seat in the pool is taken, and it takes no steps until one frees. A session resuming after a pause or a pool failure queues the same way, so it can be `true` on a page that has steps. Only asked for while `status` is `in_flight`: absent on a final page, and on a live one whose live state cannot be read. - **`waiting_since`**: `string | null`, format: `date-time` When the session was queued for the seat it is waiting for: how long it has waited. Absent whenever `waiting_for_seat` is not `true`. - **`workflow_id`**: `string | null` The session's workflow id, as `SessionRow.workflow_id`: what `heartbeat_step` was read off. **Generated example:** ```json { "run_id": "", "session_id": "", "persona_id": "", "mission_id": "", "status": "", "heartbeat_step": 1, "last_heartbeat_at": "", "waiting_for_seat": true, "waiting_since": "", "workflow_id": "", "from_step": 1, "to_step": 1, "total_steps": 1, "first_step_id": 1, "last_step_id": 1, "agent_trace_ref": "", "surface_trace_ref": "", "video_ref": "", "video_url": "", "steps": [ { "step_id": 1, "has_agent": true, "has_surface": true, "terminal": true, "ts_agent": "", "action_type": "", "target_description": "", "action_args": { "additionalProperty": "anything" }, "narration": "", "reasoning_summary": "", "perceived_state": "", "observation_mode": "", "tolerance_state": { "additionalProperty": "anything" }, "latency_ms": 1, "tokens": { "input": 1, "output": 1, "cached": 1 }, "model": "", "end_reason": "", "result": "", "field": { "additionalProperty": "anything" }, "ts_surface": "", "url": "", "route": "", "viewport": "", "inputs": [ { "additionalProperty": "anything" } ], "files_attached": [ { "additionalProperty": "anything" } ], "dialogs": [ { "additionalProperty": "anything" } ], "downloads": [ { "additionalProperty": "anything" } ], "network_failures": [ { "additionalProperty": "anything" } ], "console_errors": [ { "additionalProperty": "anything" } ], "dom_mutation_count": 1, "timing": { "action_to_settle_ms": 1, "longest_task_ms": 1, "navigation_ms": 1 }, "screenshot_ref": "", "screenshot_url": "", "video_ref": "", "video_url": "", "ts_start_s": 1, "ts_end_s": 1 } ] } ``` ### SubscriptionState **Type:** `object`, no additional properties The tenant's Saram Pro subscription as Stripe last reported it, mirrored so the billing screen can say "Pro renews on Oct 1" or "Pro ends on Oct 1" without asking Stripe on every read. - **`cancel_at_period_end` (required)**: `boolean` True after Pro was cancelled (DELETE /billing/subscription): it stays Pro until current\_period\_end, then the tenant moves to Free. POST /billing/subscription takes the cancellation back. - **`status` (required)**: `string` Stripe's: `incomplete` until the first invoice is paid, then `active`; `past_due` when a renewal failed; `canceled` once it has ended, `incomplete_expired` for a checkout nobody paid. - **`current_period_end`**: `string | null`, format: `date-time` When the month already paid for ends: the renewal date, or the date Pro ends when cancel\_at\_period\_end is true. Absent until Stripe has said. **Generated example:** ```json { "status": "", "cancel_at_period_end": true, "current_period_end": "" } ``` ### Summary **Type:** `object`, no additional properties Counts over the run. The result buckets (completed, abandoned, blocked, errored, budget\_exhausted) partition `sessions`: one bucket per value of outcomes\[].result, every session in exactly one, so they sum to `sessions`. `verdicts` has the same denominator. - **`abandoned` (required)**: `integer` - **`blocked` (required)**: `integer` - **`completed` (required)**: `integer` - **`issues_total` (required)**: `integer` - **`personas_run` (required)**: `integer` - **`sessions` (required)**: `integer` - **`actor_cost_usd`**: `number | null`, minimum: `0` - **`budget_exhausted`**: `integer | null`, default: `0` - **`confirmation_rate`**: `number | null`, minimum: `0`, maximum: `1` - **`cost_usd`**: `number | null` - **`errored`**: `integer | null` - **`gate_discards`**: `integer | null`, default: `0`, minimum: `0` - **`issues_marker_matched`**: `integer | null` - **`issues_new_vs_baseline`**: `integer | null` - **`issues_persisting_vs_baseline`**: `integer | null` - **`issues_reproducible`**: `integer | null` - **`issues_resolved_vs_baseline`**: `integer | null` - **`judge_cost_usd`**: `number | null`, minimum: `0` - **`missions`**: `integer | null` - **`opportunities_total`**: `integer | null` - **`path_diversity`**: `number | null`, minimum: `0`, maximum: `1` - **`reproduction`**: [Reproduction](#scalar-schema-reproduction) | `null` The counts behind `confirmation_rate`, so a reader can see its denominator instead of trusting it. Funnel stages and readings of one set of verdicts, not a partition: `candidates` are the per-session findings the judge raised, merging folds them into `merged`, reproduction ruled on `evaluated` of those and confirmed `confirmed`. The two overlaps are deliberate. A `flaky` finding is in `evaluated` (the experiment ran) and in `inconclusive` (it settled nothing). `excluded` is a subset of the unconfirmed - the ones dropped for having no persona voice left once the replay did not confirm them - and they are exactly the findings this artifact no longer carries, so `merged - excluded` is `issues_total`. - **`tester_errors`**: `integer | null` - **`verdicts`**: [Verdicts](#scalar-schema-verdicts) | `null` Cohort-level read of persona verdicts. Subjective, derived from outcomes\[].verdict. positive + negative + undecided = sessions: a session with no grounded verdict is undecided, not absent. That includes a session whose judge answered and had the answer discarded by the quote gate: its row names it in `outcomes[].verdict_discarded`, and it is still counted here as undecided, because this tally counts only verdicts grounded in the persona's words. - **`wall_clock_s`**: `integer | null` **Generated example:** ```json { "personas_run": 1, "missions": 1, "sessions": 1, "completed": 1, "abandoned": 1, "blocked": 1, "errored": 1, "budget_exhausted": 0, "issues_total": 1, "issues_reproducible": 1, "issues_marker_matched": 1, "tester_errors": 1, "issues_new_vs_baseline": 1, "issues_resolved_vs_baseline": 1, "issues_persisting_vs_baseline": 1, "opportunities_total": 1, "verdicts": { "positive": 1, "negative": 1, "undecided": 1, "top_reasons": [ "" ] }, "confirmation_rate": 0, "reproduction": { "candidates": 1, "merged": 1, "evaluated": 1, "confirmed": 1, "inconclusive": 1, "excluded": 1 }, "gate_discards": 0, "path_diversity": 0, "cost_usd": 1, "actor_cost_usd": 0, "judge_cost_usd": 0, "wall_clock_s": 1 } ``` ### TechnicalLiteracy **Type:** `string`, possible values: `"low", "medium", "high"` How comfortable the persona is with technology. `low`: not technical; keys, tokens and APIs mean nothing to them. `medium`: finds their way around an app's settings without being technical. `high`: technical, and notices when a key or a token is handled badly. ### TesterError **Type:** `object`, no additional properties What the persona did that explains this failure. Present exactly when `claim.attribution` is `tester`, and such an issue is in `tester_errors[]`, never in `issues[]`. The quote is the evidence line that shows the persona's action - the input echo, the step head with its args, a diary line, a browser line - copied exactly from the ledger and gated the same way a diary quote is; a `tester` answer whose quote does not gate falls back to `undetermined` and this block is not written. - **`kind` (required)**: `string` Schema: `Kind1`. Known values, open (more may be added): `mistyped_credential`, `mistyped_input`, `wrong_element`, `placeholder_as_value`, `ignored_instruction`, `other`. `mistyped_credential`: typed an email or password that differs from the one the account gave it (decided by code, comparing the typed text with the account; no secret enters a prompt or this document). `mistyped_input`: typed something other than what the brief or the screen asked for. `wrong_element`: acted on an element other than the one the title is about, or a non-interactive one. `placeholder_as_value`: read placeholder text as a pre-filled value and tried to clear or fix it. `ignored_instruction`: skipped something the screen visibly asked for first. `other`: something else the persona did that explains it. Not a tester error, by rule: a persona misreading the product's intent when a reasonable customer would misread it the same way - that is `confusion`, and it is the product's. A classification: a later kind is additive. - **`quote` (required)**: `string` - **`decided_by`**: `string | null` Schema: `DecidedBy`. Known values, open (more may be added): `code`, `judge`. `code`: a mechanical comparison decided it (the credential check). `judge`: the observer's attribution model call under its own instructions decided it. A classification. - **`quote_step`**: [QuoteStep](#scalar-schema-quotestep) | `null` The step whose ledger block the quote came from. - **`reason`**: `string | null` **Generated example:** ```json { "kind": "mistyped_credential", "quote": "", "quote_step": { "session_id": "", "step_id": 1 }, "decided_by": "code", "reason": "" } ``` ### TesterErrorList **Type:** `object`, no additional properties The run's tester errors: findings the harness attributed to its own persona, never to the product. Issue-shaped - the same id, evidence and screenshots an issue carries - and never an issue: not in `IssueList`, not in `issues_total`, not reproduced, never a ticket. They are served so a customer can see why a session stalled on their own test setup (a mistyped credential, the wrong element) instead of reading the stall as the product's. - **`advisory` (required)**: `string` How to read this whole answer: harness-attributed, never a defect. What it may and may not be used for. - **`classified` (required)**: `boolean` Whether the run's artifact sorts findings by whose failure they are at all. Empty with this false is an artifact from before the classification (contracts 0.5), or no artifact yet: nobody asked. Empty with this true is a run that asked and found none. - **`reading` (required)**: array of [TesterErrorReading](#scalar-schema-testererrorreading) One entry per record, in the same order and naming its `tester_error_id`. - **`run_id` (required)**: `string` - **`status` (required)**: `string` The run's status. An empty `tester_errors` is only a result once this is terminal. - **`subjective_fields` (required)**: `array of string` Fields on each record that are somebody's words rather than an observation: the persona's narration, and `tester_error.reason`, the harness's sentence about its own persona. - **`tester_errors` (required)**: array of [Issue](#scalar-schema-issue) The artifact's `tester_errors[]`, each the contract's own issue object with its `tester_error` block. **Generated example:** ```json { "run_id": "", "status": "", "classified": true, "tester_errors": [ { "id": "", "type": "blocker", "severity": "low", "reproducible": true, "reproduction": { "attempted": true, "result": "reproduced", "attempts": 1, "notes": "" }, "persona_specific": true, "claim": { "action_step": { "session_id": "", "step_id": 1 }, "expected": "", "observed": "", "verification": { "status": "verified", "method": "marker", "markers": [ "" ], "note": "" }, "attribution": "product", "check": { "verdict": "supported", "quote": "", "quote_step": { "session_id": "", "step_id": 1 }, "reason": "", "checked_by": "judge" } }, "tester_error": { "kind": "mistyped_credential", "quote": "", "quote_step": { "session_id": "", "step_id": 1 }, "decided_by": "code", "reason": "" }, "affected_sessions": [ "" ], "affected_personas": [ "" ], "affected_count": 1, "affected_fraction": 0, "reached_sessions": [ "" ], "first_seen_step": 1, "location": { "route": "", "selector": "", "element_description": "", "viewport": "" }, "title": "", "description": "", "persona_rationale": "", "contradiction": { "a": { "step_id": 1, "session_id": "", "text": "" }, "b": { "step_id": 1, "session_id": "", "text": "" } }, "evidence": { "step_refs": [ { "session_id": "", "step_id": 1 } ], "screenshots": [ "" ], "video": { "ref": "", "ts_start_s": 1, "ts_end_s": 1 }, "dom_snapshot": "", "network": [ { "additionalProperty": "anything" } ], "console": [ { "additionalProperty": "anything" } ] }, "repro_steps": [ { "action": "", "target": "", "value": "", "replace": true, "expect": "" } ], "preconditions": [ "" ], "baseline_status": "new", "confidence": 0, "suggested_owner": "", "connector_refs": { "additionalProperty": "" } } ], "reading": [ { "tester_error_id": "", "label": "", "may_file_ticket_alone": false } ], "advisory": "", "subjective_fields": [ "" ] } ``` ### TesterErrorReading **Type:** `object`, no additional properties How one record of `TesterErrorList.tester_errors` reads: what the persona did, and that it is never a ticket. - **`label` (required)**: `string` What the persona did, in the words every surface uses: the sentence the local MCP server, the GitHub PR comment and the dashboard all show for this kind. - **`tester_error_id` (required)**: `string` - **`may_file_ticket_alone`**: `boolean`, const: `false`, default: `false` False for every record in this array, by construction. Stated rather than left to be inferred from an absent field, because a reader that has to work it out is a reader that might not. **Generated example:** ```json { "tester_error_id": "", "label": "", "may_file_ticket_alone": false } ``` ### Tolerance **Type:** `object`, no additional properties How much the persona puts up with before giving up. Saram counts, step by step, the steps without visible progress, the errors and slow responses, and the moments with no clear next action, and ends the session `abandoned` the moment one crosses its threshold here; the model playing the persona cannot argue with the count. The persona is also told these limits in their own terms. - **`error_threshold` (required)**: `integer`, minimum: `0` - **`patience_steps` (required)**: `integer`, minimum: `1` - **`abandon_behavior`**: `string | null`, possible values: `"silent", "narrates_reason"`, default: `"narrates_reason"` Schema: `AbandonBehavior`. What the persona's diary says when they give up, on one task or on the whole session. `narrates_reason` (the default): they say why before they stop. `silent`: they simply stop. - **`attempts_per_subtask`**: `integer | null`, minimum: `1` - **`confusion_threshold`**: `integer | null`, minimum: `1` - **`latency_sensitivity_ms`**: `integer | null`, minimum: `0` **Generated example:** ```json { "patience_steps": 1, "error_threshold": 0, "latency_sensitivity_ms": 0, "confusion_threshold": 1, "attempts_per_subtask": 1, "abandon_behavior": "narrates_reason" } ``` ### TopUpRecord **Type:** `object`, no additional properties One top-up: where it stands, and while it is open, the secret the Payment Element pays it with. `pending` (the invoice is being made), `open` (awaiting payment), then `paid` (credited to the balance), `failed` (declined, or no invoice could be made), `requires_action` (an off-session charge the bank wants the person to authenticate) or `void` (unpaid after a day). Credit is added once, when Stripe says the invoice is paid - never on the browser's word. A top-up that ended unpaid says why in `failure_code` and `failure_message`. - **`amount_usd` (required)**: `number` - **`created_at` (required)**: `string`, format: `date-time` - **`id` (required)**: `string` - **`origin` (required)**: `string`, possible values: `"manual", "auto"` - **`status` (required)**: `string`, possible values: `"pending", "open", "paid", "failed", "requires_action", "void"` - **`client_secret`**: `string | null` What the Payment Element confirms in the browser (stripe.confirmPayment / confirmSetup), where SCA happens. Present only while there is something to confirm. Saram never sees the card. - **`failure_code`**: `string | null` Why a `failed`, `requires_action` or `void` top-up did not go through, for a screen to branch on. When the card was refused it is the bank's reason as Stripe names it - the `decline_code` (`generic_decline`, `insufficient_funds`, ...) or, when there is none, Stripe's error `code` (`card_declined`, `expired_card`, ...): another card may go through. Otherwise it is one of Saram's own: `requires_action` (the bank wants the person to confirm an automatic refill, which only a top-up by hand can do), `payment_failed` (an automatic refill's payment did not complete), `expired` (unpaid after a day), `voided` (the invoice was cancelled before it was paid) or `not_started` (the payment could not be started; try again later). Nothing is charged for any of them. Absent while in flight and once paid. - **`failure_message`**: `string | null` The same, in a sentence written to be shown to the person: the bank's own words as Stripe gives them for the cardholder ('Your card was declined.'), or Saram's for its own codes. - **`finished_at`**: `string | null`, format: `date-time` - **`invoice_id`**: `string | null` **Generated example:** ```json { "id": "", "amount_usd": 1, "origin": "manual", "status": "pending", "invoice_id": "", "client_secret": "", "failure_code": "", "failure_message": "", "created_at": "", "finished_at": "" } ``` ### TopUpWrite **Type:** `object`, no additional properties `POST /api/v1/billing/top-ups`: how much credit to buy, in whole dollars. - **`amount_usd` (required)**: `integer`, minimum: `10`, maximum: `500` Whole dollars of prepaid credit: the invoice reads 'Saram usage credit, N x $1.00', with tax on top where it applies. Counts against the monthly budget, manual and automatic top-ups together. **Generated example:** ```json { "amount_usd": 10 } ``` ### TrustDefaults **Type:** `string`, possible values: `"low", "medium", "high"` Accepts prefilled/default choices vs. checks everything ### Type **Type:** `string` Known values, open (more may be added): `blocker`, `friction`, `confusion`, `bug`, `contradiction`. Defect classes only. A moment that worked well is an opportunity of kind `delight`, never an issue: it has no failure for reproduction to replay. **Example:** ```json "blocker" ``` **Example:** ```json "friction" ``` **Example:** ```json "confusion" ``` **Example:** ```json "bug" ``` **Example:** ```json "contradiction" ``` ### UsageCeiling **Type:** `object`, no additional properties The most Usage a run on this spec can use, and the balance `POST /runs` would hold it against. Computed the way `POST /runs` holds the ceiling, at the same moment's balance, so a run this says is covered is one that starts - unless another run takes the balance between the two calls. - **`available_usd` (required)**: `number` The balance less what the tenant's runs in flight already hold. - **`covered` (required)**: `boolean` Whether `available_usd` covers `usage_usd`. - **`enforced` (required)**: `boolean` Whether `POST /runs` refuses this spec with a 402 when `covered` is false. False while the gate runs in shadow, and always for Saram's own fleet. - **`sessions` (required)**: `integer` Sessions the spec plans, one per persona per mission. - **`steps_per_session` (required)**: `integer` - **`steps_per_session_that_fit` (required)**: `integer` The largest `budget.steps_per_session` that `available_usd` covers with the rest of the spec as written; 0 when not even one step per session does. - **`usage` (required)**: `number` `sessions x usage_per_session`: the run's ceiling, which `POST /runs` holds against the balance. - **`usage_per_session` (required)**: `number` The most Usage one session can use: every step at its most, or the wall clock spent in full. - **`usage_price_usd` (required)**: `number` - **`usage_usd` (required)**: `number` The ceiling in dollars, at the plan's price per Usage. - **`wall_clock_s_per_session`**: `integer | null` The spec's own wall-clock budget. Absent when it names none, and then only the step budget bounds a session: every step is counted at the most seconds a step is charged for. **Generated example:** ```json { "sessions": 1, "steps_per_session": 1, "wall_clock_s_per_session": 1, "usage_per_session": 1, "usage": 1, "usage_usd": 1, "usage_price_usd": 1, "available_usd": 1, "covered": true, "steps_per_session_that_fit": 1, "enforced": true } ``` ### UsageChargeRun **Type:** `object`, no additional properties One run's Usage in the month, from the credit ledger: what it used and what that cost, refunds apart. - **`refunded_usd` (required)**: `number` Given back for its sessions that failed on Saram's side; each one is also in refunds. - **`run_id` (required)**: `string` - **`sessions` (required)**: `integer` Its persona sessions settled this month. - **`settled_at` (required)**: `string`, format: `date-time` When the run's last session was settled this month: the moment its Usage was charged. - **`usage` (required)**: `number` The Usage its settled sessions used, refunded ones included, to 0.001. - **`usage_usd` (required)**: `number` What that Usage cost you, net: refunded\_usd is already taken off. - **`caller`**: `string | null` Who started it, as GET /runs reports it. Absent when nobody was identified. **Generated example:** ```json { "run_id": "", "caller": "", "settled_at": "", "sessions": 1, "usage": 1, "usage_usd": 1, "refunded_usd": 1 } ``` ### UsageCharges **Type:** `object`, no additional properties The month's prepaid Usage, from Saram's credit ledger: what your personas used and what it cost you, by run and by persona, and the sessions that were not charged. Only a plan that prices Usage (Free, Pro) has rows here; a plan billed in steps reports zeros, and its charge is `billable_usd` beside this. A session is in the month it was settled in, which is the month it finished. - **`personas`**: array of [UsagePersona](#scalar-schema-usagepersona) Per persona, most Usage first, then by persona\_id; deleted personas included. - **`refunded_usd`**: `number`, default: `0` Given back this month for sessions that failed on Saram's side. - **`refunds`**: array of [UsageRefund](#scalar-schema-usagerefund) Every session refunded this month, oldest refund first, then by run\_id and session\_id. - **`runs`**: array of [UsageChargeRun](#scalar-schema-usagechargerun) Per run, oldest settlement first, then by run\_id. - **`sessions`**: `integer`, default: `0` Persona sessions settled this month. - **`usage`**: `number`, default: `0` The month's Usage, refunded sessions included, to 0.001. - **`usage_price_usd`**: `number | null` USD per Usage on your plan, in force at the end of the month. Absent on a plan billed in steps. A session is charged at the price in force when it ran. - **`usage_usd`**: `number`, default: `0` What the month's Usage cost you, net of refunds: the total a screen prints as Saram's charge. Your model provider's bill is never in it (see provider). **Generated example:** ```json { "usage_price_usd": 1, "usage": 0, "usage_usd": 0, "refunded_usd": 0, "sessions": 0, "runs": [ { "run_id": "", "caller": "", "settled_at": "", "sessions": 1, "usage": 1, "usage_usd": 1, "refunded_usd": 1 } ], "personas": [ { "persona_id": "", "persona_label": "", "deleted": true, "runs": 1, "sessions": 1, "usage": 1, "usage_usd": 1, "refunded_usd": 1 } ], "refunds": [ { "run_id": "", "session_id": "", "persona_id": "", "persona_label": "", "fault": "saram", "usage": 1, "amount_usd": 1, "refunded_at": "", "note": "" } ] } ``` ### UsageMeter **Type:** `object`, no additional properties One line of the table of meters: how much of one meter one model caller used in the period. - **`meter` (required)**: `string` steps (one body tool call, the platform fee's unit), sessions (persona sessions that ran a step), browser\_seconds (pool time), model\_usd\_hosted (model spend on a Saram-held credential), model\_usd\_byo (the same on your own provider key, which the invoice charges at $0) or usage (persona steps weighted by how long each took, recorded per session; reported, not billed). - **`quantity` (required)**: `number` In the meter's own unit: a count, seconds, or USD. - **`role`**: `string | null` Which model caller the quantity is attributed to: actor, judge or reproduction. The counting meters are the actor's; a model meter is written per role that had anything priced. **Generated example:** ```json { "meter": "", "role": "", "quantity": 1 } ``` ### UsagePersona **Type:** `object`, no additional properties One persona's Usage in the month, under the name it had when its runs ran (section 2.3). Grouped by `persona_id`, which carries the version: `dana_roofer_novice@1` and `@2` are two lines. A persona that was renamed keeps the name its sessions were settled under; one that has since been deleted keeps its line and says so, because the money it used is still money you spent. - **`deleted` (required)**: `boolean` True when the persona no longer exists: checked when this report is read, so a line can say 'Dana (deleted)' and still carry every dollar its sessions cost. - **`persona_id` (required)**: `string` The persona's address as the run named it: id, or id\@version. - **`refunded_usd` (required)**: `number` Given back for its sessions that failed on Saram's side. - **`runs` (required)**: `integer` How many runs it took part in this month. - **`sessions` (required)**: `integer` Its sessions settled this month. - **`usage` (required)**: `number` The Usage its sessions used, refunded ones included, to 0.001. - **`usage_usd` (required)**: `number` What that cost you, net of refunds. - **`persona_label`**: `string | null` Its display name as recorded when its latest session this month was settled - not as it reads now. Absent for a persona that declared no name; show persona\_id then. **Generated example:** ```json { "persona_id": "", "persona_label": "", "deleted": true, "runs": 1, "sessions": 1, "usage": 1, "usage_usd": 1, "refunded_usd": 1 } ``` ### UsageRefund **Type:** `object`, no additional properties One session that was not charged because it failed on Saram's side (section 2.4): debited, then refunded whole, and both kept, so the Usage it would have cost stays visible. - **`amount_usd` (required)**: `number` What was given back: the whole of the session's debit. - **`refunded_at` (required)**: `string`, format: `date-time` - **`run_id` (required)**: `string` - **`session_id` (required)**: `string` - **`usage` (required)**: `number` The Usage the session used before it failed, to 0.001. - **`fault`**: `string`, const: `"saram"`, default: `"saram"` Whose failure it was. Always saram: the harness - the browser pool, a lost worker, a setup step in Saram's own code. A session your model provider or your product ended is charged, and is not here. - **`note`**: `string | null` The refund's reason as the ledger recorded it. - **`persona_id`**: `string | null` - **`persona_label`**: `string | null` As recorded when the session was settled. **Generated example:** ```json { "run_id": "", "session_id": "", "persona_id": "", "persona_label": "", "fault": "saram", "usage": 1, "amount_usd": 1, "refunded_at": "", "note": "" } ``` ### UsageReport **Type:** `object`, no additional properties One month of your tenant's usage: the meters, the runs behind them, and the totals (`GET /api/v1/usage`). Your own tenant and no other: the ledger is filtered by the tenant the request's key belongs to, so a neighbour's run is not in these figures and cannot be. `GET /api/v1/usage.csv` is the same report as the file an invoice is written from. **One meter is billed and four are reported.** `billable_usd` is the month's bill: billed steps at `step_price_usd`, less `included_steps`, plus `monthly_fee_usd`. The model spend, the session count and the browser seconds are here so you can see what your keys spent and how long your personas were in your product; none of them is multiplied by anything. The arithmetic is served rather than left to the client, so a screen and an invoice cannot disagree. - **`from` (required)**: `string`, format: `date-time` First moment of the period, inclusive (UTC). - **`period` (required)**: `string` The calendar month these figures cover, YYYY-MM, in UTC. - **`plan` (required)**: `string` pilot, byo or hosted: the price list this usage is invoiced under. - **`tenant_id` (required)**: `string` - **`tenant_name` (required)**: `string` - **`tenant_slug` (required)**: `string` - **`to` (required)**: `string`, format: `date-time` First moment of the next period, exclusive (UTC). - **`billable_usd`**: `number`, default: `0` What Saram bills you for this month, to the cent: billed steps at the price in force when each run ran, less included\_steps, plus monthly\_fee\_usd. Model spend on your own keys is not in it. - **`billed_steps`**: `integer`, default: `0` The month's billed steps: the persona loop's (role actor), never the reproduction replay's. This, not totals.steps, is what billable\_usd is a multiplication of. - **`house_models`**: `boolean`, default: `false` True when Saram has agreed to carry this tenant's model spend, so runs on a bring-your-own-keys plan may sign their model calls with a Saram-held credential. It changes no figure here - the steps bill at the plan's price either way - and it is what explains a model\_usd\_hosted total that is not zero on such a plan. - **`included_steps`**: `integer`, default: `0` Billed steps the plan's monthly fee covers before the step price applies. - **`meters`**: array of [UsageMeter](#scalar-schema-usagemeter) Per meter and role, summed over the period's runs. - **`model_usd_complete`**: `boolean`, default: `false` Whether the model meters cover every model caller in a run: the persona loop under role actor, the judge and the reproduction check under their own roles. A run metered before the judge was priced (2026-09-25) carries the persona loop's spend alone, so a month reaching back past that is a floor for those runs. The step meter is complete. - **`monthly_cap_usd`**: `number | null` On a plan that bills steps (pilot): what this month may bill before a new run is refused with a 402. Absent means no cap. The refusal compares billable\_usd below against this figure. Not a limit on a plan that prices Usage (free, pro), whose runs are limited by the available balance instead. - **`monthly_fee_usd`**: `number`, default: `0` The plan's monthly fee, included in billable\_usd. - **`provider`**: [ProviderSpend](#scalar-schema-providerspend) Your model provider's estimated bill for the month: shown apart, never added to usage.usage\_usd. - **`runs`**: array of [UsageRun](#scalar-schema-usagerun) The runs metered in the period, oldest first. - **`step_price_usd`**: `number`, default: `0` USD per billed step under your plan, or your own agreed price where one was set. The price in force at the end of this period; a run is billed at the price in force when it ran, so across a mid-month price change this figure times billed\_steps is not billable\_usd, and the per-run billable\_usd figures say why. - **`totals`**: [UsageTotals](#scalar-schema-usagetotals) Per meter, roles collapsed. - **`usage`**: [UsageCharges](#scalar-schema-usagecharges) The month's prepaid Usage by run and by persona, with its refunds: Saram's charge on a plan that prices Usage. Read from the credit ledger, apart from the step meters above. **Generated example:** ```json { "period": "", "from": "", "to": "", "tenant_id": "", "tenant_slug": "", "tenant_name": "", "plan": "", "house_models": false, "meters": [ { "meter": "", "role": "", "quantity": 1 } ], "totals": { "steps": 0, "sessions": 0, "browser_seconds": 0, "model_usd_hosted": 0, "model_usd_byo": 0 }, "runs": [ { "run_id": "", "caller": "", "occurred_at": "", "steps": 0, "billed_steps": 0, "sessions": 0, "browser_seconds": 0, "model_usd_hosted": 0, "model_usd_byo": 0, "billable_usd": 0 } ], "model_usd_complete": false, "billed_steps": 0, "step_price_usd": 0, "included_steps": 0, "monthly_fee_usd": 0, "monthly_cap_usd": 1, "billable_usd": 0, "usage": { "usage_price_usd": 1, "usage": 0, "usage_usd": 0, "refunded_usd": 0, "sessions": 0, "runs": [ { "run_id": "", "caller": "", "settled_at": "", "sessions": 1, "usage": 1, "usage_usd": 1, "refunded_usd": 1 } ], "personas": [ { "persona_id": "", "persona_label": "", "deleted": true, "runs": 1, "sessions": 1, "usage": 1, "usage_usd": 1, "refunded_usd": 1 } ], "refunds": [ { "run_id": "", "session_id": "", "persona_id": "", "persona_label": "", "fault": "saram", "usage": 1, "amount_usd": 1, "refunded_at": "", "note": "" } ] }, "provider": { "model_usd_byo": 0, "complete": false, "runs": [ { "run_id": "", "model_usd_byo": 1 } ] } } ``` ### UsageRun **Type:** `object`, no additional properties One run's line on the invoice: its meters with the roles collapsed, because a line is money per run. - **`occurred_at` (required)**: `string`, format: `date-time` When the run finished, which is the moment its usage is dated. - **`run_id` (required)**: `string` - **`billable_usd`**: `number`, default: `0` What this run adds to the month's bill: its billed steps, less any covered by the plan's included allowance, at the step price in force when it ran. The plan's monthly fee is not in it - that is on the month, not on a run. - **`billed_steps`**: `integer`, default: `0` The steps this run is billed for: the persona loop's (role actor), never the reproduction replay's, which are included. Equal to steps until replay steps are counted. - **`browser_seconds`**: `number`, default: `0` - **`caller`**: `string | null` Who started it, as GET /runs reports it. Absent when nobody was identified. - **`model_usd_byo`**: `number`, default: `0` - **`model_usd_hosted`**: `number`, default: `0` - **`sessions`**: `integer`, default: `0` - **`steps`**: `integer`, default: `0` **Generated example:** ```json { "run_id": "", "caller": "", "occurred_at": "", "steps": 0, "billed_steps": 0, "sessions": 0, "browser_seconds": 0, "model_usd_hosted": 0, "model_usd_byo": 0, "billable_usd": 0 } ``` ### UsageTotals **Type:** `object`, no additional properties The period's totals per meter, roles collapsed: the figures a price list is applied to. - **`browser_seconds`**: `number`, default: `0` - **`model_usd_byo`**: `number`, default: `0` - **`model_usd_hosted`**: `number`, default: `0` - **`sessions`**: `integer`, default: `0` - **`steps`**: `integer`, default: `0` **Generated example:** ```json { "steps": 0, "sessions": 0, "browser_seconds": 0, "model_usd_hosted": 0, "model_usd_byo": 0 } ``` ### ValidationError **Type:** `object` - **`loc` (required)**: `array of (string | integer)` - **`msg` (required)**: `string` - **`type` (required)**: `string` - **`ctx`**: `object` - **`input`**: `any` **Generated example:** ```json { "loc": [ "" ], "msg": "", "type": "", "input": null, "ctx": {} } ``` ### Verbosity **Type:** `string`, possible values: `"terse", "normal", "verbose"` How long the persona's diary entries run. `terse`: short. `normal`: as much as the moment deserves. `verbose`: in full, with the detail they noticed. ### Verdict **Type:** `object`, no additional properties SUBJECTIVE. The persona's answer at session end to the run's verdict prompt - the run spec's `mission.verdict_prompt` when it has one, `persona.verdict_prompt` otherwise - grounded in diary quotes. A persona pack's question is about the product the pack was written for, so a run that points a pack at another target sets the mission's and the pack's is not applicable for that run. Which of the two this answers, and its text, are on the row: `outcomes[].verdict_prompt_source` and `outcomes[].verdict_prompt`, which sit there rather than here so that a session with no verdict still names the question that went unanswered. Consumers must tag it as subjective. `decision_text`, `reason` and `quotes` are quotation, verbatim from the diary at `step_refs`; `reason_paraphrase` is the only field written in the judge's voice, and a consumer that shows it must label it as the judge's, never as the persona's words. - **`decision` (required)**: `string`, possible values: `"positive", "negative", "undecided"` Schema: `Decision`. Would keep paying / would recommend / would text 'it works' = positive - **`reason` (required)**: `string` - **`step_refs` (required)**: `array of integer`, minItems: `1` - **`decision_text`**: `string | null` - **`quotes`**: `(array of string) | null` - **`reason_paraphrase`**: `string | null` **Generated example:** ```json { "decision": "positive", "decision_text": "", "reason": "", "reason_paraphrase": "", "quotes": [ "" ], "step_refs": [ 1 ] } ``` ### Verdict1 **Type:** `string` Known values, open (more may be added): `supported`, `unsupported`, `ungrounded`, `skipped`. `supported`: the checker answered yes with a quote that gates against the ledger text of the cited steps - the only verdict that lets `status` read `verified`. `unsupported`: the checker answered no; the title claims more than the quoted line shows. `ungrounded`: the checker answered yes but its quote is not in the ledger, so the answer is discarded the way an ungrounded diary quote is and recorded in the gate ledger. `skipped`: no marker matched, or the check did not run. A classification: a later verdict is additive. **Example:** ```json "supported" ``` **Example:** ```json "unsupported" ``` **Example:** ```json "ungrounded" ``` **Example:** ```json "skipped" ``` ### VerdictDiscarded **Type:** `object`, no additional properties NOT A VERDICT. The answer a judge gave about this session that the verbatim-quote gate threw away because it could not ground itself in the persona's words: none of its quotes is in the diary at the steps it cites, or none of the steps it cites is a step of the session. Present only on a row a judge read that has no `verdict`, so never beside `verdict` or `observer_error`. It is what tells a judge that answered and was not grounded apart from a persona who gave no answer: both rows carry no `verdict` and both count `undecided` in `summary.verdicts`, which counts grounded verdicts alone, and only this one says an answer was given. The decision is the judge's reading with nothing in the diary to support it, so a consumer never shows it as the persona's verdict, never counts it and never acts on it; what it points a reader at is the judge's citations. Nothing the judge quoted is carried here, because none of it is verifiably the persona's: the records, the dropped quotes among them, are in the session's gate ledger beside its observation, and the discard is counted in `summary.gate_discards`. Absent on a row with no `verdict` means nothing was discarded: the judge returned no answer, no judge read the session (`observer_error`), or no question was in play (`verdict_prompt_source` absent). An artifact written before 0.6.3 carries none either, and `contracts_version` is what tells those apart. - **`gate_reason` (required)**: `string` - **`judge_decision` (required)**: `string`, possible values: `"positive", "negative", "undecided"` Schema: `JudgeDecision`. The decision the judge returned, before the gate discarded it. The judge's and not the persona's: no diary line at the steps it cites supports it. Named apart from `verdict.decision` so that nothing reading a row by field name takes it for the persona's. **Generated example:** ```json { "judge_decision": "positive", "gate_reason": "" } ``` ### VerdictList **Type:** `object`, no additional properties A run's persona verdicts: each session's answer to the run's verdict question, grounded in the persona's own diary quotes, and the cohort's tally. Persona feedback, never a ticket on its own. - **`advisory` (required)**: `string` How to read this whole answer: persona feedback, not defects. Never a ticket on its own. - **`run_id` (required)**: `string` - **`status` (required)**: `string` The run's status. An empty `verdicts` is only a result once this is terminal. - **`subjective_fields` (required)**: `array of string` Fields on each row that are the persona's opinion - for a verdict, the verdict itself. - **`verdicts` (required)**: array of [VerdictRow](#scalar-schema-verdictrow) - **`summary`**: [Verdicts](#scalar-schema-verdicts) | `null` The artifact's cohort read (`summary.verdicts`). **Generated example:** ```json { "run_id": "", "status": "", "summary": { "positive": 1, "negative": 1, "undecided": 1, "top_reasons": [ "" ] }, "verdicts": [ { "session_id": "", "persona_id": "", "mission_id": "", "result": "", "verdict_prompt": "", "verdict_prompt_source": "mission", "verdict": { "decision": "positive", "decision_text": "", "reason": "", "reason_paraphrase": "", "quotes": [ "" ], "step_refs": [ 1 ] }, "verdict_discarded": { "judge_decision": "positive", "gate_reason": "" }, "observer_error": "" } ], "advisory": "", "subjective_fields": [ "" ] } ``` ### VerdictPromptSource **Type:** `string` Known values, open (more may be added): `mission`, `persona`. Whose question `verdict` answers: the run spec's `mission.verdict_prompt`, or the persona pack's `persona.verdict_prompt` when the mission carried none. `mission` is also the record that the pack's own question was not applicable for this run, which is what makes an absent `verdict` beside it readable: the diary did not answer a question about the product actually under test, which is a run-spec problem, not a judge that could not ground itself (that one names its discarded answer in `verdict_discarded`). Absent means no verdict question was in play: neither the mission nor the pack carried one, so `verdict` is absent by construction and `summary.verdicts` counts the session `undecided` without that being anything the judge failed at. An artifact written before this release carries none either, and `contracts_version` is what tells the two apart. Absent beside `observer_error` is neither: no judge ran, so nobody resolved which question was in play. **Example:** ```json "mission" ``` **Example:** ```json "persona" ``` ### VerdictRow **Type:** `object`, no additional properties SUBJECTIVE: one session's verdict as the artifact's `outcomes[]` carries it. Persona feedback, never a ticket on its own. - **`mission_id` (required)**: `string` - **`persona_id` (required)**: `string` - **`result` (required)**: `string` - **`session_id` (required)**: `string` - **`observer_error`**: `string | null` Why no judge read this session, when none did, in Saram's words (`artifact.outcomes[].observer_error`): its judge pass failed after its retries, so the absent `verdict` is a question nobody asked a model, not one the persona left unanswered. The model provider's status and own message when the provider refused, the `providers` entry's refusal when it did not resolve, Saram's sentence otherwise. Absent on every session a judge read, and on every row of an artifact written before contracts 0.6.1; one written before 0.6.3 carries the failure as it was recorded. - **`verdict`**: [Verdict](#scalar-schema-verdict) | `null` SUBJECTIVE. The persona's answer at session end to the run's verdict prompt - the run spec's `mission.verdict_prompt` when it has one, `persona.verdict_prompt` otherwise - grounded in diary quotes. A persona pack's question is about the product the pack was written for, so a run that points a pack at another target sets the mission's and the pack's is not applicable for that run. Which of the two this answers, and its text, are on the row: `outcomes[].verdict_prompt_source` and `outcomes[].verdict_prompt`, which sit there rather than here so that a session with no verdict still names the question that went unanswered. Consumers must tag it as subjective. `decision_text`, `reason` and `quotes` are quotation, verbatim from the diary at `step_refs`; `reason_paraphrase` is the only field written in the judge's voice, and a consumer that shows it must label it as the judge's, never as the persona's words. - **`verdict_discarded`**: [VerdictDiscarded](#scalar-schema-verdictdiscarded) | `null` NOT A VERDICT. The answer the judge gave that the quote gate threw away because none of it could be grounded in the persona's diary at the steps it cited, and why (`artifact.outcomes[].verdict_discarded`). Set only on a row with no `verdict`; absent there means nothing was discarded - the judge returned no answer, or no judge read the session (`observer_error`). Never show `judge_decision` as the persona's verdict and never count it: the cohort's `summary` counts this session `undecided`. Absent on every row of an artifact written before contracts 0.6.3. - **`verdict_prompt`**: `string | null` The verdict question this session was asked, verbatim: the one `verdict` answers. Absent when no question was in play for the session, and on every row of an artifact written before the outcome carried it (0.4.1). - **`verdict_prompt_source`**: `string | null` Schema: `VerdictPromptSource`. Known values, open (more may be added): `mission`, `persona`. Whose question `verdict` answers: the run spec's `mission.verdict_prompt`, or the persona pack's when the mission carried none. Present exactly when `verdict_prompt` is; `mission` beside an absent `verdict` is a persona who was asked the run's question and did not answer it - unless `verdict_discarded` says the judge answered for them and could not ground the answer. **Generated example:** ```json { "session_id": "", "persona_id": "", "mission_id": "", "result": "", "verdict_prompt": "", "verdict_prompt_source": "mission", "verdict": { "decision": "positive", "decision_text": "", "reason": "", "reason_paraphrase": "", "quotes": [ "" ], "step_refs": [ 1 ] }, "verdict_discarded": { "judge_decision": "positive", "gate_reason": "" }, "observer_error": "" } ``` ### Verdicts **Type:** `object`, no additional properties Cohort-level read of persona verdicts. Subjective, derived from outcomes\[].verdict. positive + negative + undecided = sessions: a session with no grounded verdict is undecided, not absent. That includes a session whose judge answered and had the answer discarded by the quote gate: its row names it in `outcomes[].verdict_discarded`, and it is still counted here as undecided, because this tally counts only verdicts grounded in the persona's words. - **`negative`**: `integer | null` - **`positive`**: `integer | null` - **`top_reasons`**: `(array of string) | null` - **`undecided`**: `integer | null` **Generated example:** ```json { "positive": 1, "negative": 1, "undecided": 1, "top_reasons": [ "" ] } ``` ### Verification **Type:** `object`, no additional properties Whether the claim was checked, and by what. The two are separate questions and were conflated once already: `status` is the answer a reader acts on, `method` only says which instrument produced it. Consumers show `status`. - **`status` (required)**: `string`, possible values: `"verified", "marker_matched", "inconclusive", "unverified"` Schema: `Status`. Best-supported first. `verified`: a replay matched the failure marker at `action_step` AND the claim check answered `supported` with a verbatim quote from the cited steps (`check`) AND the failure is attributed to the product (`attribution`) - or `method` is `assertion` or `manual` and the same two conditions hold. This and only this may file a ticket alone, and `reproducible` is true exactly when this reads `verified`. `marker_matched` (0.5): a replay matched the failure marker at `action_step`; the claim as titled is unchecked, unsupported, or the check could not quote. The step failed again; whether it failed for the stated reason is not established, so it never files alone. `inconclusive`: a check ran and settled nothing (the marker did not appear again, the two attempts disagreed) - not a refutation. `unverified`: nothing checked it, because there was nothing to check, reproduction never ruled, or the finding is a tester error. A wrong total or a select that resets is `unverified` by construction, not disproven. Default and safe reading when the block is absent. This enum drives an action - it is the ticket-filing gate, like `issue.severity` - so it stays closed for readers too and adding a member is a breaking change: a gate must refuse a value it cannot interpret rather than guess at one, which is why `marker_matched` made 0.5 a new family. - **`markers`**: `(array of string) | null` - **`method`**: `string | null` Schema: `Method`. Known values, open (more may be added): `marker`, `assertion`, `manual`. Which check ran, when one did; absent when `status` is `unverified`. `marker`: a replay compared the failure marker at `action_step` - name it in `markers`. A marker matching is evidence the step failed again, not that it failed for the reason the title gives, so on its own it produces `marker_matched`, and `verified` only with `check.verdict: supported` and `attribution: product`. `assertion`: a state assertion checked the claim itself (reserved; not produced yet). `manual`: a human checked it. A classification: nothing acts on it - every surface shows `status` - so a reader on an older release buckets an instrument it does not know as `unknown`, and adding one is additive. - **`note`**: `string | null` **Generated example:** ```json { "status": "verified", "method": "marker", "markers": [ "" ], "note": "" } ``` ### Video **Type:** `object` A clip of a session's video that shows the issue: where the video is stored (`ref`), and where the clip starts and ends, in seconds from the start of the recording. - **`ref`**: `string | null` - **`ts_end_s`**: `number | null` - **`ts_start_s`**: `number | null` **Generated example:** ```json { "ref": "", "ts_start_s": 1, "ts_end_s": 1 } ``` ### Vocabulary **Type:** `object`, no additional properties Terms the persona does and does not understand. More specific than the literacy enums in `priors`; the renderer tells the actor to say so when an unknown term appears rather than guess. - **`knows`**: `(array of string) | null` - **`unknown`**: `(array of string) | null` **Generated example:** ```json { "knows": [ "" ], "unknown": [ "" ] } ``` ### Voice **Type:** `object`, no additional properties Narration style only. It never influences what the persona does. - **`style`**: `string | null` - **`verbosity`**: `string | null`, possible values: `"terse", "normal", "verbose"` Schema: `Verbosity`. How long the persona's diary entries run. `terse`: short. `normal`: as much as the moment deserves. `verbose`: in full, with the detail they noticed. **Generated example:** ```json { "style": "", "verbosity": "terse" } ``` ### RunSpec **Type:** `object`, no additional properties Input to the control plane. A run = target x persona set x missions x budget x baseline. - **`budget` (required)**: `object`, no additional properties How far each session may go and how much the run may spend. A session ends at the first of its caps it reaches: `steps_per_session` steps (`end_reason` `budget_steps`), `tokens_per_session` model tokens billed, which is the prompt not read from cache plus the reply (`budget_tokens`), or `wall_clock_s_per_session` seconds (`budget_wall_clock`). `max_concurrent_sessions` is the most of the run's sessions that run at once, and your plan's own limit bounds it. `total_cost_usd` pauses the run once the sessions it has recorded have cost that much: no further session starts until it is resumed, and the ones already running finish. - **`steps_per_session` (required)**: `integer`, minimum: `1` - **`max_concurrent_sessions`**: `integer`, minimum: `1` - **`tokens_per_session`**: `integer`, minimum: `1` - **`total_cost_usd`**: `number`, minimum: `0` Soft cap; run pauses when reached - **`wall_clock_s_per_session`**: `integer`, minimum: `1` - **`missions` (required)**: array of [RunSpecMission](#scalar-schema-runspecmission), minItems: `1` - **`personas` (required)**: `array`, minItems: `1` **Items:** **One of:** - `string` persona id\@version from the registry, e.g. novice\_mobile\_skimmer\@2 - [Persona](#scalar-schema-persona) A structured persona: a sampling strategy over the UI state space plus a tolerance function. Two kinds of fields: a structured core the run enforces and the dashboard diffs (`tolerance`, `constraints`, `action_policy`), and prose behavior fields the model reads (`backstory`, `vocabulary`, `facts`, `conversation`, `verdict_prompt`). Both drive what the persona does. `voice` affects narration only. - **`target` (required)**: `object`, no additional properties The product under test: the URL every session opens on (a mission's own `entry_url` overrides it), a name for it, the tunnel to it when it runs on your own machine (`tunnel`), and, when it needs them, how a session gets past a gate in front of the whole deployment (`auth`) and how the product's state is reset and its test accounts handed out between sessions (`seed`). - **`entry_url` (required)**: `string`, format: `uri` - **`auth`**: `object`, no additional properties How a session gets past the target's own door: the gate in front of the whole deployment, not the product's sign-in. `auth` and `seed.accounts` are one mechanism seen from two ends and both resolve `secret-store` entries; which end a credential belongs at is decided by whether it is the target's or the persona's. **The target's** (one for the run, the same for every session, nothing a person would be shown): `basic` on a staging host, a deployment-protection bypass `header` on a Vercel or Cloudflare Access preview, a `cookie` the whole run shares. That is this object: the pool receives `secret_ref` as `session_create.target_auth_secret_ref` and applies the entry's `basic` and `headers` to requests to the target's site (`secret-store` `entry.basic`) and its `cookies` to the browser context, and the control plane sends the same entry's `basic`, `headers` and the `cookies` scoped to the hook on its POST to `seed.reset_hook`, which is behind the same door. **The persona's** (one per session, and a persona may be asked to type it): `seed.accounts`, one ref per session, where the pool applies the entry's `cookies` and the prompt renderer reads `email`, `password`, `inbox` and `notes`. A run can carry both - a staging host behind basic auth whose personas each sign in with their own account - and where the two name the same credential the account's wins. Closed by design: credentials arrive only as `secret_ref`, so a field this object does not list is an inline secret and every reader rejects it, not just the producer. - **`kind`**: `string`, possible values: `"none", "basic", "cookie", "header", "login_flow"` What the door is, for a reader of the spec. It selects nothing: the pool applies whatever the entry behind `secret_ref` carries, so `basic` and `header` on one entry are both applied whichever of the two this says. `none` is a target with no gate (`secret_ref` then says nothing and is omitted); `login_flow` is the one kind that is not a gate at all but a form the persona fills in, which is `seed.accounts` and `login_url`. - **`login_url`**: `string`, format: `uri` Where the product's own sign-in form is, with `kind: login_flow`. Descriptive only: the persona is given the account behind `seed.accounts` and finds the form itself, which is the behaviour under test, so nothing navigates here. - **`secret_ref`**: `string` Reference into the secret store; never inline credentials. Carried to the pool as `session_create.target_auth_secret_ref`, once per session, unchanged; the pool resolves it and applies `basic` and `headers` to requests to the target's site (`secret-store` `entry.basic`) and `cookies` on the context. The same ref for every session of the run - a per-session credential is `seed.accounts`, not this. - **`name`**: `string` - **`seed`**: `object`, no additional properties Environment provisioning. Every e2e product bleeds here; make it explicit. Closed by design (x-closed) for the same reason as `auth`: `accounts` are secret refs and nothing inline may ride beside them. - **`accounts`**: `array of string` Pool of test account secret\_refs, one per session, handed out in session order; sessions past the end of the list get none and the persona is told they have no login. The other half of `auth` (see its description for the split): the persona's credential rather than the target's. Each ref reaches the pool as `session_create.auth_secret_ref`, which applies the entry's `cookies` (and `basic`/`headers`, for the rare account that overrides the target's own), while the renderer tells the persona which credentials they 'have' (email, password) and which they do not (inbox access). Never inline. - **`feature_flags`**: `object` **Additional properties:** `boolean` - **`reset_hook`**: `string`, format: `uri` Endpoint to reset state before each session. The control plane POSTs it once per run with `{"feature_flags": ...}` (the whole target), before each session with `session_id` added (that session's partition), and before each reproduction replay attempt the same way. It sits behind the target's own door like every other url on the host, so the POST carries the entry behind `auth.secret_ref`: its `basic` as HTTP basic auth, its `headers` verbatim and, as one `Cookie` header, those of its `cookies` the browser would send to the hook's url - the same credential the pool opens the browser's door with. A cookie goes when its scope matches the hook as Playwright scopes it in the pool: a `url` names its host alone, that url's path up to its last `/`, and https only; otherwise `domain` (with a leading dot, every subdomain too) and `path` (a prefix ending at a `/`) do, and `secure` keeps it off plain http. A cookie scoped to another host or path is not sent, and neither is any cookie when `headers` already name `Cookie`. A hook that answers anything but 2xx fails the reset. - **`tunnel`**: `string`, pattern: `^tun_[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9a-f]{8}$` For a product running on your own machine: the id of the tunnel you opened to it (`tun_...`), which Saram's browsers reach it through and nothing else can. Not available yet: until Saram opens and binds tunnels, every spec naming one is refused with a 422 (`tunnels to your own machine are not available yet`) by `POST /api/v1/runs`, `POST /api/v1/runs/validate` and the `validate_spec` and `start_run` tools, before any run exists. Naming it binds that tunnel to this run, and the spec is refused unless the tunnel is one the tenant starting the run opened, is still open and is bound to no other run, and every URL the run opens on the target - `entry_url`, each mission's `entry_url` and `seed.reset_hook` - is on the tunnel's hostname. Saram puts the tunnel's own door in front of the run, so a spec naming a tunnel carries no `auth` of its own. The tunnel closes when the run ends. - **`actor`**: `object`, no additional properties How the persona sees the page and which models drive it: the observation mode, the persona and judge models, when a screenshot is taken, and whether the session is recorded on video. - **`actor_model`**: `string` Model id for navigation steps - **`judge_model`**: `string` Model id for the observer - **`observation_mode`**: `string`, possible values: `"vision", "tree", "dom_diff", "hybrid"`, default: `"vision"` How the persona perceives the page. `vision`, the default and the only mode supported today: the persona sees a screenshot of the page when it chooses to look. `tree`, `dom_diff` and `hybrid` are reserved for modes that read the page's structure instead of, or beside, a screenshot, and nothing runs them yet: a spec naming one is refused with a 422 by `POST /api/v1/runs`, `POST /api/v1/runs/validate` and the `validate_spec` and `start_run` tools, before any run exists. - **`record_video`**: `boolean`, default: `true` - **`screenshot_policy`**: `string`, possible values: `"on_look", "every_step", "decision_points", "on_escalation"`, default: `"on_look"` `on_look`: 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. - **`baseline_run_id`**: `string` Run to diff against, typically the last run on main - **`connectors`**: `object`, no additional properties Where the run's results are delivered once its artifact is assembled: `github` files issues and comments on a pull request in your repository, and `webhook` sends one signed POST to a URL of yours. Both are optional; a run with neither is read through the API, the dashboard and the MCP tools. - **`github`**: `object`, no additional properties Deliver the run to the GitHub repository `repo` (`owner/name`). `create_issues` files a GitHub issue for each issue that reproduced, is not specific to one persona and is rated at least `min_severity_for_issue`; `pr_comment` posts one comment on the run on the pull request named in `trigger.pr`, created once and updated after that. Both are off by default, so a block that turns neither on writes nothing. `check_run` is accepted and nothing posts a check run yet. Delivery writes as Saram's GitHub App where it is installed on `repo`, and otherwise with `token_ref`. - **`check_run`**: `boolean`, default: `false` - **`create_issues`**: `boolean`, default: `false` - **`min_severity_for_issue`**: `string`, possible values: `"low", "medium", "high", "critical"`, default: `"medium"` The least severe issue `create_issues` files, from least to most: `low`, `medium` (the default), `high`, `critical`. An issue rated below it stays in the run's artifact and is not filed. - **`pr_comment`**: `boolean`, default: `false` - **`repo`**: `string` - **`token_ref`**: `string`, pattern: `^[a-z][a-z0-9+.-]*://.+$` Reference to a GitHub token that may write issues and comments on `repo`, never the token: `awssm://` under the requesting tenant's own prefix (a credential of kind `github`, POST /api/v1/credentials), or `env://NAME` on an ungated local stack. The fallback, not the connector's identity: delivery authenticates as the deployment's GitHub App installation on `repo` and reads this only when the App is not installed there. With neither, every record the delivery would have written fails with that reason; no other credential is tried, so a run never writes to its repo as somebody else. Resolved by the worker that delivers, at delivery. - **`webhook`**: `object`, no additional properties One signed POST when the run has finished and its artifact is assembled: a small JSON summary (run id, status, session and issue counts, links to the run and the artifact), so a CI job or a deploy pipeline can act on a run without polling. Sent once, after the run's row is closed, by an activity that never fails the run: a receiver that answers 5xx, 429 or nothing is retried a bounded number of times and then given up on, and the outcome is recorded on the run. A run that is cancelled, or that fails before an artifact exists, sends nothing. Closed by design: the signing key arrives only as `secret_ref`, so a field this object does not list is an inline secret and every reader rejects it. - **`secret_ref` (required)**: `string`, pattern: `^[a-z][a-z0-9+.-]*://.+$` Reference to the HMAC-SHA256 key the body is signed with, never the key: `awssm://` under the requesting tenant's own prefix (a credential of kind `webhook`, POST /api/v1/credentials), or `env://NAME` on an ungated local stack. Resolved by the worker that delivers, at delivery. The signature is `Saram-Signature: t=,v1=`, the digest being HMAC-SHA256 over `.` - the scheme Stripe uses - so a receiver verifies the raw bytes it received and refuses a timestamp outside its own tolerance. - **`url` (required)**: `string`, format: `uri` Where the POST goes. `https` only; the control plane refuses a URL naming a loopback, private, link-local or otherwise non-public address, whether written as one or resolved to one at delivery, unless the deployment allows private hosts (a local stack), and then `http` is accepted for a loopback host too. Redirects are not followed. - **`webhook_url`**: `string`, format: `uri` Never read. Reserved since 0.3 and superseded by `webhook` (0.6.0), which carries the signing key a bare URL has no room for; kept so a spec that sets it still validates. - **`files`**: `array`, maxItems: `10` Files the personas bring with them. Bytes never travel in the spec: upload each file with POST /api/v1/files first and name it here by the `ref` that answers. A file belongs to the tenant that uploaded it, so any key or member of that tenant may name it in a run and nobody else may. **Items:** no additional properties One file a persona brings with them: the uploaded file (`ref`), what the persona and the target call it (`name`), what the persona knows about it (`description`), and which sessions carry it (`assign`). - **`description` (required)**: `string`, minLength: `1` What the persona knows about it, in one sentence, briefed verbatim. - **`name` (required)**: `string`, pattern: `^[A-Za-z0-9][A-Za-z0-9 ._-]{0,127}$` What the persona calls it and what the target receives as the filename. Unique within the run. - **`ref` (required)**: `string`, pattern: `^saram-file://[a-z0-9][a-z0-9_-]{0,63}/f_[a-z2-7]{26}$` A file uploaded through POST /api/v1/files by the tenant starting the run: saram-file://\/\, where the owner segment is that tenant's id. A file uploaded before files were a tenant's carries the name of the caller that uploaded it there instead, and only that caller may name it. - **`assign`**: `object`, no additional properties Which sessions carry it: those matching every list given. Absent means every session. - **`missions`**: `array`, minItems: `1` **Items:** `string`, pattern: `^[a-z0-9_]+$` - **`personas`**: `array of string`, minItems: `1` - **`providers`**: `object`, no additional properties Which model, on which provider or router, with which credential, every model call in this run uses. `entries` are named; `default` names the entry every role uses unless `roles` says otherwise; `roles` binds the three callers (the actor loop, the observer's judge for session judgment and run merge, the reproduction model check) to entry names. A role that resolves to no entry is a producer error. When this block is present it wins over `actor.actor_model` and `actor.judge_model`; when absent, those two and the worker's own process credential apply, which is every run written before 0.4.1. Resolved by the worker that makes the call, at the start of its activity, never by the API or a workflow. - **`entries` (required)**: `object`, minProperties: `1` **Additional properties:** [RunSpecProvider](#scalar-schema-runspecprovider) One model on one provider or router, in pydantic-ai's own terms: `model` is `:` and the prefix decides how the rest is read. Closed by design: a credential arrives only as `credential_ref`, so a field this object does not list is an inline secret and every reader rejects it. - **`default`**: `string` Entry name. Absent with exactly one entry means that entry. - **`roles`**: `object`, no additional properties Which entry, by name, each of the run's three model callers uses: `actor`, the model that plays the persona; `judge`, the judge that reads each session and merges the run's findings; `reproduction`, the model check made when an issue is replayed. A role left out uses `default`. - **`actor`**: `string` - **`judge`**: `string` - **`reproduction`**: `string` - **`run_id`**: `string` Assigned by the control plane if absent - **`trigger`**: `object`, no additional properties Who or what started the run, and against which code: a record kept with the run, which changes nothing about what it does but in two places. A run billed to a ChatGPT subscription login is refused when `kind` is `ci`, and `pr` and `commit` are copied onto the artifact's `target`, where `pr` is the pull request the GitHub connector's comment goes on. - **`actor`**: `string` User, token, or CI job that triggered the run - **`commit`**: `string` - **`diff_ref`**: `string` Where the PR diff can be fetched, for diff-aware targeting. Accepted and recorded; nothing reads it yet. - **`kind`**: `string`, possible values: `"manual", "api", "ci"` How the run was started: `manual`, by a person at a keyboard; `api`, through the API or a coding agent's MCP tools, which is what `start_run` writes when the spec leaves it out; `ci`, by a CI job with nobody watching. The caller's own claim, recorded as given. `ci` is refused on a run billed to a ChatGPT subscription login, which serves only runs a person starts. - **`pr`**: `integer` **Example:** ```json { "target": { "entry_url": "https://example.com" }, "personas": [ "dana_first_timer@1" ], "missions": [ { "id": "sign_up", "goal": "create an account", "success_criteria": [ { "kind": "judge_assertion", "value": "the persona reached a signed-in page" } ] } ], "budget": { "steps_per_session": 30 } } ``` ### RunSpecMission **Type:** `object`, no additional properties One thing each persona is sent to do: a goal in the persona's own terms, how to tell it was achieved, and optionally where to start, what the judge should watch for and the question the persona answers at the end. - **`goal` (required)**: `string` Stated in terms a persona would understand, e.g. 'buy one of the blue shirts' - **`id` (required)**: `string`, pattern: `^[a-z0-9_]+$` - **`success_criteria` (required)**: `array`, minItems: `1` What reaching the goal looks like. The persona is never shown these: it is given `goal` and `brief` and decides for itself when it is finished. Only a `url_matches` criterion is checked by machine; every other kind is context for the judge. See `kind`. **Items:** no additional properties One way to tell the goal was reached: what sort of evidence it is (`kind`) and what to look for (`value`). Only a `url_matches` criterion is checked by machine; see `kind`. - **`kind` (required)**: `string`, possible values: `"url_matches", "text_visible", "element_visible", "network_call", "judge_assertion"` What the criterion states and who checks it. `url_matches` is the only kind checked by machine: `value` is part of a URL, e.g. `/order/confirmed`, and when the persona says it is finished the session ends with `end_reason` `success_criteria_met` if `value` occurs in the last URL the browser was on, or `claimed_finished_unverified` if it does not (always, for a mission with no `url_matches` criterion). Nothing checks the other four: the judge that reads each session afterwards is shown every criterion as text, `=`, beside the mission's goal, and nothing decides whether one was met. `text_visible`: text the page shows once the goal is reached. `element_visible`: an element on that page, in words or as a selector. `network_call`: a request the product makes when the goal is reached, e.g. `POST /api/orders`. `judge_assertion`: a plain-language statement about how the session ended, e.g. `the persona reached a signed-in page`. For a verified ending, give a mission a `url_matches` criterion beside any of these. - **`value` (required)**: `string` Read according to `kind`: part of a URL for `url_matches`, otherwise text for the judge. - **`authored_by`**: `string`, possible values: `"human", "doppelganger", "planner"`, default: `"human"` Who wrote the mission: `human`, the default, for one a person wrote; `doppelganger` and `planner` for one an agent wrote on someone's behalf. A label only: it is kept with the run and changes nothing about how the mission is run. - **`brief`**: `string` Optional context from the mission author to the persona (what they were told before starting) - **`entry_url`**: `string`, format: `uri` Overrides target.entry\_url - **`name`**: `string` - **`verdict_prompt`**: `string` The question about THIS product that the persona answers in their final diary entry, asked after the persona's own `persona.verdict_prompt`. Set it whenever the personas were written for a different product than the one under test: a roofing persona asked 'is this going to bring you roofing jobs' has nothing to answer with after a shirt-shop session, and the verdict comes back null. When present, this is the question the judge answers and `persona.verdict_prompt` is not applicable for the run. - **`watch_list`**: `array of string` Specific concerns the judge should look for, e.g. 'is the new discount widget discoverable'. The only place free opinions may be solicited: the persona itself is never asked to critique the product. **Generated example:** ```json { "id": "", "name": "", "entry_url": "", "goal": "", "success_criteria": [ { "kind": "url_matches", "value": "" } ], "watch_list": [ "" ], "authored_by": "human", "brief": "", "verdict_prompt": "" } ``` ### RunSpecProvider **Type:** `object`, no additional properties One model on one provider or router, in pydantic-ai's own terms: `model` is `:` and the prefix decides how the rest is read. Closed by design: a credential arrives only as `credential_ref`, so a field this object does not list is an inline secret and every reader rejects it. - **`model` (required)**: `string`, pattern: `^[a-z][a-z0-9-]*:.+$` pydantic-ai model string: anthropic:claude-opus-5, bedrock:us.anthropic.claude-opus-5, openai:gpt-5.6, openrouter:anthropic/claude-opus-5, azure:, google:gemini-3-pro, google-cloud:gemini-3-pro, litellm:. - **`api_version`**: `string` azure: the Azure OpenAI api-version. Absent means OPENAI\_API\_VERSION on the worker. - **`base_url`**: `string`, format: `uri` Endpoint override: a gateway (LiteLLM, a private OpenAI-compatible URL), an Azure resource endpoint, a Bedrock VPC endpoint. Absent means the provider's public endpoint. - **`credential_ref`**: `string`, pattern: `^[a-z][a-z0-9+.-]*://.+$` Reference to the credential, never the credential: `env://NAME`, `awssm://`. Absent means the provider's ambient credential (IRSA or instance role on bedrock, Application Default Credentials on google-cloud); for prefixes that have no ambient path, absent is a producer error at resolution. One secret holds one string; what the string is (API key, bearer token, service-account JSON) is fixed by the prefix. - **`location`**: `string` google-cloud: the Vertex location, e.g. us-central1 or global. - **`project`**: `string` google-cloud: the GCP project. Absent means the one Application Default Credentials carry. - **`region`**: `string` bedrock: the AWS region the model is invoked in. Absent means AWS\_REGION / AWS\_DEFAULT\_REGION on the worker. **Generated example:** ```json { "model": "", "credential_ref": "", "base_url": "", "region": "", "project": "", "location": "", "api_version": "" } ```