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

## Effective servers

- `https://api.saram.sh`

  Saram

## Authentication

- **HTTPBearer**: HTTP bearer

  a Saram API key (`sk_saram_<env>_...`) or a person's AuthKit access token, as `Authorization: Bearer <credential>`. Both are told apart by shape and produce the same principal.

## Request body

**Required:** `true`

**Content type:** `application/json`

schema: `RunSpec`, 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`, minItems: `1`

  **Items:**

  schema: `RunSpecMission`, 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, `<kind>=<value>`, 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.
- **`personas` (required)**: `array`, minItems: `1`

  **Items:**

  **One of:**
  - `string`

    persona id\@version from the registry, e.g. novice\_mobile\_skimmer\@2
  - `object`, schema: `Persona`, 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)**: `object`, schema: `ActionPolicy`, 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
    - **`constraints` (required)**: `object`, schema: `Constraints`, 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.
    - **`goals` (required)**: `object`, schema: `Goals`, 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`
    - **`id` (required)**: `string`, pattern: `^[a-z0-9_]+$`
    - **`priors` (required)**: `object`, schema: `Priors`, 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.
    - **`tolerance` (required)**: `object`, schema: `Tolerance`, 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`
    - **`version` (required)**: `integer`, minimum: `1`
    - **`backstory`**: `string | null`
    - **`conversation`**: `object | null`, schema: `Conversation`, 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`
    - **`facts`**: `object | null`

      **Additional properties:**

      `string`
    - **`name`**: `string | null`
    - **`pack`**: `string | null`
    - **`provenance`**: `object | null`, schema: `Provenance`, 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`
    - **`tags`**: `(array of string) | null`
    - **`verdict_prompt`**: `string | null`
    - **`vocabulary`**: `object | null`, schema: `Vocabulary`, 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`
    - **`voice`**: `object | null`, schema: `Voice`, 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.
- **`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://<secret name>` 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://<secret name>` 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=<unix seconds>,v1=<hex>`, the digest being HMAC-SHA256 over `<t>.<raw body>` - 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://\<tenant\_id>/\<file\_id>, 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:**

    schema: `RunSpecProvider`, no additional properties

    One model on one provider or router, in pydantic-ai's own terms: `model` is `<provider>:<model>` 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://<secret name>`. 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.
  - **`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
  }
}
```

## Responses

### 202 The run is accepted and has started.

**Content type:** `application/json`

schema: `StartedRun`, 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.

**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`

schema: `PaymentRequired`, 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`**: `object | null`, schema: `MonthlyCap`, no additional properties

  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.
  - **`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.
- **`usage_ceiling`**: `object | null`, schema: `UsageCeiling`, no additional properties

  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.
  - **`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.

**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`

schema: `HTTPValidationError`

- **`detail`**: `array`

  **Items:**

  schema: `ValidationError`
  - **`loc` (required)**: `array of (string | integer)`
  - **`msg` (required)**: `string`
  - **`type` (required)**: `string`
  - **`ctx`**: `object`
  - **`input`**: `any`

**Example:**

```json
{
  "detail": [
    {
      "loc": [
        ""
      ],
      "msg": "",
      "type": "",
      "input": null,
      "ctx": {}
    }
  ]
}
```

## Schemas

### 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` — shown above.

### 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`.

- `Constraints` — shown above.
- `Conversation` — shown above.

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

### 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

### Exploration

**Type:** `string`, possible values: `"explores", "beeline"`

Wanders vs. heads straight for the goal

- `Goals` — shown above.
- `HTTPValidationError` — shown above.

### 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

- `MonthlyCap` — shown above.

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

- `PaymentRequired` — shown above.
- `Persona` — shown above.

### Phrasing

**Type:** `string`, possible values: `"plain_speech", "commands", "mixed"`

Asks like they would ask a person, vs. writes instructions

- `Priors` — shown above.

### 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` — shown above.

### Reading

**Type:** `string`, possible values: `"reads", "skims", "ignores"`

How much on-screen text they consume

- `StartedRun` — shown above.

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

- `Tolerance` — shown above.

### TrustDefaults

**Type:** `string`, possible values: `"low", "medium", "high"`

Accepts prefilled/default choices vs. checks everything

- `UsageCeiling` — shown above.
- `ValidationError` — shown above.

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

- `Vocabulary` — shown above.
- `Voice` — shown above.

### RunSpec

**Type:** `object`, no additional properties

Input to the control plane. A run = target x persona set x missions x budget x baseline.

*Schema `RunSpec` is shown above.*

**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` — shown above.
- `RunSpecProvider` — shown above.
