# ReTibe MCP — a guide for Claude Code

Paste this file (or a link to it) into a Claude Code thread and it will connect to
the ReTibe platform and write you automated tests.

Russian original: `docs/CLAUDE_MCP_GUIDE.md`. The two are kept in step; where they
disagree, the Russian one was edited first.

---

## 1. Connecting

```bash
claude mcp add --transport http retibe https://retibe.com/mcp \
  --header "Authorization: Bearer YOUR_TOKEN"
```

Issue a token in your profile at https://retibe.com/profile → "API tokens". It is
shown once and cannot be recovered. Revoking is in the same place and takes effect
immediately.

**Two independent instances.** There is also `https://retibe.ru/mcp` — the same
code and the same set of tools, but a **separate database**: its own accounts, its
own scenarios, its own quota. A token from one does not work on the other; you get
`Invalid, revoked or expired`. Issue the token where your scenarios live.

To check the connection, ask Claude to call `retibe_whoami`. It should return your
email, your plan and what is left of your quotas.

---

## 2. How this differs from Playwright MCP

Worth understanding before you start, or your expectations will part company with
reality.

| | Playwright MCP | ReTibe MCP |
|---|---|---|
| How it works | drives a live browser | writes a scenario, runs it, reads the analysis |
| Sees the page between steps | yes | **no** |
| What survives the session | nothing | a saved scenario, a report, run history |
| Running it again | explain it again | one call by id |
| Where it executes | locally | on the platform, in its browsers |

**The upshot:** Playwright MCP is good for reconnaissance — "look at this page,
click around". ReTibe is good for pinning things down — "this path must always
work, check it on every release".

They are not competitors. The working combination is: scout with Playwright, pin it
down in ReTibe.

If Claude does not know your site's markup, have it look at the page first by
whatever means it has (Playwright MCP, WebFetch, or just open DevTools and hand it
the selectors). Guessing selectors blind is something it does badly.

---

## 3. How to ask

Works well:

> Write a ReTibe scenario: open https://example.com, check the heading is there, go
> to /pricing, make sure the form is visible. Validate it, run it, show me the
> result.

> Run webtest-123 failed. Work out why and suggest a fix.

> Go through my scenarios and find checks that do not actually check anything.

> Look at the "Release 2.4" checklist: what is still manual? Take the top case,
> write a scenario and link it to that case.

> Put the smoke set on every morning at 07:30 Moscow time. Show me what time the
> platform actually scheduled it for.

> What is scheduled for next week, and what failed last week?

Works badly:

> Test the site.

Too vague — Claude cannot see the page and will not guess what matters.

---

## 4. The right order of work

Tell Claude to follow this order; it is built into the tool descriptions.

1. **`retibe_env`** — which environments exist, and the parameter keys
2. **`retibe_actions`** — the action catalog, and the contract of each action it intends to use
3. **`retibe_reference`** — cross-cutting mechanics (templates, conditions, fixtures)
4. write the scenario
5. **`retibe_validate_scenario`** — before a browser starts; takes milliseconds
6. **`retibe_validate_scenario view:'storyboard'`** — "which of these steps can actually go red?"
7. **`retibe_run_scenario`** → **`retibe_run_result`**

Step 5 cannot be skipped. The platform **does not validate scenarios** — it will
accept a broken one and keep running it.

Step 6 answers a question the warning list does not. Forty warnings do not tell you
whether the test would notice your site breaking; the storyboard does, and it costs
the same milliseconds. If it says nothing can go red, the scenario is decorative and
running it is pointless.

If the account keeps checklists, there is a useful step zero before item 4 —
**`retibe_checklists`**, which answers "what is worth automating at all" — and after
you save the scenario, **`retibe_checklist_item op:link`** ties a case to it. The
link is a plain string the platform never validates; how it breaks is section 10.

How to name a scenario and how many to keep is section 14. Read it **before** item
4: the name and the top-level `id` are close to unchangeable afterwards, because
both the run history and the checklist link hang off them.

After `retibe_save_scenario`, add the row to `docs/TEST_COVERAGE.md`, the
project's coverage map (section 17). In the same pass, not "later": the thread
that comes next starts from that file.

`retibe_examples` and `retibe_docs` are deliberately outside this order — they are
not always needed. Reach for them when an action is unfamiliar and the contract from
`retibe_actions` is not enough: `retibe_examples` gives a whole working scenario,
`retibe_docs` gives prose on a topic. How much to trust them is section 5.

---

## 5. How much to trust any of this

The reference tools have **different natures and different reliability**. This is
not pedantry — it decides what to believe when two of them disagree.

| Source | Where it comes from | Freshness |
|---|---|---|
| `retibe_actions` | extracted from the engine's own `switch` by parsing its AST | **high** — a test re-reads the engine on every run and fails on any drift |
| `data.*` field contracts | read out of the engine once, maintained by hand since | **medium** — accurate today, but nothing checks it against the engine automatically |
| `retibe_reference` | frozen text, compiled into the build | **low** — nothing regenerates it |
| `retibe_docs`, `retibe_examples` | files in `knowledge/` and `examples/`, shipped in the image | **medium** — edited by hand, never checked against the engine |
| a checklist's link to a scenario | a string somebody typed once | **none** — no foreign key, no trigger, no reconciliation; it breaks silently (section 10) |
| `docs/TEST_COVERAGE.md` in the repo | maintained by hand, by this same thread | **none** — nothing reconciles it; but it is the only place recording what is *not* covered and why (section 17) |
| tool descriptions and prompts | written by hand | **low** — connected to the engine by nothing |

**What this means in practice.**

The action list is reliable. If `retibe_actions` did not return an action, the engine
does not have it: the catalog is machine-extracted from the dispatcher and guarded by
a drift test. Invented actions (`web_fill`, `web_type`, `web_hover`) are caught by the
validator.

Everything else is prose that may have fallen behind the code. Believe it until the
first contradiction with observed behaviour; when they disagree, the engine is right.

**The public endpoint can lag the repository.** Deploys are manual — `git pull` plus
a container rebuild, no CI/CD, and nothing checks the boundary. The endpoint does not
report its version or commit, so the discrepancy is invisible to a client. If a fix
to the validator or to a description is already on main but the behaviour has not
changed, it most likely just has not shipped yet.

---

## 6. The main thing to know about this engine

This is why the validator exists at all. The engine accepts almost anything, so a
broken test does not fail — it **passes green while checking nothing**. Claude is
told this in the tool descriptions, but it is worth knowing yourself.

**Checks that cannot fail.** `api_test`, `security`, `accessibility`, `seo_analysis`,
`graphql_test`, `load_test` and several others write the problem into findings while
the step itself stays `passed`. The API returned a 500 — the test is green. If a step
is meant to block a release, it needs a real assertion after it.

**`web_assert` does less than it looks like it does.** It checks visibility and
substring containment — and the substring only through `data.contains` or its alias
`data.expectedText` (the engine reads `data.contains ?? data.expectedText`). The
fields `assertion` / `expected` / `attribute` / `text` / `value` are **never read at
all**, so a step using them passes on any visible element with any text in it.

**There is no built-in absence assertion.** `visible: false` looks like "it is not
there" and means precisely the opposite: the wait is weakened to `state: "attached"`,
so the element must still be in the DOM, merely possibly hidden. None of the 48
actions asserts absence — but you can get there with `web_evaluate`: a script like
`if (document.querySelector('.popup')) { throw new Error('still there') } true`
genuinely fails the step, because the engine wraps the exception in
`web_evaluate failed:` and rethrows, provided the step does not carry `optional`.

**`web_fill_form` swallows field errors — but it now reads the form back.** By
default a failure on one field does not fail the step: it goes into
`validationErrors` and the step stays `passed`. On top of that, once **all** fields
are filled the engine reads each one back and puts what the form holds into the step
message: `#card → 19 симв. | #month → ПУСТО`. Values are **described, not quoted** —
that line reaches the run log, the HTML report and the stored result, and a form
holds card numbers, CVVs and resolved `{{params.*}}`; `revealValues: true` quotes
them if you need that, never a password. A field asked for a non-empty value that is
empty means the page cleared or rejected it: with `validateForm: true` that fails the
step and names the field, without the flag it is a warning. Reformatting is **not** a
failure, or a card number would fail every time, and a field whose value cannot be
read confidently is marked unreadable and fails nothing. Reading repeats Playwright's
own retargeting, so a field addressed through its `<label>` or a `role=checkbox`
wrapper is read where the write landed. The read-back runs after the whole loop,
because the clearing is often cross-field — picking the year blanks the month. For a
widget that clears itself on purpose (tags, autocomplete) set
`allowEmptyAfterFill: true` on that field; a step that **tests rejection**
(`checkErrorMessages` / `expectedErrors`) is exempt from the check entirely. And when
`formSubmitSelector` is in the same step, failing means the form is never submitted; a
submit written as a separate `web_click` still runs unless the fill step carries
`critical: true`.

**Assert the outcome, not the fact of the action.** Filling a form and clicking
"submit" is not yet a test: if nothing is checked afterwards, the steps pass green
even when the server refuses. You need a signal that **might not arrive** — a
redirect, an element appearing, a URL change. A good way to be sure a check is real
is to break the scenario deliberately and watch it go red.

**No screenshot is saved on a failing step** — the engine captures the screen after a
successful action. You will be diagnosing the failure from a neighbouring step's
frame.

**Visual regression fires in exactly two cases** — `web_scroll_screenshot` together
with `data.screenshotFullPage`, and `use_fixture` when the fixture carries
`visualRegression`. On `web_screenshot` it silently does nothing.

**And `visualRegression` is a STEP-level field, not a `data` one.** This is the
shape that works:

```json
{ "id": "full", "action": "web_scroll_screenshot",
  "visualRegression": true,
  "data": { "screenshotFullPage": true } }
```

Putting it in `data` is the natural mistake and the most expensive one here: the
validator does not look there, so it answers `valid: true` with not a single
warning, and no regression is captured. Verified against the live endpoint.

**Actions that do not exist.** The platform's built-in assistant documents
`web_fill`, `web_type`, `web_hover`, `web_select` and several more that the engine
does not have. The validator catches them and names the replacement.

**`{{params.*}}` do not work in Telegram scenarios** — the Telegram engine does not
resolve templates at all.

The validator surfaces most of this as warnings with a ready replacement:
`PHANTOM_ACTION` (with the exact substitute), `CANNOT_FAIL`, `ASSERT_FIELD_IGNORED`,
`VR_ON_WRONG_ACTION`, `TEMPLATE_IN_TELEGRAM`. If Claude ignored them, ask it to run
`retibe_validate_scenario` and go through each one.

**But four of these it cannot catch, as a matter of principle** — keep them in your
own head:

- **a scenario that checks nothing whatsoever.** Open, fill, click, and not a single
  assertion: `valid: true`, zero warnings;
- **a lone `visible: false`.** The validator mentions it only inside the `fix` of
  `ASSERT_FIELD_IGNORED`, i.e. only when an ignored field also ended up in `data`. On
  its own the step passes clean;
- **`visualRegression` put inside `data`.** The `VR_ON_WRONG_ACTION` check reads the
  step-level field, so it says nothing at all about `data.visualRegression` —
  `valid: true`, and no regression;
- **the contents of a fixture** — the body of `use_fixture` is not visible to the
  validator.

For the first there is the `/harden` prompt, which is written precisely as a check
for what the validator cannot see. And `view:'storyboard'` (section 11), which
answers it mechanically: it lists every step with a verdict on whether that step can
go red, and says how many can. `valid: true` together with "nothing can go red" is
the first item on that list, seen at a glance.

---

## 7. The tools

**Reference:** `retibe_actions` (the action catalog — the only machine-extracted
one), `retibe_reference` (cross-cutting mechanics), `retibe_docs` (an authoring
handbook, by section), `retibe_examples` (working scenarios as few-shot material),
`retibe_doctor` (the state of the run environment — on the public endpoint that
means the server's container, not your machine). Their reliability differs — see
section 5.

**Work:** `retibe_validate_scenario` (plus `view:'storyboard'` — section 11),
`retibe_run_scenario`, `retibe_run_status` (plus `wait_for` and `since` — section
12), `retibe_run_result`, `retibe_list_runs`, `retibe_stop_run`, `retibe_publish_run`
(needed in local mode, to publish a local run into reports and history; runs from
`retibe.com/mcp` are on the platform already).

**Platform:** `retibe_whoami`, `retibe_env` (plus `secretKeys` — which keys are
flagged sensitive), `retibe_scenarios`, `retibe_save_scenario`, `retibe_history`,
`retibe_visual_review`, `retibe_param` (write a `{{params.*}}` value).

`retibe_param` closes the gap that used to stop an agent and make it wait for a
person: a key could only be created by hand in the web UI. The write is one-way —
the value goes in and is returned by no tool, because these rows hold passwords.
Overwriting an existing key needs `confirm:true`: the old value cannot be read
back from here, and another scenario may be reading it. The key is checked against
the resolver's own character set (`[a-zA-Z0-9_.]`) — the store would accept a
hyphen and the engine would then leave `{{params.my-key}}` in the selector
verbatim. Every write is read back through the route the engine resolves from, and
undone if the key still does not resolve.

**Checklists:** `retibe_checklists` (the coverage map — what is still manual),
`retibe_checklist` (create a checklist with its cases in one call, rename it, delete
it), `retibe_checklist_item` (add a case, edit it, delete it, link it to a scenario,
unlink it, record a manual result). Section 10.

**Calendar:** `retibe_calendar` (what is scheduled and what has already run),
`retibe_schedule` (create, edit, pause, delete a schedule, and run one now).
Section 13.

**Prompts** — all three take required arguments; without them the client returns a
schema error:

| Prompt | Arguments |
|---|---|
| `/author` | `url`, `goal`, and an optional `environment` |
| `/debug` | `runId` |
| `/harden` | `scenarioPath` — on the public endpoint this is a saved scenario's id or name, not a file path; the argument name is left over from local mode |

**`retibe_history` with no arguments returns the index** — which scenarios have a
history at all, and the `historyId` of each. That is the only way to obtain one:
`historyId` is an md5 of the scenario's shape and appears in no run digest and no run
listing. A scenario enters the index after its first run **on the platform**.

The repository has **24 tools and 3 prompts**. What the endpoint you are talking to
actually serves is whatever `/mcp` shows after connecting, and that is more
trustworthy than any number in this file.

---

## 8. Reading the result

- **`passed` / `failed`** — the test ran and the verdict is real
- **`infra_error`** — the scenario **did not execute**: the browser failed to start,
  or the deadline expired. There is no bug in the scenario to look for
- **`unknown`** — the run has not finished. This is **not** "passed"
- **`stepsObserved`** — an estimate, not a counter. Only `state` says a run finished.
  It became more accurate — a step is counted when it is entered rather than when a
  token changes — but it is still an estimate: step boundaries do not exist as data
  in this engine, see section 12
- **`steps`** — the step timeline with intervals. The intervals are estimates; what
  exactly stops them being measurements is in `timelineCaveats` on every response
- **`waitEndedBy`** — what ended the wait. `timeout` means "time ran out", not
  "nothing happened"
- **`next_offset`** — the cursor for your next poll

Screenshots come back as paths, not as images.

---

## 9. Limits

- **Telegram is authoring only.** Running requires a session created by an
  interactive login with an SMS code.
- **One test at a time** per account; bot and web tests block each other.
- Runs from here spend the same monthly quota as runs started from the web UI.
- A web test usually takes about a minute; the slowest reach ten. Calls never block:
  starting returns a `runId`, and you poll from there.

---

## 10. Checklists

A manual checklist is something the team already has: a list of cases, some of them
checked by hand every release. Both tools exist for one purpose: **make it visible
what is still manual, and let it be closed with a scenario.**

**The working cycle.**

1. `retibe_checklists` — the checklists with raw counters
2. `retibe_checklists id:N only:'unlinked'` — exactly the cases nothing covers
3. write a scenario for one of them, `retibe_validate_scenario`, `retibe_run_scenario`
4. `retibe_save_scenario` — save it on the platform
5. `retibe_scenarios id:…` — **read the saved one back**
6. `retibe_checklist_item op:'link'` — link the case to it

Item 5 cannot be skipped, and not out of tidiness: the key the platform later matches
the checklist on is taken **from the saved scenario**, not from the linking call.

### Creating a checklist

If the checklist does not exist yet, it is created from here too — with its cases, in
one call:

```
retibe_checklist op:'create' name:'shop / checkout — release' project:'shop' \
  cases:[
    {title:'Card payment succeeds', priority:'critical'},
    {title:'A declined card shows an error', priority:'high'},
    {title:'A coupon reduces the total', priority:'medium'}
  ]
```

Each case is a **separate write**, not a transaction: if one fails, the checklist and
the other cases stand, and the response names exactly the ones that did not land. You
do not repeat the whole call — add those with `retibe_checklist_item op:'add'`.

Checklist names are **not unique** on the platform, and its own import looks a
checklist up by exact name. So creating a second checklist with a name you already use
requires `confirm:true`, and the response tells you there are now two and to address
them by id.

| What you need | Call |
|---|---|
| add a case | `retibe_checklist_item op:'add' checklist_id:N title:'…'` |
| edit a case | `retibe_checklist_item op:'update' checklist_id:N item_id:M priority:'high'` |
| delete a case | `op:'delete'` + `confirm:true` — along with its result history |
| rename a checklist | `retibe_checklist op:'update' id:N name:'…'` |
| retire one without losing history | `retibe_checklist op:'update' id:N status:'archived'` |
| delete a checklist | `retibe_checklist op:'delete' id:N confirm:true` — every case and every recorded result cascade with it |

`status` is free text in the schema, but the UI filters only on `active` and
`archived`: any third value drops the checklist out of both of its filters.

A case created this way is **linked to nothing** — no automatic result can reach it
until you `op:'link'`. And `automation_status:'automated'` on its own only moves the
coverage percentage: nothing checks that a scenario stands behind it.

### How the link works, and why it breaks

The platform ties a run to a case like this: the **checklist** has a `scenario_id`
field (a string), the **case** has a `step_id`. When a run finishes, the engine takes
`scenario.id || scenario.name` from whatever object started it and looks for
checklists with that `scenario_id`.

Everything else follows:

- **One checklist = one scenario.** The field is on the checklist, not on the case.
  Two cases about different scenarios are two checklists. `op:'link'` on a checklist
  that already points at a different scenario requires `confirm:true`, because it
  redirects **every** case in it.
- **No foreign key, no trigger, no reconciliation.** Rename the scenario, give it an
  `id` it did not have, delete it — the link is broken, silently and permanently. The
  platform reports this nowhere: a failed match looks exactly like "no matching
  cases". The only way to find out is `retibe_checklists verify_links:true`, with two
  caveats — it works on the listing (not on an opened checklist) and it checks
  against your 200 most recent scenarios, so `linksNotFound` means "not found", not
  "broken". The response says so. It matches all three forms the key can take: the
  scenario's own string `id`, the platform's numeric id, and the name. If none of the
  checklists points anywhere at all, the response says `nothingToVerify` rather than
  staying silent.
- **`scenarioKeySource` in the `op:'link'` response matters more than the key
  itself.** If it says `template id`, the scenario has no string `id` of its own and
  the key became the platform's numeric id. Such a link fires for a run started from
  the object `retibe_scenarios` returns, and does **not** fire for the same scenario
  passed inline — there the key is the name. If you want one key for both, give the
  scenario a stable top-level string `id` and link it again.
- **`checklist_items.scenario_id` and `scenario_hash` are dead columns.** They exist,
  they are indexed, and nothing reads them. The tool does not write them.
  `scenario_hash` is **not** the `historyId` that `retibe_history` takes.
- **Export/import severs the link.** A checklist export does not contain the
  checklist-level `scenario_id`, so a re-imported checklist looks wired up and
  receives no results.

### Where a case's results come from

Only from a run of the linked scenario **on the platform**. A local or composite run
does not stamp checklists at all, and `retibe_publish_run` does not fix that: it sends
the run id as the scenario name, and that key matches nothing.

The recording code coerces anything that is not `passed` to `failed` — a cancelled or
stopped run shows on the case as an honest failure. A case with no `step_id` takes the
run's overall verdict rather than its own step's.

### The coverage numbers

There are **three of them**, and they disagree:

| Where | Formula |
|---|---|
| the checklist listing | `automated / total` — `partial` is not counted |
| an opened checklist | `(automated + partial×0.5) / total` |
| the web UI | computes a fourth, in the browser, per project |

So the tool returns **raw counters** and names the formula beside them. A percentage
from a response can only be trusted together with its formula.

Two counters behave differently from how they look. `passed` and `failed` each count a
case if **either** of its two results matches — manual or automatic — so a case that
passed by hand and failed automatically is in both, and the sum can exceed `total`.
`automation_status` and `priority` are free text and the platform validates neither: a
value outside `manual/automated/partial/planned` drops out of every counter.

### `op:'record'` — careful

It records a **manual** result under the token owner's name. The platform has no
marker for "an agent did this": a human opening the checklist will see themselves as
the tester. The `[MCP]` prefix on the comment is the only thing distinguishing such an
entry. Execution rows **are not deleted** — there is no route for it. Hence
`confirm:true`.

If the call returns an error, **do not repeat it**. The route writes the execution row
first and updates the case afterwards, with no transaction: the result may already be
recorded. Read the case back with `retibe_checklists`.

### What is deliberately absent

**Import.** The platform has `POST /api/checklists/import`, which takes a batch of
checklists at once, and it is not exposed here for three reasons: it matches
checklists by **exact name**; with `overwrite:true` it deletes every case of the one
it found and recreates them, losing the link key in the process; and its response
carries only counters — **no ids** — so after an import you do not know what you
created. `op:'create'` does the same work with real writes and hands back the id of
every case.

Also absent: writing `checklist_items.scenario_id`. Nothing reads that column, so
writing it would look like a link and link nothing. Cases are wired only through
`op:'link'` (above).

---

## 11. The storyboard: which step can actually fail

`retibe_validate_scenario` with `view:'storyboard'`. A table per step, plus one line
of conclusion.

```
#  id     action        target           verdict
1  open   web_navigate  https://x.com    can fail
2  typo   web_clikc     #a               no such action — not in the action catalog for this kind
3  guard  web_assert    h1               may not run — guarded by skipIf, which the engine checks first
4  title  web_assert    h1.hero          asserts nothing — data.text is never read; no data.contains
5  sec    security      {checks}         always green — writes its problems to findings and reports passed

Only step 1 can fail; the other 4 cannot make this scenario go red.
```

`view` takes `findings` (the default, as before), `storyboard` and `both`.

**The verdicts.** Read worst to best; the one shown is the one that actually governs
the step:

| Verdict | What it means |
|---|---|
| `no such action` | the action is not in the catalog — the step throws rather than checking. Catches both a typo (`web_clikc`) and an invented action (`web_hover`) |
| `always green` | writes its problem into findings and reports `passed` regardless |
| `asserts nothing` | a `web_assert` whose text field the engine does not read |
| `failure ignored` | `optional:true`, and this action honours it |
| `may not run` | the step is guarded by `condition` or `skipIf` — nothing can be promised about it |
| `can fail` | a real gate: this step can go red |

**What this is not.** `can fail` is about the shape of the step, not about your site:
the engine can turn this step red, but whether it is the right check the storyboard
does not know. Reachability is not statically decidable (conditions, fixtures,
`optional` on four actions), so a guarded step's honest verdict is "may not run"
rather than "green".

Both guards count: the engine checks **`skipIf` before `condition`**
(`WebSiteTester.ts:4707`) and records a skipped step as a success — so a case shaped
like "`skipIf: {previousStepFailed: true}` plus a real assertion" looks like a gate
and in fact switches itself off the moment the previous step fails. An empty guard
(`condition: null`, `condition: {}`) does not count as a guard, because the engine
does not count it either.

The verdicts are derived from the same constants the validator itself uses
(`CANNOT_FAIL_ACTIONS`, `HONOURS_OPTIONAL`, `ASSERT_IGNORED_FIELDS`,
`PHANTOM_WEB_ACTIONS`). A second copy of those lists would have drifted from the
validator silently — which is exactly the defect all of this was written against.

The reliable way to know a check is real remains the same one: break the scenario
deliberately and watch it go red.

---

## 12. Polling a run: wait for a thing, not for a duration

`retibe_run_status` gained `wait_for`, `since` and `stall_ms`.

| `wait_for` | Returns when |
|---|---|
| `terminal` | the run reached a final state (the default, as before) |
| `step` | the next step boundary went by |
| `failure` | the first error-level line appeared — or the run ended |
| `stall` | the run is alive but has said nothing for `stall_ms` (30 s by default) |

The point is that a blind `wait_ms` spends the whole budget on a run that failed two
seconds in. `wait_for:'failure'` comes back immediately — and it is **not** a verdict:
`state` is still `running`, and the engine logs at error level on recoverable retries
too. What ended the wait is in `waitEndedBy`: `timeout` means "time ran out",
everything else means "this happened".

`since` is a cursor. The response carries `next_offset`; pass it to the next call and
the poll reads only what is new instead of re-reading the tail you already paid for.

**The step timeline.** The response gained a `steps` field:

```
✓ open   ██                       2.4s
✗ submit ████████████████████████ 31.7s
… verify                          ?
```

`✓` completed, `✗` failed, `–` skipped, `…` still running. Trust it exactly as far as
`timelineCaveats` says, and here is why: **step boundaries do not exist as data in
this engine.** There is no `stepStart` and no `stepEnd` — a step's state is recognised
by matching Russian prose in the log. Consequently:

- A step whose message did not match a pattern is simply absent from the timeline.
- A duration is the interval between two recognised lines, not time the engine
  reported spending. That makes it an estimate, not a measurement.
- `(retried)` means the engine entered the step more than once; the interval covers
  every attempt.
- The last, still-running step shows `?` rather than `0.0s`: printing a zero next to a
  step that has been hanging for a minute is worse than admitting there is no figure.

`stepsObserved` is still an estimate but a better one: a step is counted when it is
entered, so a retry no longer advances the counter. What this does **not** fix: a
step's token is `id || action`, so two steps without an `id` sharing an action remain
indistinguishable and count as one. The only cure is to give steps ids.

**On the public endpoint this works through the platform's progress channel**
(`GET /api/webtest/:testId/progress`, added alongside it). Before that, a remote poll
returned hard zeros and a sentence saying progress was unavailable. If the channel
does not answer, the response says plainly that **nothing is known** about the run —
which is not the same as "the run is quiet" — and `waitUnsupported` lists the
conditions it could not honour. The ring holds the last 500 lines: if your cursor is
older than that, the response says entries were evicted rather than going quiet.

---

## 13. The calendar and schedules

Two tools: `retibe_calendar` reads, `retibe_schedule` writes.

```
retibe_calendar                              # what is scheduled
retibe_calendar id:5                         # one schedule
retibe_calendar view:'entries' from:'2026-09-01' to:'2026-09-30'
retibe_schedule op:'create' name:'shop-smoke-nightly' scenario:42 \
                frequency:'daily' time:'07:30' timezone:'Europe/Moscow'
```

### There are only four frequencies

`once`, `daily`, `weekly`, `monthly`. The tool **does not accept** `cron`, and that is
not caution — the `cron` branch in the engine reads, in full:

```ts
// Simplified cron parser - for production use proper library like node-cron
// This is a placeholder that returns next hour
// TODO: Implement proper cron parsing
```

The expression is read exactly once — to check it is non-empty — after which the top
of the next UTC hour is returned. If you create such a schedule through the web API
directly:

- `scheduleConfig: {cron: "0 9 * * 1-5"}` → **200**, and the test runs **every hour,
  around the clock**, ignoring the expression. The quota is gone within a day.
- `scheduleConfig: {expression: "0 9 * * 1-5"}` → **400**: the route validates the key
  `expression` while the scheduler reads `cron`. So the key the error message points
  you at does not work at all.
- Such a schedule never appears on the calendar — for frequencies outside the four
  working ones the platform returns no entries whatsoever.

`interval` is named in the route's error text but has no branch in the scheduler, so it
is refused too.

### `nextRunAt` is the only honest answer to "when"

The platform computes it when the schedule is written. The tool returns it in the
response to `create` and `update` and **never recomputes it**. Check it with your own
eyes: if that is not the moment you expected, the schedule is wrong — and now is a
better time to find out than tomorrow.

**The timezone defaults to UTC.** Leave `timezone` out and `time: '09:00'` means 09:00
UTC. This is the most common cause of "the schedule did not fire". Pass an IANA name:
`Europe/Moscow`, `Asia/Almaty`.

For `monthly`, `day_of_month` is clamped to the length of the month: 31 means the last
day in February. For `daily` you can narrow to weekdays with `days_of_week`
(0 = Sunday).

### The run counters

`counts` in the response is `runs`, `passed`, `failed`, with `countsFrom` beside it
naming where they came from. There are two sources:

- **`schedule columns`** — the schedule's own counters. **This is the default**,
  because it is one instant request. A verdict is written there only for runs since
  `b297b63`, so a schedule older than that commit shows `passed` and `failed` at zero
  however many times it succeeded — only `runs` moved.
- **`tests table`** — counted from the real run rows, accurate at any age. Turned on
  with `real_counts:true`.

Why it is not the default: the statistics route aggregates the **whole** tests table
per schedule and builds a thirty-day trend. On an instance with six thousand runs
that is **about 15 seconds**, and it grows from there. Worth paying to learn the
truth about an old schedule; not worth paying on every call.

### A schedule holds a copy of the scenario

On creation the scenario's **steps** are copied into the schedule, not referenced. Edit
the scenario and the schedule keeps running the old version. To pull the new one in,
call `op:'update'` with the same `scenario`.

### What `view:'entries'` shows

The platform returns several different things under one key; the tool separates them
with a `kind` field:

- **`occurrence`** — a computed future firing. Nothing exists in the database for it;
  it is arithmetic over the schedule. The `end` field is not a prediction but a flat
  "plus five minutes" placeholder.
- **`run`** — a real run, past or in flight.

The caveats the tool returns alongside the data:

- Running tests are included **regardless of the range** — deliberately, so an active
  test is always visible.
- Finished runs are drawn only from the account's **500 most recent** tests. A run
  inside the range but older than those 500 is silently absent.
- A paused schedule still produces entries, with `status: 'paused'`.

### `op:'run_now'`

Fires the schedule immediately and returns a `testId` you **can poll**:
`retibe_run_status runId:'<testId>'`. This works as of `f413edd` — the scheduler starts
a web test through the same function that fills the progress channel, and ownership is
resolved from the `tests` table.

**This used to say that about half of calls return `Scheduler service is not
available` and you should just call again.** That was wrong in both directions.
Measured: 2 successes in 10 direct requests, and **0 in 20 through MCP** — the sidecar
holds one keep-alive connection, so it pins to a single worker, and if that is not the
first worker then `run_now` never succeeds no matter how many times you retry.

Fixed: the route no longer depends on which worker answers. The periodic sweep still
lives only in the first worker — otherwise a schedule would fire once per worker — but
running one schedule on demand can happen anywhere. Measured after the fix: **20 of 20**.

If you do still see `Scheduler service is not available`, the endpoint is on an image
older than `f5a3620`.

A run from here spends the monthly quota like any other and is subject to the
one-test-at-a-time lock.

### What the tools do not do

Deleting requires `confirm:true` — the copy of the scenario inside the schedule goes
with it and cannot be restored (reports from past runs remain). If you only want it to
stop firing, use `op:'pause'`: the schedule keeps both its history and its next-run
time.

Pause and resume go through the update route rather than `/toggle`: toggle flips
whatever it finds, so two callers both asking to pause could leave the schedule
running.

---

## 14. Naming scenarios, and how many to keep

This section is about the things you cannot easily redo later. A scenario's name and
top-level `id` are not cosmetic: the run history and the checklist link both hang off
them.

### A project prefix, in the name and in the `id`

Give every scenario a **stable top-level string `id`** of the form
`<project>-<area>-<what it checks>`, lowercase, hyphen-separated:

```json
{
  "id": "shop-checkout-guest-purchase",
  "name": "shop-checkout-guest-purchase — guest purchase",
  "startUrl": "https://shop.example.com",
  "steps": [ … ]
}
```

The prefix belongs in **both** the `id` and the `name`, hyphenated the same way in
each. The reason is mechanical: `retibe_scenarios search:` resolves to
`name LIKE ? OR description LIKE ?` and **never looks at the `id`**. Name a
scenario "shop / checkout — guest purchase" and `search:'shop-checkout'` will not
find it, correct `id` or not. Verified against the live endpoint.

Why this rather than "Login test":

- **The `id` is the key the platform finds a checklist by.** The engine takes
  `scenario.id || scenario.name`. With no string `id` of its own, the key becomes the
  platform's numeric id — and then the link works for a run started from the object
  `retibe_scenarios` returns and does not work for the same scenario passed inline.
  With a stable `id` the key is the same in both cases (section 10).
- **`retibe_scenarios` is your coverage map.** The listing returns the name, the
  description and the step count. `search:'shop-checkout'` finds a whole area — but
  only if the prefix is in the **name** or the description, because that is what the
  search matches, not the `id`. Without prefixes, twenty "Login test 2" cannot be
  found at all.
- **`verify_links` checks against your 200 most recent scenarios.** A prefix makes it
  immediately clear which project an unmatched key belonged to.
- **Renaming orphans the history.** `historyId` is an md5 of the name, the URL, the
  step count and the action list. Change the name or add a step and the previous
  history is orphaned. So the name is chosen once.

Keep prefixes short and consistent within a project: `shop-`, `crm-`, `api-`. The area
is the second level: `shop-auth-`, `shop-checkout-`, `shop-catalog-`.

### There should be few scenarios, and they should be long

This is not about tidiness but about how the limits work.

| What | Why it bears on the number of scenarios |
|---|---|
| **One test at a time** per account, bot and web tests blocking each other | 40 one-minute scenarios is 40 strictly sequential minutes; they cannot overlap |
| **The quota counts runs, not steps** | ten 40-step scenarios is 10 runs; forty 10-step ones is 40 runs for the same coverage |
| **Fixed per-run overhead** | starting a browser and reaching the page is about a minute at p50 even for a tiny test. On short scenarios the overhead *is* the run |
| **`infra_error` is counted per run** | the more launches, the more chances one of them fails to start |

So aim for **a handful to a few dozen scenarios per project**, not hundreds. One
scenario is one whole user journey with its own setup: "arrived, logged in, added to
the cart, checked out, verified the email" is a single 30–50 step scenario, not five of
six steps each.

### Where a long scenario starts to hurt

The other side of it: a scenario that failed at step 3 tells you nothing about steps
4–40. So do divide — along these lines:

- **By shared setup.** Steps that need the same login and the same state live together.
  A different account or a different environment means a different scenario.
- **By what blocks a release.** Keep "it failed, we do not ship" apart from "it failed,
  file a bug". The first runs on every release, the second overnight.
- **By speed.** Do not mix a two-minute smoke with a ten-minute catalog walk, or you
  wait for the fast answer at the slow one's pace.

Inside a long scenario, decide deliberately what aborts the run: `critical` and
`stopOnFailure` are compared strictly against `true` (the validator warns if something
else ended up there). Without them a failed step does not stop the rest — sometimes
that is what you want, sometimes not.

### Give steps ids

In a long scenario this is required, not advisory:

- **The step timeline and `stepsObserved`** identify a step by `id || action`. Two
  steps without an `id` sharing an action are indistinguishable and count as one
  (section 12).
- **A checklist case links on `step_id`** — with no `id` on the step, a specific case
  cannot be tied to a specific check and takes the whole run's verdict instead
  (section 10).
- **The storyboard** names a step by its `id`; without one, a 40-row table is hard to
  read.

Step names follow the same principle as scenarios: `login-submit`, `cart-add`,
`checkout-assert-redirect`. Not `step-1`.

### How this maps onto checklists

Grouping and checklists are coupled more tightly than it looks: **one checklist points
at one scenario**. So a 40-step scenario covering 8 manual cases is one checklist,
where each case has its own `step_id`. That is the main practical argument for long
scenarios: the mapping from cases to checks comes out naturally. Split those same 8
cases into 8 scenarios and you need 8 checklists.

### A short instruction you can hand to Claude

> Work with ReTibe like this. Scenario names are `<project>-<area>-<what>`, lowercase,
> hyphen-separated, and put that same value in the top-level `id` field; our project
> prefix is `shop-`. Give every step a meaningful `id`. Do not breed small scenarios:
> one scenario is one whole user journey, 30–50 steps, split off only what needs
> different setup or a different run frequency. Always `retibe_validate_scenario`
> before running, then the same call with `view:'storyboard'` — and if nothing can go
> red, rewrite it rather than running it.

---

## 15. Split view: two browsers in one scenario

One scenario can drive **several browsers at once** — buyer and seller, caller and
callee, admin and user. This is how you test anything that happens *between* two
participants.

```json
{
  "name": "shop / chat — buyer asks, seller answers",
  "webrtc": { "enabled": true, "splitPreview": true },
  "actors": {
    "buyer":  { "name": "Buyer",  "viewport": { "width": 1280, "height": 720 } },
    "seller": { "name": "Seller", "viewport": { "width": 1280, "height": 720 } }
  },
  "steps": [
    { "id": "buyer-login",  "actor": "buyer",  "action": "web_login",  "data": { "…": "…" } },
    { "id": "seller-login", "actor": "seller", "action": "web_login",  "data": { "…": "…" } },
    { "id": "buyer-asks",   "actor": "buyer",  "action": "web_click",  "data": { "buttonSelector": ".send" } },
    { "id": "seller-sees",  "actor": "seller", "action": "web_assert", "data": { "selector": ".msg", "contains": "hello" } }
  ]
}
```

`actors` is an **object, not an array**: the key IS the actor id, and that is what
`step.actor` looks up. The `name` field is only a label in the log and in the
preview pane — you cannot address an actor by name.

### BOTH fields are required: `actors` and `webrtc.enabled`

The engine opens a second browser only when there is **both** an `actors` block
**and** `webrtc.enabled: true`. `actors` alone is not enough: everything runs in
one browser, the `actor` fields are ignored, and **nothing says so** — the run goes
green and there were never two participants.

`retibe_validate_scenario` now catches this as the error `ACTORS_WITHOUT_WEBRTC`.
Check before your first run: it is the most expensive mistake in this section,
because it looks like success.

The name `webrtc` is historical. The block is what enables multi-actor mode at all;
calls have nothing to do with it unless you are testing calls.

### Steps take turns; they do not run at the same time

The order is the order of `steps`. While one actor acts, the other waits. **There
is no parallel step, no barrier, and no way to express "meanwhile".** To make one
participant wait for the other, give it its own `web_wait` — or better, an
assertion that only passes once the other side has acted: a `web_assert` on an
element that appears because of someone else's action IS the synchronisation.

All contexts are created **up front**, before the first step, in declaration order.

### Two silent reassignments

| In the scenario | What the engine does | What the validator says |
|---|---|---|
| a step has no `actor` | runs it in the **first** actor's browser, with no warning | `STEP_WITHOUT_ACTOR` (warning) |
| `actor` is not declared | warns in the log and runs it in the **first** actor's browser — and the step still passes | `UNKNOWN_ACTOR` (error), with a suggestion |

The second is the dangerous one: a mistyped id does not fail the run, it quietly
tests the wrong participant. The storyboard shows such a step as `membr (unknown)`
rather than as `creator`, precisely so the substitution is visible.

### Which actor fields actually work

| Field | Works |
|---|---|
| `viewport` | **yes**, defaults to 1280×720 |
| `permissions` | **yes** — `camera`, `microphone`, `geolocation`, `notifications`. The grant is wrapped in a try, so an unknown name fails quietly |
| `userAgent` | **at the network level yes**, but inside the page `navigator.userAgent` is hard-coded to one macOS Chrome 120 string for **every** actor. Page scripts see no difference |
| `name`, `description` | labels |
| `browserArgs` | **no.** Every actor shares the one browser, so there is nowhere to put per-actor flags |
| `device` | **no.** Device presets are reachable only from a `mobile_device` step |

An actor has **no** `storageState`, `locale` or `timezone` at all. That is why each
participant logs in through the UI with its own `web_login` — there is no way to
inject a ready session. It also means two actors are two independent sessions in
one browser: they do not share cookies.

### `splitPreview` is only the picture

`splitPreview: true` turns on the stream of both browsers side by side, for a human
watching in the web UI. It does not affect how many browsers actually run: one of
the repository's examples drives two actors with `splitPreview: false`.

**Those frames do not reach MCP.** They are discarded before the run log — images
of a few hundred KB every couple of seconds. What does arrive: the actor roster as
one event at the start, and a line per switch.

`forceHeaded: true` **does not work** on the public endpoint: the container has
Xvfb but no `DISPLAY`, and a headed launch fails. Two browsers still come up
normally — headless does not prevent that. Do not set `forceHeaded` for a run on
the platform.

### What is now visible over MCP

- **`retibe_validate_scenario`** — eleven actor checks, including both traps above,
  unknown permissions, and the fields the engine ignores.
- **`view:'storyboard'`** — an `actor` column: who performs which step, how many
  steps each has, and `(none)` / `(unknown)` marks wherever the engine will
  substitute a browser.
- **`retibe_run_status`** — an actor column in the step timeline, plus the roster.
  Attribution is read from the switch line the engine logs before each step, so a
  step whose line was not recognised carries **no** actor rather than inheriting
  the previous one.
- **`retibe_run_result`** — an `actors.perActor` block: how many steps ran, passed
  and failed **in each browser**, plus `actor` on every failing step. The first
  question about a two-user run — "whose side broke?" — is now answered by the
  digest.
- **`retibe_scenarios`** — a multi-actor scenario also carries `actors` and
  `multiActor`. `multiActor: false` with a non-empty `actors` is exactly the
  scenario that reads as two participants and runs as one.
- **`retibe_examples search:'multi-actor'`** — four working examples. They sit at
  the end of the index and the default page does not reach them, hence `search`.

What MCP does **not** have: live split frames, and step screenshots tagged by
actor — the engine names a screenshot file after the step, with no actor in the
name. Who performed a step comes from the step result, not from the filename.

---

## 16. Frames: when the form is in an embedded document

A card form, reCAPTCHA, Turnstile, Stripe Elements, any hosted checkout — all of it
lives in an `<iframe>`. A selector in the main document cannot see inside one, so a
step aimed at an embedded form **silently acts on the page around it**.

Give the step a `data.frame`:

```json
{ "id": "fill-card", "action": "web_fill_form",
  "data": { "frame": "iframe#card-form",
            "formFields": [{ "selector": "#card-number", "value": "4111111111111111" }],
            "formSubmitSelector": "#pay-now", "validateForm": true } }
```

### Four forms

```json
"frame": "iframe#card-form"                  // a CSS selector for the iframe itself
"frame": ["#outer", "iframe[name=card]"]     // a chain: a frame inside a frame
"frame": { "name": "payplus" }               // by the name attribute
"frame": { "url": "checkout\\." }            // by URL — this is a REGULAR EXPRESSION
```

A CSS form finds the `<iframe>` with the same machinery as any other selector: comma
alternatives in order, `:contains(text)` works, `visible` is tried before
`attached`. One selector language inside a frame and outside it, not two.

### What a frame scopes, and what it does not

`frame` scopes **element lookups**, for one step, in the current actor's browser.
Nine actions read it: `web_click`, `web_click_submenu`, `web_fill_form`,
`web_assert`, `web_extract`, `web_wait`, `web_scroll`, `web_login`, `web_evaluate`.

| | |
|---|---|
| `web_assert` with `selector` | looks inside the frame |
| `web_assert` with `urlContains` / `title` | **no** — a URL and a title are page-level facts |
| `web_evaluate` | runs in the frame's context. This is the **only** way to run script in a cross-origin frame: `page.evaluate` cannot reach into one at all |
| `web_scroll` | scrolls the frame's document, not the window |
| `web_login` | fills the form inside the frame, but the navigation and any URL wait stay on the page |
| `touch_tap`, `touch_swipe` | **no** — they work in viewport coordinates, there is nothing to scope |
| `page_element`, `page_method` | **no** — the selector comes from the PageObject registry |
| inside a fixture | **ignored**: fixtures run on their own reduced dispatcher. Keep frame work in the scenario |

The validator rejects every "no" above — it does not stay quiet.

### It fails loudly, on purpose

A frame that cannot be resolved fails the step immediately with `frame not found`,
naming what was searched for and where. That is a **different diagnosis** from an
element that was not found, and it is fixed differently. If `frame` points at
something that is not an iframe, it says so: `"#notaframe" matched an element in the
page, but it is not a frame`.

Resolution budget: `data.frameTimeout`, else `data.selectorTimeout`, else
`data.timeout`, else 10000ms.

### The field name matters

The engine reads **`frame`** and nothing else. `frameSelector`, `iframe`,
`frameName`, `frameUrl`, `frameLocator` and `iframeSelector` are **not read**. And
because `web_fill_form` swallows per-field errors by default, a scenario using one
of those names produces a **green run of a payment that never happened**. That is
why the validator treats each of those names as an **error**, not a warning.

### The storyboard shows it

```
#  id                   action          target
3  frame-is-there       web_assert      iframe#card-form ▸ #card-number
4  fill-card            web_fill_form   iframe#card-form ▸ {formFields, v…
8  shop-shows-receipt   web_assert      .order-receipt
```

The `▸` prefix shows which steps went into another document and which stayed on the
page. In a forty-step scenario there is no other way to see it.

A worked example: `retibe_examples name:'payment-iframe-checkout-example.json'` — a
card payment through a frame, from checkout to receipt. Details in `retibe_docs
section:'frames-iframes'`.

---

## 17. The coverage map in your repository

The platform knows which scenarios exist. It does not know **why** they are the way
they are, or what was deliberately left uncovered. That knowledge belongs in the
project, next to the code the tests are checking.

Keep a `docs/TEST_COVERAGE.md` in the repository, and maintain it as part of the
work rather than "at some point later".

### Why `retibe_scenarios` is not enough

The listing returns a name, a description, a step count and a modification date.
That falls short for reasons that are all practical:

- **A new thread starts from nothing.** It reads a file in the repository for free,
  along with the code. The platform listing is a call, and one it has to think of
  making first.
- **The numeric `id` is per-instance.** `retibe.com` and `retibe.ru` have separate
  databases: scenario 242 on one has nothing to do with 242 on the other. The
  document names the instance explicitly, or the number means nothing.
- **The platform stores no "what is missing".** What was decided against and why,
  what was tried and abandoned — there is no field for it. And that is the most
  valuable knowledge in the file: without it the next thread spends a day on
  something already ruled out.
- **The document is versioned with the code.** A change to the registration form
  and a change to its coverage line land in the same commit, and review can see
  whether the test was updated.
- **Checklist links break silently** (section 10). If the file records which case
  is tied to what, a mismatch at least becomes visible.

### What goes in it

A table, one row per scenario:

| Column | What it holds |
|---|---|
| `id` | the stable string id from section 14 — `shop-checkout-guest-purchase` |
| instance | the instance and the numeric id: `com:242`. Two instances, two rows |
| steps | from the listing; a mismatch with reality is the first sign the file has fallen behind |
| checklist | the checklist and case ids, if linked; `—` if not |
| what it pins down | one sentence: what is broken on the site if this scenario goes red |

Below the table, a section headed **"Not covered, and why"**. It matters more than
the table: the table can be rebuilt from the platform in one call, that section
cannot be rebuilt from anywhere.

Put the limits you found by experience there too: "logging in after registration is
not checked — the platform demands email confirmation and there is no bypass",
"stopping the widget is not checked — open bug such-and-such". Every line like that
is a day of someone's work saved.

### When to update it

The rule is simple: **you saved a scenario, you update the file in the same pass.**

- after `retibe_save_scenario` — the table row;
- after `retibe_checklist_item op:'link'` — the checklist column;
- when you decide against covering something — a line under "Not covered, and why",
  immediately, while the reason is fresh;
- when a scenario is deleted on the platform — remove the row rather than leaving a
  ghost behind.

Worth writing into the project's `CLAUDE.md` as well, so the rule holds in every
thread and not only the one that happened to remember it.

### An honest caveat

Nothing forces this file to be true. Not the platform, not the validator, not a
test — it drifts out of step exactly as silently as a checklist link does. So:

- **on facts, the endpoint wins.** Reconcile with `retibe_scenarios` for the table
  and `retibe_checklists verify_links:true` for the links;
- **on intent, the document wins.** Why a scenario is shaped the way it is, and
  what it deliberately leaves out, exists nowhere on the platform.

Every few sessions it is worth asking: "reconcile `docs/TEST_COVERAGE.md` against
`retibe_scenarios` and show me the differences." That is one call and a couple of
minutes.

---

## 18. When something is wrong

| Symptom | Cause |
|---|---|
| `Missing API token` | The header did not arrive — check `--header` in the connect command |
| `Invalid, revoked or expired` | The token is revoked or wrong — or was issued on the other instance, see section 1 |
| `Usage limit exceeded` | The platform refused the run: web_tests or security_scan ran out, visible in `retibe_whoami` |
| `This account has used N of M API requests` | A gateway refusal on the api_requests quota — a separate counter, spent by the AI scenario generator rather than by MCP calls |
| The test is green but checks nothing | Run `/harden` over the scenario |
| `infra_error` | The scenario is not at fault; look at `retibe_doctor` and `retibe_whoami`, then try later |
| `... is missing from this checkout` | The endpoint is on an image older than `d1069cc` — `knowledge/` was not in it. Needs a redeploy, see section 5 |
| `No examples in this checkout` | The same thing, about the `examples/` directory |
| The validator says something different from this file | The endpoint has fallen behind the repository — deploys are manual, see section 5 |
| A case is linked but no results arrive | The run was local, or the key did not match. `retibe_checklists verify_links:true`, then check `scenarioKeySource` (section 10) |
| `Case N is not in checklist M` | A case id from a different checklist. The tool verifies membership before writing — the platform did not |
| `... already points at "..."` | One checklist points at one scenario. Either `confirm:true` and move every case, or use a separate checklist |
| `"step-N" is not a step id` | The step has no `id` of its own, or a different one. The engine addresses a step by `id || action`, as saved |
| `"..." is not a scenario hash` | A runId or a scenario id went into `retibe_history`. Call it with **no arguments** for the index of real `historyId`s |
| `Invalid arguments for prompt ...` | The prompts take required arguments: `/author` needs `url` and `goal`, `/debug` needs `runId`, `/harden` needs `scenarioPath`. Section 7 |
| `search:` finds nothing though the scenario exists | Search matches the name and description, not the `id`. The prefix has to be in the name — section 14 |
| The storyboard says nothing can go red | The scenario checks nothing. Go through every verdict from section 11 — this is not a false alarm |
| `steps` is empty while the run is going | No engine message matched a step-boundary pattern, or the progress channel did not answer. See `timelineCaveats` and `waitUnsupported` |
| "nothing is known about this run" | The progress channel did not answer — an old image, or Redis unreachable. This is **not** "the run is quiet"; the run's state is still honest |
| `wait_for:'failure'` returned but the run continues | By design: the engine logs at error level on recoverable retries too. Only `state` is a verdict |
| A step "filled" the payment form and there was no payment | The field is named `frameSelector` / `iframe` / `frameName` instead of `frame`. The engine reads only `frame`, and `web_fill_form` swallows per-field errors — hence a green run. The validator calls it `FRAME_FIELD_IGNORED` (an error) — section 16 |
| `frame not found: no element matched …` | The frame was not on the page by the time the step ran. This is NOT an element timeout: the step failed resolving the document. Put a `web_assert` with `frame` and `frameTimeout` before the work — section 16 |
| `… matched an element, but it is not a frame` | `frame` points at an ordinary element. Aim it at the `<iframe>` itself |
| `web_evaluate` in a frame returns an access error | Without `frame` the script runs in the page context, and a cross-origin frame is unreachable from there by design. Set `frame` and the script runs in the frame itself |
| `FRAME_NOT_SUPPORTED` | The action cannot take a frame: the coordinate `touch_*` actions, `page_element`/`page_method`, URL and title assertions, and any step inside a fixture. The nine that do read it are listed in section 16 |
| The run is green but there was no second browser | The scenario has `actors` and no `webrtc.enabled: true`. The engine needs both. The validator calls it `ACTORS_WITHOUT_WEBRTC` — section 15 |
| A step acted as the "wrong" participant | `actor` names an id that is not declared. The engine warns in the log and runs it in the first actor's browser, and the step passes. Look for `UNKNOWN_ACTOR` and check the id against the `actors` keys |
| Steps show no actor although there are two | The engine's switch line was not recognised. Attribution is read from the log prose, so a gap means "unknown", not "the previous one" |
| `forceHeaded: true` but the browser is headless | Headed does not come up on the platform: the image has Xvfb, `DISPLAY` is unset. Two browsers still run — section 15 |
| No split-view frames over MCP | There will not be any: they are images and are dropped before the log. The roster and the switches do arrive — section 15 |
| `You already have a checklist named …` | Names are not unique, and the platform's import looks them up by name. Edit the existing one by id, pick another name, or pass `confirm:true` — section 10 |
| A case was created but gets no automatic result | A new case is linked to nothing. `retibe_checklist_item op:'link'` — section 10 |
| A checklist vanished from the UI filters | `status` is free text, but the UI only knows `active` and `archived` |
| `Scheduler service is not available` | An endpoint older than `f5a3620`: before it, `run_now` worked only on the worker holding the scheduler, and through MCP essentially never. Needs a redeploy (section 13) |
| A schedule was created but did not fire | Check the `nextRunAt` from the response. The usual cause is a missing `timezone`, making 09:00 mean 09:00 UTC |
| The schedule exists but is not on the calendar | Its frequency is `cron` — the platform returns no entries for it. Recreate it as `daily`/`weekly` |
| A scenario is in `TEST_COVERAGE.md` but not on the platform | The file fell behind, or the scenario was deleted. Reconcile with `retibe_scenarios` and fix the row — on facts the endpoint wins (section 17) |
| The scenario was edited but the schedule runs the old one | A schedule holds a **copy** of the steps from when it was created. Update it with `op:'update'` and the same `scenario` |
