---
name: jenna-api
description: Drive Jenna — autonomous AI UX audits of web apps — from scripts and agents. Create projects and missions, run audits, poll them to completion, read the video and reports, and publish public share links.
---

# Jenna API

Jenna is an autonomous UX auditor. You give it a URL and an objective in plain
language; an AI agent opens the site in a real browser, tries to accomplish the
objective the way a chosen persona would, and produces:

- a **narrated video** of the whole session,
- a **UX report** scoring the experience and listing friction points,
- a **run log** with a screenshot at every step.

You drive all of it over HTTP. This document is the complete contract.

**Base URL:** `https://jenna.haredev.com/api`

---

## 1. Authentication

Every call carries a long-lived API key in the `X-API-Key` header:

```bash
curl -s https://jenna.haredev.com/api/jenna/projects \
  -H "X-API-Key: $JENNA_API_KEY"
```

Keys look like `jna_live_` followed by 43 URL-safe characters. The user creates
them in **Settings → API keys** in the Jenna app; the plaintext is shown exactly
once, at creation, and cannot be recovered afterwards. Store it as a secret
(environment variable, secret manager) — never in source control.

A missing, unknown, malformed or revoked key gets a flat `401`. Nothing in the
response tells you which of those it was.

**What an API key can do:** everything under `/jenna/*` (projects, missions,
runs, sharing) plus `GET /billing/balance`.

**What it deliberately cannot do:** change the profile, delete the account, log
in, buy credit packs, or manage API keys. Those need an interactive session. If
you get a `401` on such a route, that is by design — do not try to work around
it, ask the user to do it in the app.

---

## 2. Object model

```
Project ──< Mission ──< Run ──< RunLog
```

- **Project** — a named container, and the unit of public sharing.
- **Mission** — one repeatable audit scenario: a URL, an objective, a persona, a
  mode, a video format.
- **Run** — one execution of a mission. Creating a mission automatically creates
  and submits run #1. Re-executing creates run #2, and so on.
- **RunLog** — the step-by-step trace of a run, screenshots included.

---

## 3. Mission fields

| Field | Required | Values | Meaning |
| --- | --- | --- | --- |
| `name` | yes | 1–200 chars | Label shown in the app |
| `url` | yes | `http://…` or `https://…` | Entry point the agent opens |
| `objective` | no | free text, no size limit; may name the agent's built-in actions (CLICK, TYPE, SCROLL, WAIT, READ_PAGE_TEXT for exact text of long pages, GET_SECRET, SEARCH/READ_KNOWLEDGE, FINISH) | What the agent must try to accomplish. Omit it and Jenna infers a sensible goal from the mode. Long objectives are fine: the agent fetches the objective from the API at start rather than receiving it inline. |
| `persona` | yes | `Expert` \| `Novice` \| `Administrator` | Who the agent pretends to be. `Novice` surfaces onboarding friction; `Expert` surfaces efficiency problems; `Administrator` targets back-office flows. |
| `mode` | yes | `walkthrough` \| `check_functionality` \| `create_test` \| `generate_documentation` \| `discovery` | What kind of audit to run. See below. |
| `format` | yes | `16:9` \| `9:16` | Video aspect ratio. `9:16` for social/mobile. |
| `maxDifficulty` | no | integer `0`–`10` | How much **human-perceived** friction a single step may show before the run is aborted and reported as `FAILED`. Omit to keep the default (`3`; `discovery` always uses `10`). Raise it if you want the agent to push through rough pages instead of stopping. |
| `maxRetriesPerStep` | no | integer `1`–`10` | How many times Jenna retries interacting with a step before giving up and reporting `automation_blocked`. Omit to keep the account default (`4`). This is a technical limit on our side, never a finding about the site. |
| `noVoice` | no | boolean (default `false`) | When true the video has no narration — cheaper, and roughly halves the cost of a batch of audits. |
| `ttsEco` | no | boolean (default `false`) | When true, narration is synthesized only for runs worth narrating — completed journeys and aborts that carry defect evidence. Cheaper for large batches where most runs are discarded. |
| `hideSubtitles` | no | boolean (default `true`) | When false, burned-in subtitles are shown on the video. |
| `variableName` | no | `^[A-Za-z0-9_-]{1,40}$`, or empty/null to clear | One variable per mission, referenced in the objective as `$<name>` (e.g. `$student`). Setting it requires at least one entry in `variableValues`. |
| `variableValues` | no | array of strings | The cohort to fan out over — one run per value, each with `$<variableName>` in the objective replaced by that value. Trimmed, deduped, empties dropped. Switching cohort = sending a new `variableValues` array on update (the existing `variableName` is kept if you omit it). |
| `highEffort` | no | boolean (default `false`) | When true, runs use a stronger Gemini model (env `JENNA_HIGH_EFFORT_MODEL`, default `gemini-3.5-flash`, vs. the normal `JENNA_DEFAULT_MODEL`, default `gemini-3.5-flash-lite`) and are billed at `JENNA_HIGH_EFFORT_MULTIPLIER`× the recording minutes (default `2.5`) instead of 1×. Use it for tougher missions where the cheaper model misses things; off by default because it costs more. |
| `maxSteps` | no | positive integer, or null to clear | Agent-step budget for the run. Omit to use the resolved default: the normal budget `JENNA_MAX_STEPS` (else the engine's `99`), or the high-effort budget `JENNA_HIGH_EFFORT_MAX_STEPS` (default `250`) when `highEffort` is true. Set an explicit value to override both — it is used verbatim, with no fixed upper cap (the wall-clock and no-progress watchdogs remain the real safety nets). |

Modes:

- `walkthrough` — narrated tour of the flow, from the persona's point of view.
  The default choice for a UX audit.
- `check_functionality` — verify that the flow actually works, and report what
  breaks.
- `create_test` — derive a reusable end-to-end test scenario from the flow.
- `generate_documentation` — produce user-facing documentation of the flow.
- `discovery` — no fixed objective: explore the site and report what it does and
  where it hurts. Best first mission on an unfamiliar site.

Every field in the table above is accepted by both the create and the update
bodies (all optional on update). Anything **not** in the table is silently
dropped from the request body, so a payload that "looks accepted" may not have
applied what you intended — only send the fields listed above.

An audit can stop early for several very different reasons, and the distinction
matters — only the first is a statement about the site:

- `maxDifficulty` caps what a **human visitor** would struggle with (visual
  prominence, label clarity, touch-target size, contrast, whether the next step
  is discoverable). Tripping it is a **real audit finding**: the page really is
  too hard for a person. The run ends `FAILED` with `errorMessage` starting
  `ux_difficulty_exceeded:`.
- `maxRetriesPerStep` caps how many times Jenna retries interacting with a
  single step. Tripping it means **we** could not drive the page — it says
  nothing about the merchant's UX and must never be turned into outreach copy.
  The run ends `FAILED` with `errorMessage` starting `automation_blocked:`.
- `blocked_by_site:` means the site answered our very first request with
  401/403/429/5xx — bot protection, a WAF or a CDN refusing our datacentre IP.
  The audit never started: no LLM call, **no UX finding and no score**. This says
  nothing about the merchant's UX (their customers reach the shop normally), and
  it must never be turned into outreach copy. Permanently non-retryable from the
  same network.
- `run_timeout:` means the run exceeded its wall-clock budget (`maxRunMinutes`,
  default 45) or completed no step for five minutes. Ours, not theirs.

In every case the video and reports produced up to that point are still returned
(`blocked_by_site` produces none, by design); the journey simply did not finish.
Read `errorMessage` to tell them apart.

Both limits fall back to the account defaults configured in the app settings
(Settings -> Audit limits) when the mission does not set them, and then to the
system defaults (`3` and `4`).

---

## 4. The full flow

### 4.1 Create a project

```bash
curl -s -X POST https://jenna.haredev.com/api/jenna/projects \
  -H "X-API-Key: $JENNA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Shop audits Q3","description":"Checkout funnel review"}'
```

```json
{
  "id": "0f4b2c1e-…",
  "name": "Shop audits Q3",
  "description": "Checkout funnel review",
  "shareToken": null,
  "createdAt": "2026-08-04T09:12:00.000Z"
}
```

Keep the `id`.

### 4.2 Create a mission — this already starts run #1

```bash
curl -s -X POST \
  https://jenna.haredev.com/api/jenna/projects/$PROJECT_ID/missions \
  -H "X-API-Key: $JENNA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "Checkout as a first-time buyer",
        "url": "https://example-shop.com",
        "objective": "Find a pair of running shoes in size 42, add them to the basket and reach the payment step.",
        "persona": "Novice",
        "mode": "walkthrough",
        "format": "16:9"
      }'
```

```json
{
  "mission": { "id": "8ac1…", "name": "Checkout as a first-time buyer", "…": "…" },
  "run":     { "id": "b73f…", "status": "SUBMITTED", "runNumber": 1 },
  "runs":    [ { "id": "b73f…", "status": "SUBMITTED", "runNumber": 1, "variableValue": null } ],
  "count":   1
}
```

**There is no separate "start" call.** Creating the mission creates run #1 and
submits it to the execution backend. Take `run.id` and go straight to polling.

**A mission created with `variableName` set fans out the same way
re-execute does**: creation starts one run per entry in `variableValues`
instead of one, so `runs` may come back with more than one element —
`count` is `runs.length`, and `run` is always `runs[0]` (kept for backward
compatibility with callers that only read `.run`). Each run's
`variableValue` says which entry it ran with; a mission with no variable
always gets exactly one run, with `variableValue: null` (unchanged from
before this feature).

To run the same mission again later:

```bash
curl -s -X POST \
  https://jenna.haredev.com/api/jenna/missions/$MISSION_ID/re-execute \
  -H "X-API-Key: $JENNA_API_KEY"
```

```json
{
  "run":   { "id": "c91a…", "status": "SUBMITTED", "runNumber": 2, "variableValue": null },
  "runs":  [ { "id": "c91a…", "status": "SUBMITTED", "runNumber": 2, "variableValue": null } ],
  "count": 1
}
```

**Missions with `variableName` set fan out**: re-execute starts one run per
entry in `variableValues` instead of one, so `runs` may come back with more
than one element — `count` is `runs.length`, and `run` is always `runs[0]`
(kept for backward compatibility with callers that only read `.run`). Each
run's `variableValue` says which entry it ran with; a mission with no
variable always gets exactly one run, with `variableValue: null` (unchanged
from before this feature).

### 4.3 Poll the run until it finishes

```bash
curl -s https://jenna.haredev.com/api/jenna/runs/$RUN_ID \
  -H "X-API-Key: $JENNA_API_KEY"
```

```json
{
  "id": "b73f…",
  "missionId": "8ac1…",
  "status": "RUNNING",
  "runNumber": 1,
  "currentStep": 14,
  "maxSteps": 60,
  "uxScore": null,
  "videoUrl": null,
  "reportUrl": null,
  "uxReportUrl": null,
  "errorMessage": null,
  "startedAt": "2026-08-04T09:12:30.000Z",
  "completedAt": null
}
```

`status` moves through:

```
SUBMITTED → PENDING → RUNNABLE → STARTING → RUNNING → SUCCEEDED
                                                   └→ FAILED
```

`SUCCEEDED` and `FAILED` are terminal. Everything else means "keep waiting".

**Runs are asynchronous and take 5 to 40 minutes.** A short landing-page
walkthrough finishes near the low end; a deep `discovery` run on a large site
near the high end. Do not hold a request open waiting for one, and do not assume
a run that is still `RUNNING` after ten minutes is stuck.

Polling cadence that respects the rate limit and still feels responsive:

- first 2 minutes: every **15 s** (catches immediate submission failures),
- then: every **30 s**,
- after 10 minutes: every **60 s**,
- give up after **50 minutes** and report the run as timed out — the run itself
  keeps going server-side and can be read later.

`currentStep` / `maxSteps` give you a live progress bar. For a step-by-step
trace with screenshots:

```bash
curl -s "https://jenna.haredev.com/api/jenna/runs/$RUN_ID/logs?limit=200" \
  -H "X-API-Key: $JENNA_API_KEY"
```

### 4.4 Read the artifacts

Once `status` is `SUCCEEDED`, the same run object carries the results:

| Field | What it is |
| --- | --- |
| `videoUrl` | MP4 of the narrated session |
| `reportUrl` | HTML report of what the agent did |
| `uxReportUrl` | HTML UX audit: findings, severity, recommendations |
| `uxScore` | Overall score, 0–100 |
| `cost` | Provider cost of the run |
| `usage` | `{ inputTokens, outputTokens, ttsCharacters }` |

They are public CDN URLs — hand them to the user directly, no auth header
needed. Any of them may be `null` if that artifact was not produced (for example
`videoUrl` on a run that failed halfway).

If `status` is `FAILED`, read `errorMessage`. A failed run still consumed the
minutes it burned before failing.

### 4.5 Publish a public share link

```bash
curl -s -X POST \
  https://jenna.haredev.com/api/jenna/projects/$PROJECT_ID/share \
  -H "X-API-Key: $JENNA_API_KEY"
```

```json
{ "shareToken": "kQ8vX2r…" }
```

Build the URL the human opens:

```
https://jenna.haredev.com/app/share/<shareToken>
```

Anyone with that link can browse the project, its missions, every run, the
videos and the reports — no account required. Owner identity, billing and
provider internals are stripped from the public view.

The call is idempotent: calling it again returns the same token. To revoke:

```bash
curl -s -X DELETE \
  https://jenna.haredev.com/api/jenna/projects/$PROJECT_ID/share \
  -H "X-API-Key: $JENNA_API_KEY"
```

---

## 5. Credits — check before you batch

Jenna bills **recording minutes**, prepaid. A run consumes minutes equal to its
own duration, debited once when it settles. Minutes never expire.

```bash
curl -s https://jenna.haredev.com/api/jenna/usage \
  -H "X-API-Key: $JENNA_API_KEY"
```

```json
{ "balanceMinutes": 87.5 }
```

`GET /billing/balance` returns the same figure.

When the balance is exhausted, every call that would start a run — creating a
mission, re-executing one, submitting a run — is rejected with **HTTP 402**:

```json
{ "error": "INSUFFICIENT_CREDITS", "message": "Out of recording minutes" }
```

The gate is server-authoritative and checked at submission time, so a long batch
can start with credit and hit `402` partway through.

**Rules for an agent:**

1. Read the balance before starting a batch. Budget roughly **20–30 minutes per
   run** as a planning figure.
2. On `402`, stop the batch immediately. Do not retry — retrying cannot succeed.
   Report to the user how many missions completed, which are left, and that they
   need to top up in **Settings → Top up** in the app. You cannot buy credits
   through the API; that route is session-only on purpose.

---

## 5b. Autopilot — routines, secrets, advanced knowledge

Three per-mission features are reserved to accounts with an active **Autopilot**
subscription. All three are driven with the same API key as everything else;
the gate is server-side: without a subscription the write calls return
**HTTP 403** ("This is an Autopilot feature — an active subscription is
required"). Read calls always work. Check first:

```bash
curl -s https://jenna.haredev.com/api/billing/subscription \
  -H "X-API-Key: $JENNA_API_KEY"
# { "active": true, "subscription": { "status": "active", ... } }
```

You cannot subscribe through the API (session-only, like buying credits): on a
`403`, tell the user to subscribe in the app under **Settings → Autopilot**.

### Routine — run a mission on a schedule

A routine launches a **fresh** run of the mission every N hours (1–168, whole
hours; never a replay). Set it on the mission; `null` clears it. Each run
consumes minutes exactly like a manual one and is skipped when the balance is
empty or the subscription lapses.

```bash
curl -s -X PATCH https://jenna.haredev.com/api/jenna/missions/$MISSION_ID \
  -H "X-API-Key: $JENNA_API_KEY" -H "Content-Type: application/json" \
  -d '{ "routineEveryHours": 24 }'
# → mission with "routineEveryHours": 24, "routineNextRunAt": "<ISO date>"
```

### Secrets — credentials the mission may use

A secret is a named value (a login password, an API key…) the agent can use
during a run. It is encrypted with AWS KMS at rest, decrypted only inside the
run's container, and masked in every log, video and report. **Values are
write-only**: no endpoint ever returns them; listing shows names only.

```bash
# create or replace (name: letters/digits/underscore, ≤64 chars; value ≤8000 chars)
curl -s -X POST https://jenna.haredev.com/api/jenna/missions/$MISSION_ID/secrets \
  -H "X-API-Key: $JENNA_API_KEY" -H "Content-Type: application/json" \
  -d '{ "name": "login_password", "value": "…" }'
# list names → [{ "id", "name", "createdAt", "updatedAt" }]
curl -s https://jenna.haredev.com/api/jenna/missions/$MISSION_ID/secrets -H "X-API-Key: $JENNA_API_KEY"
# delete
curl -s -X DELETE https://jenna.haredev.com/api/jenna/missions/$MISSION_ID/secrets/$SECRET_ID -H "X-API-Key: $JENNA_API_KEY"
```

Then tell the agent about it **in the objective**, by name, e.g.
"Log in as tomsmith; take the password from the mission secret `login_password`."
During the run the agent retrieves it with its `GET_SECRET` tool and types it;
the value never appears in the artifacts (you will see `••••••` instead).

### Advanced knowledge — reference material for the run

Longer, mission-specific context than the objective allows: documentation,
product details, navigation hints, business rules, marking criteria. Each entry
is chunked (~1 200 chars) and embedded. Plain text or Markdown, ≤200 000 chars
per entry, any number of entries.

How the agent sees it, at run start, in its cached prompt prefix:
- a **small corpus** (≤6 000 chars in total) travels whole;
- a **large corpus** travels as an **index**: every entry, then one line per
  passage — its key `<entry>#<n>` and a headline. During the run the agent
  fetches passages **at any step** with two actions: `READ_KNOWLEDGE {key}`
  (an exact passage, with prev/next keys) and `SEARCH_KNOWLEDGE {query}`
  (top passages by similarity). Results arrive on the next step and stay
  visible for a few steps. Retrieval is driven by what the current step needs,
  not by a one-shot guess against the objective.

Write entries for that: one entry per topic with a clear title (the title
becomes the entry's slug and the prefix of every key), and passages that open
with a descriptive line (it becomes the index headline). Sections of ~1 000
chars that each stand alone read best.

```bash
curl -s -X POST https://jenna.haredev.com/api/jenna/missions/$MISSION_ID/knowledge \
  -H "X-API-Key: $JENNA_API_KEY" -H "Content-Type: application/json" \
  -d '{ "title": "Checkout rules", "content": "Promo codes are entered on the cart page…" }'
# list → [{ "id", "title", "preview", "contentLength", ... }]   delete → DELETE …/knowledge/$KNOWLEDGE_ID

# Preview what the agent would get back — same lookup the run performs:
curl -s -X POST https://jenna.haredev.com/api/jenna/missions/$MISSION_ID/knowledge/lookup \
  -H "X-API-Key: $JENNA_API_KEY" -H "Content-Type: application/json" \
  -d '{ "query": "how are promo codes applied" }'          # or { "key": "checkout-rules#0" }
# → { "kind": "search", "results": [{ "key", "title", "seq", "passages", "content", "prevKey?", "nextKey?" }] }
```

### Other mission settings (no subscription needed)

The same `PATCH /jenna/missions/:missionId` accepts `objective`, `mode`
(`walkthrough` or `discovery`), `noVoice` (skip narration), `ttsEco`,
`hideSubtitles`, `watermark`, `maxDifficulty`, `maxRetriesPerStep`,
`highEffort` (stronger model, 2.5x minutes — see the mission fields table above).

---

## 6. Rate limits

The API allows **100 requests per minute** per client. A `429` means you exceeded
it: back off exponentially, starting at 5 s.

Poll intervals below 10 s are wasteful — run status changes on the order of
minutes, not seconds — and will exhaust the budget on a batch of any size.

---

## 7. Errors

| Status | Meaning | What to do |
| --- | --- | --- |
| `400` | Malformed body; the message names the offending field | Fix the payload. Do not retry unchanged. |
| `401` | Missing, invalid or revoked API key — or a session-only route | Check the key; if the route is session-only, ask the user. |
| `403` | Belongs to someone else — or an Autopilot feature without a subscription | Do not retry; for Autopilot, ask the user to subscribe in the app. |
| `404` | No such project / mission / run | Do not retry. |
| `402` | `INSUFFICIENT_CREDITS` | Stop the batch, tell the user to top up. |
| `429` | Rate limited | Exponential backoff from 5 s. |
| `5xx` | Transient server-side | Retry up to 3 times with backoff. |

---

## 8. Endpoint reference

All paths are relative to `https://jenna.haredev.com/api`.

| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/jenna/usage` | Recording-minutes balance |
| `POST` | `/jenna/projects` | Create a project |
| `GET` | `/jenna/projects` | List your projects |
| `GET` | `/jenna/projects/:projectId` | Get one project |
| `PATCH` | `/jenna/projects/:projectId` | Rename / update a project |
| `DELETE` | `/jenna/projects/:projectId` | Delete a project and everything under it |
| `POST` | `/jenna/projects/:projectId/share` | Enable sharing, returns `shareToken` |
| `DELETE` | `/jenna/projects/:projectId/share` | Disable sharing |
| `POST` | `/jenna/projects/:projectId/missions` | Create a mission **and start run #1** — one run per `variableValues` entry if the mission has a variable, else one. Returns `{ mission, run, runs, count }` (`run` is `runs[0]`). |
| `GET` | `/jenna/projects/:projectId/missions` | List missions of a project |
| `GET` | `/jenna/missions/:missionId` | Get one mission |
| `PATCH` | `/jenna/missions/:missionId` | Update a mission |
| `POST` | `/jenna/missions/:missionId/duplicate` | Copy a mission into its project (optional `name`/`url`/`objective` overrides; secrets + knowledge copied, routine not; **no run started**) |
| `DELETE` | `/jenna/missions/:missionId` | Delete a mission and its runs |
| `POST` | `/jenna/missions/:missionId/re-execute` | Start a new run of a mission — one per `variableValues` entry if the mission has a variable, else one. Returns `{ run, runs, count }` (`run` is `runs[0]`). |
| `GET` | `/jenna/missions/:missionId/runs` | List runs of a mission |
| `GET` | `/jenna/runs/:runId` | **Run status and artifacts — the polling endpoint** |
| `GET` | `/jenna/runs/:runId/logs` | Step-by-step logs with screenshots |
| `GET` | `/billing/balance` | Same balance, billing namespace |
| `GET` | `/billing/subscription` | Autopilot subscription status |
| `PATCH` | `/jenna/missions/:missionId` | Also sets `routineEveryHours` (Autopilot) |
| `GET` | `/jenna/missions/:missionId/secrets` | List secret names (never values) |
| `POST` | `/jenna/missions/:missionId/secrets` | Create/replace a secret (Autopilot) |
| `DELETE` | `/jenna/missions/:missionId/secrets/:secretId` | Delete a secret |
| `GET` | `/jenna/missions/:missionId/knowledge` | List knowledge entries |
| `POST` | `/jenna/missions/:missionId/knowledge` | Add a knowledge entry (Autopilot) |
| `POST` | `/jenna/missions/:missionId/knowledge/lookup` | Preview a READ (key) or SEARCH (query) lookup |
| `DELETE` | `/jenna/missions/:missionId/knowledge/:knowledgeId` | Delete a knowledge entry |

---

## 9. Worked example — audit a list of shops

Audit the checkout funnel of ten shops, gather the reports, publish one share
link for the whole batch. This is the shape to copy for any batch job.

```bash
#!/usr/bin/env bash
set -euo pipefail

API="https://jenna.haredev.com/api"
AUTH=(-H "X-API-Key: $JENNA_API_KEY" -H "Content-Type: application/json")

SHOPS=(
  "https://shop-one.example.com"
  "https://shop-two.example.com"
  # … eight more
)

# --- 0. Never start a batch you cannot pay for -------------------------------
BALANCE=$(curl -s "$API/jenna/usage" -H "X-API-Key: $JENNA_API_KEY" | jq -r .balanceMinutes)
NEEDED=$(( ${#SHOPS[@]} * 25 ))          # ~25 min per run, planning figure
echo "balance=$BALANCE min, batch needs ~$NEEDED min"
if (( $(echo "$BALANCE < $NEEDED" | bc -l) )); then
  echo "Not enough recording minutes. Ask the user to top up before running this."
  exit 1
fi

# --- 1. One project for the whole batch --------------------------------------
PROJECT_ID=$(curl -s -X POST "$API/jenna/projects" "${AUTH[@]}" \
  -d '{"name":"Shop checkout audits"}' | jq -r .id)

# --- 2. Create missions in small waves ---------------------------------------
# Concurrency 3: enough to keep the backend busy, low enough that a 402 or a
# broken URL costs you at most three runs before you notice.
RUN_IDS=()
for url in "${SHOPS[@]}"; do
  body=$(jq -nc --arg url "$url" '{
    name:      ("Checkout — " + $url),
    url:       $url,
    objective: "Add any product to the basket and reach the payment step. Report every point of friction.",
    persona:   "Novice",
    mode:      "walkthrough",
    format:    "16:9"
  }')

  resp=$(curl -s -w '\n%{http_code}' -X POST \
    "$API/jenna/projects/$PROJECT_ID/missions" "${AUTH[@]}" -d "$body")
  code=$(tail -n1 <<<"$resp"); json=$(sed '$d' <<<"$resp")

  if [ "$code" = "402" ]; then
    echo "Out of recording minutes after ${#RUN_IDS[@]} missions. Stopping — top up and resume."
    break
  fi
  if [ "$code" != "201" ]; then
    echo "Skipping $url: HTTP $code — $(jq -r '.message // empty' <<<"$json")"
    continue
  fi

  RUN_IDS+=("$(jq -r .run.id <<<"$json")")

  # Wave of 3, then let them run before queueing more.
  if (( ${#RUN_IDS[@]} % 3 == 0 )); then sleep 60; fi
done

# --- 3. Poll every run to a terminal state -----------------------------------
# Widening interval: 15s early, 30s mid, 60s late. Cap at 50 minutes.
declare -A DONE
START=$(date +%s)
while (( ${#DONE[@]} < ${#RUN_IDS[@]} )); do
  elapsed=$(( $(date +%s) - START ))
  (( elapsed > 3000 )) && { echo "Timed out waiting; runs continue server-side."; break; }

  for run_id in "${RUN_IDS[@]}"; do
    [ -n "${DONE[$run_id]:-}" ] && continue
    run=$(curl -s "$API/jenna/runs/$run_id" -H "X-API-Key: $JENNA_API_KEY")
    status=$(jq -r .status <<<"$run")
    case "$status" in
      SUCCEEDED)
        DONE[$run_id]=ok
        jq -r '"✓ score=\(.uxScore)  ux=\(.uxReportUrl)  video=\(.videoUrl)"' <<<"$run"
        ;;
      FAILED)
        DONE[$run_id]=failed
        jq -r '"✗ failed: \(.errorMessage)"' <<<"$run"
        ;;
      *)
        jq -r '"… \(.status) step \(.currentStep)/\(.maxSteps // "?")"' <<<"$run"
        ;;
    esac
  done

  if   (( elapsed < 120 )); then sleep 15
  elif (( elapsed < 600 )); then sleep 30
  else                           sleep 60
  fi
done

# --- 4. One public link for the whole batch ----------------------------------
TOKEN=$(curl -s -X POST "$API/jenna/projects/$PROJECT_ID/share" \
  -H "X-API-Key: $JENNA_API_KEY" | jq -r .shareToken)
echo "Report: https://jenna.haredev.com/app/share/$TOKEN"
```

What makes this batch well-behaved, and what you should reproduce:

1. **Check the balance first**, and refuse to start rather than fail halfway.
2. **Concurrency of 3, not 10.** Runs are long and expensive; a small wave keeps
   a bad URL or an empty balance from burning the whole budget.
3. **A widening poll interval**, never tighter than 15 s. Ten runs polled every
   5 s would exhaust the 100 req/min budget on their own.
4. **`402` stops the batch**, it never retries.
5. **A hard timeout** that reports rather than hangs — the runs survive on the
   server and can be read later with the same run ids.
6. **One share link at the end**, so the human gets a single URL instead of
   thirty artifact links.

---

## 10. Checklist before you report back to a human

- Every run reached `SUCCEEDED` or `FAILED`, or you said explicitly which are
  still running and how to check them.
- For each `FAILED` run, you quoted `errorMessage`.
- You gave the share URL, not a wall of raw artifact links.
- If you hit `402`, you said how many minutes are left and that topping up
  happens in the app under **Settings → Top up**.
- You never printed the API key.
