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

**From your own machine (stdio).** If you have the ReTibe repository checked out,
connect over stdio instead: scenarios, parameters and history still come from the
platform, but the runs execute on your machine — fast, free, and without spending
your monthly quota (that is `composite` mode, which turns itself on as soon as
`RETIBE_BASE_URL` is set).

```bash
claude mcp add retibe \
  --env RETIBE_BASE_URL=https://retibe.com \
  --env RETIBE_TOKEN=rtb_... \
  -- node /absolute/path/to/TestBot/bin/retibe-mcp.js
```

It is the same token, from the same profile page. It takes precedence over
`RETIBE_EMAIL` / `RETIBE_PASSWORD` / `RETIBE_COOKIE`: set them alongside a token and
they are ignored, and the server says so on stderr. The mode and the credential are
both in the first log line:
`backend=composite (runs local, reads https://retibe.com auth=token)`.

To check the connection, ask Claude to call `retibe_whoami`. It should return your
email, your plan, what is left of your quotas, and `build.sha` — the commit the image
answering you was built from (why that matters is section 6).

**Calls are rate-limited.** The public endpoint cuts the rate before it checks the
token: 60 calls a minute per token (bursting to 30) and 120 a minute per address
(bursting to 60). A refusal is HTTP 429 with a `Retry-After` header and the code
`-32004` — that is "wait this many seconds", not "the quota ran out" (`-32003`) and
not "the token was revoked". Retrying in a tight loop only keeps the bucket empty.

**Everything a token writes is logged.** Every non-GET request made with a token
leaves one row: time, method, path, response code, the token's name, and the id of
whatever it touched — a scenario, a schedule, a checklist case, a parameter, a run.
Request bodies are not logged: scenario steps, parameter values and secrets never
reach the log, because it is taken before the body is parsed. Browsing under a cookie
adds no rows — there it is already obvious who acted. The log is kept for 90 days (on
your own installation, `API_AUDIT_RETENTION_DAYS`) and is read with `retibe_audit`
(section 8). Which credential a session is using shows in `retibe_whoami` as
`authVia`: `api_token` or `session`.

---

## 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** — but it sees the page BEFORE the scenario is written: `retibe_inspect_page`, section 5 |
| 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: reconnaissance no longer has to be borrowed.** ReTibe now has both
halves of it, and on the platform both look at the page with the same environment,
the same parameter store and the same egress IP a run will use:

- **`retibe_crawl`** — the site map: which pages exist, at what depth, what their
  titles are, and how many links, forms, buttons and inputs are on each. This is
  where you start when you were given a domain rather than a page.
- **`retibe_inspect_page`** — the markup of ONE page: headings, form fields with
  their labels, buttons, links, iframes with a ready `data.frame` value, and 1–2
  selector candidates per element, each verified to match exactly one element.

The difference between them matters: **a crawl returns counts, an inspection returns
selectors**. "Two forms on /checkout" is a reason to open /checkout; it is not
something you can put in a step.

Playwright MCP is still good for interactive work — "click around and show me what
happens". It is no longer where you go for a scenario's selectors: only the run's own
browser sees the page a run will see. 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
`retibe_inspect_page`, pin it down in ReTibe, and use Playwright to explore by hand
whatever is not in a scenario yet.

---

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

> Look at https://shop.example.com/checkout with `retibe_inspect_page` and tell me
> which selectors address the card fields. Do not guess.

> Run webtest-123 failed. Diff it against the last green run and tell me what changed.

> 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 can look at the page now (`retibe_crawl`, `retibe_inspect_page`),
but what on it matters is something only you know.

---

## 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_crawl`** — when you were handed a domain rather than a page: the site
   map, to decide what the scenario should be about at all (section 5)
3. **`retibe_inspect_page`** — look at the page you picked: headings, forms with
   their labelled fields, buttons, links, iframes, and selector candidates verified
   unique. It costs one unit of the separate, cheaper `inspections` quota, not
   `web_tests` (section 5)
4. **`retibe_actions`** — the action catalog, and the contract of each action it intends to use
5. **`retibe_reference`** — cross-cutting mechanics (templates, conditions, fixtures)
6. **`retibe_param`** — if the scenario needs a login, a password or a base URL,
   create them now (section 19). Parameters first, scenario second
7. write the scenario
8. **`retibe_validate_scenario environment:'…'`** — before a browser starts; takes
   milliseconds and costs no quota
9. **`retibe_validate_scenario view:'storyboard'`** — "which of these steps can actually go red?"
10. **`retibe_run_scenario`** → **`retibe_run_result`**

Steps 2–3 are not a luxury; they are the cheapest part of the cycle. A guessed
selector costs a minute of browser time and one `web_tests` per attempt.
Reconnaissance answers the same question for one `inspections` unit, and there are an
order of magnitude more of those. Paste the selectors from the answer verbatim.

`retibe_inspect_page` shows the whole page by default, but the answer is cut to an
8 KB budget: `counts` says how many elements the page really had, and `truncated`
says what was dropped to fit. To look at one part of it, narrow with
`selector:'form#checkout'` — cheaper in context and more precise. Candidates are
still checked for uniqueness against the whole document rather than the subtree, so a
selector taken from a narrowed inspection stays valid in the scenario.

Step 8 cannot be skipped. The platform **does not validate scenarios** — it will
accept a broken one and keep running it. With the `environment` argument the
validator also runs a preflight: every `{{params.*}}` in the scenario is checked
against that environment's real store, and anything missing comes back as
`PARAM_KEY_MISSING`. That matters because a key that does not resolve **does not fail
the step**: the resolver leaves the placeholder as it is, and the login field
receives the text `{{params.auth.email}}`. Without the argument the check does not
run, and the response says so in an `unchecked` field rather than staying silent —
the details are in section 19. Either way no browser starts and no quota is spent.

Step 9 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 7 —
**`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 15. Read it **before** item
7: 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 18). In the same pass, not "later": the thread
that comes next starts from that file.

A save is undoable, and you can lean on that. Every save that changes the steps, the
name or the description leaves a snapshot behind: `retibe_save_scenario` answers with
a `revision`, and `retibe_scenarios {id:47, revisions:true}` shows the last twenty
edits, each with a step diff. Undoing one is a single command:
`retibe_save_scenario {op:'revert', id:47, revision:4, confirm:true}`. Without
`confirm` the revert refuses and shows the diff it was about to apply, so looking
before you leap costs nothing. The state a revert replaces becomes a revision in turn
— so the revert is undone by the same call. None of which is a reason to skip
validating before you save: revisions keep twenty edits and know nothing about runs,
checklists or `historyId`.

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

---

## 5. Reconnaissance: the site map and the page markup

The most expensive mistake in this product is a guessed selector. The agent writes
`#submit-button`, the page says `button.btn-primary`, the run goes red on step one,
and a minute of browser time plus one `web_tests` has been spent on a typo. Two tools
close that before the run.

### `retibe_crawl` — which pages exist at all

It answers the question that comes first when all you have is a domain: what should
the scenario even be about. It walks the site from the given URL and returns the map
— every page it reached, its depth, its title, and counts of links, forms, buttons
and inputs.

```
retibe_crawl url:'https://shop.example.com'
retibe_crawl url:'https://shop.example.com' max_pages:20 max_depth:2
retibe_crawl crawl_id:'…'
```

| Argument | What it does |
|---|---|
| `url` | where to start. Required unless `crawl_id` is given |
| `crawl_id` | read a crawl an earlier call started, instead of starting a new one |
| `max_pages` | how many pages to visit, 1–20, default 10. Your plan may lower it — the answer echoes what was granted |
| `max_depth` | 0 = the start page only, 1 = its links, 2 = theirs. Same-domain links only |
| `wait_ms` | how long to wait for the crawl, 0–55000, default 50000 |

**It returns counts, not selectors.** "Two forms and nine inputs on /checkout" is a
reason to open /checkout with an inspection; it is not something you can put in a
step. There is not one verified element address here, and there cannot be: the
crawler counts, it does not address.

A page takes up to thirty seconds, so the tool waits `wait_ms` and, if the crawl is
not done, hands back a `crawl_id`; a second call with it reads the finished map.
Pages that would not load come back in a separate `deadPages` list — they are never
mixed in with empty ones. Pages classed as SPAs come back **without** counts (`—`,
not `0`): performance metrics are taken off them instead of markup. One crawl at a
time per account; a second gets a 409.

### `retibe_inspect_page` — what is on the page, and how to address it

It opens one page in a headless browser and returns a compact outline: headings,
forms with their labelled fields, buttons, links, iframes (with a ready `data.frame`
value), and for every element 1–2 selector candidates, each verified unique at
capture time (`querySelectorAll().length === 1`). Paste those selectors verbatim.

| Argument | What it does |
|---|---|
| `url` | required; may still carry `{{params.*}}`, which the platform resolves |
| `environment` | which parameter environment to resolve in |
| `selector` | scope the outline to a subtree, e.g. `form#checkout` |
| `maxElements` | per-list ceiling, 1–100, default 40 |

The answer is capped at 8 KB: `counts` says how much the page actually held,
`truncated` says what was cut. `scopeNotFound: true` means the `selector` matched
nothing — empty lists then mean "nothing was looked at", not "the page is empty".
Candidates are checked for uniqueness against the whole document rather than the
subtree, so a selector taken from a narrowed inspection stays valid in the scenario.

One inspection at a time per account; a second gets `INSPECT_IN_FLIGHT` — the answer
arrives within forty seconds, so that is "wait", not "broken".

### Reconnaissance has its own, cheaper quota

On the platform a call bills one unit of the `inspections` counter, never
`web_tests`: 60 a month on Free, 600 on Professional, unlimited on Corporate — an
order of magnitude above the run quota, precisely so an agent never economises on
looking. What is left shows in `retibe_whoami` as `inspections: 3/60`. It is charged
only after a navigation actually happened: a typo in a URL, a refused host or a dead
DNS record costs nothing. `retibe_crawl` spends no `inspections`; your plan caps it
by page count instead.

### The platform versus your own machine

On the platform the page is opened with the same environment, the same parameter
store and the same outbound IP as the run. `retibe_inspect_page` also works without a
platform — the local backend opens the page with a browser on your machine — but that
is a weaker claim, and the answer says so with `source: 'local'`: your network and
your IP, no quota at all, `{{params.*}}` unresolved (a templated URL is rejected
before the browser starts), `localhost` allowed, and a Chromium required on the
machine. `retibe_crawl` has **no** local equivalent.

What exactly changes in each of those, and why reconnaissance in `composite` mode
still goes to the platform (`source: 'remote'`) —
`retibe_docs section:'local-vs-platform-inspection'`.

### What reconnaissance does not do

Selectors were unique **at capture time**. A page that renders differently per
session will break them — so prefer candidates built on `id`, `data-testid` and
`name` over `nth-of-type` paths.

The platform does not open internal network addresses: a URL that resolves into a
private range is refused before the browser starts, and so is every redirect the page
tries to follow. Exactly what is blocked is in section 9.

---

## 6. 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 names, `required`, aliases | the engine's AST: every `data.*` read inside each `case`, following delegates and their parameter types | **high** — a test re-extracts on every run and fails when the engine reads a field no contract describes |
| `data.*` field descriptions, traps, examples | written by hand against the engine source | **medium** — hand-maintained; a new field arrives as "(undocumented — read by the engine, not yet described)" until someone describes it |
| `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 18) |
| tool descriptions | written by hand | **low** — connected to the engine by nothing |
| the `/author`, `/debug`, `/harden` prompts | written by hand, but guarded by a test | **medium** — the test checks that every tool a prompt names is registered on that backend, that the key steps are present, and that the text branches on the backend correctly; nothing checks the quality of the wording |

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

The `data.*` field list is reliable for the same reason: it is extracted from the
engine's case bodies, delegates and delegate parameter types included. A field shown
by `retibe_actions` as "(undocumented — read by the engine, not yet described)" is one
the engine really reads and nobody has written up yet — it works; check the engine for
what it means. The gate is one-directional wherever the engine hands `data` to a
helper whole (`web_assert`, `api_request`): for those actions the field list may be
incomplete.

Everything else is prose that may have fallen behind the code — field descriptions
included: their names are checked against the engine, their meaning is not. Believe it
until the first contradiction with observed behaviour; when they disagree, the engine
is right.

**`retibe_docs` and `retibe_examples` work on `retibe.com/mcp`** — as of
`d1069cc`. Before that commit the image was built without the `knowledge/` and
`examples/` directories, and both tools answered every call with an error; now the
Dockerfile puts them in `/app`, which is where the server resolves them from
(`repoRoot = RETIBE_REPO_ROOT ?? cwd`). If you still see `... is missing from this
checkout`, the endpoint is running an image older than that commit, and the cure is a
redeploy, not an edit to the scenario.

**The public endpoint can lag the repository — and now you can see it.** Deploys are
manual: `git pull` plus a container rebuild, no CI/CD, and nothing checks the
boundary. But the endpoint reports its own build: `retibe_whoami` returns
`build: { sha, version, startedAt }`, and `GET /health` carries a `build` field.
The same answer has `publicBase` — the address every link in a reply is built from
(report, screenshots, JUnit, Allure) — and `platform`, the address the endpoint
itself calls the platform on; inside the deployment that is `http://app:3000`, and it
is supposed to be. Compare the build with the repository:

    git rev-parse --short=12 origin/main

A match means the endpoint's behaviour *is* main's behaviour, so a contradiction with
this file is a bug in the code. A mismatch means the fix simply has not shipped, and
there is nothing to debug. `build.sha: "dev"` is an image built without the stamp — a
local run, or a hand-rolled `docker compose build` that bypassed deploy.sh;
`build.version` is the package.json version, and `build.startedAt` is when the process
started, so a fresh sha with an old `startedAt` means the image was rebuilt and the
container never restarted. The same build is visible before the first tool call: the
`initialize` reply's `serverInfo.version` reads `1.0.0+<sha>`.

---

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

**Selectors have two engines, and the syntax depends on which one reads them.** The
same string is parsed differently in `web_click` and in `web_wait`:

| Path | Fields that travel it | `:contains('text')` | Playwright syntax (`:has-text()`, `text=`, `xpath=`, `role=`) |
|---|---|---|---|
| **A — the engine's own resolver** (`findElementBySelector`) | `web_click` (`selector`, `buttonSelector`, `waitForSelector`), `web_click_submenu` (`submenuParentSelector`, `submenuSelector`, `waitForSelector`), `web_fill_form` (`formFields[].selector`, `formSubmitSelector`) | **works**, but only in the exact shape `tag:contains('text in quotes')`: quotes required, nothing after the closing bracket | works — anything that is not `:contains()` is handed to Playwright unchanged |
| **B — straight to Playwright** | everything else: `web_assert.selector`, `web_extract.selector`, `web_wait.waitForSelector`, `web_login.loginSelectors.*`, `web_fill_form.successSelector` and `errorMessageSelectors[]`, `webrtc_verify_stream.videoSelector`, `page_*`, `condition.selector`, `skipIf.condition.selector`, `data.frame` | **a syntax error** — Playwright has no such engine and rejects the whole selector | works |

Path A also splits the string on commas and tries each alternative in turn, so a
comma INSIDE `:has-text("a, b")` or `:is(a, b)` is read as a separate alternative and
breaks the selector. Inside a `use_fixture` body there is no path A at all: the
fixture dispatcher clicks and fills with bare Playwright calls, so every selector
there is path B, and `button:contains('Login')` — perfectly fine in a scenario —
fails the step in a fixture.

The validator knows this table field by field and answers with three codes, all
**warnings**: they name a step that will break, but they do not stop a save or a
run, because these rules are younger than the scenarios they judge and a stored
scenario that ran yesterday must still run today. Run
`retibe_validate_scenario strict:true` while authoring and they block again.
The three are `SELECTOR_UNSUPPORTED_PSEUDO` (`:contains()` on path B),
`SELECTOR_CONTAINS_BAD_SHAPE` (`:contains()` on path A but in a shape the helper
cannot translate — the engine **drops that alternative without ever trying it**, and
the step fails "nothing found" after the full timeout) and `SELECTOR_JQUERY_PSEUDO`
(`:eq()`, `:first`, `:input`, `:checkbox`, `:hidden` and a dozen more — these are
jQuery, not CSS, and no path translates them). `:has()`, `:is()`, `:not()`,
`:first-child`, `:checked`, and Playwright's own `:visible`, `:has-text()`,
`:text()`, `:nth-match()` are valid, and the validator says nothing about them.
Values that are not CSS at all (`xpath=…`, `//…`, `text=…`, `id=…`, `role=…`) and
any string containing `{{` are not judged.

The same codes also come out of `web_evaluate`: the validator reads the string
literals `data.script` passes to `querySelector`, `querySelectorAll`, `closest` and
`matches`, and judges them as path B — only harsher, because there is not even
Playwright between that string and the browser. It catches `:contains()` and the
jQuery pseudo-classes there; `:has-text()`, `>> nth=` and `:nth-match()` do not work
in a script either, but the validator says nothing about them there — keep that in
your own head. A variable, a template literal or a concatenation in place of a
literal is not judged. `page_method` selectors are not judged at all: they live in
the page-object store, not in the scenario.

**Absence is asserted with `data.absent: true`.** It waits for Playwright's `hidden`
state, which means **detached from the DOM OR present but not visible**
(`display: none`, `visibility: hidden`, an empty box) — exactly "the popup closed",
"the error went away". That is deliberate: modals are hidden far more often than they
are removed, and an assertion that insisted on removal would go red on every other
site. The step fails honestly with `Timeout <timeout>ms` while the element is still on
screen — break it on purpose and watch it.

Two conditions. First, it must be the boolean `true`: `"true"`, `1` and `"yes"` leave
the ordinary visibility check in place, so the step starts asserting the exact
opposite of what was written; the validator catches that as
`ASSERT_ABSENT_NOT_BOOLEAN`. Second, with `absent: true` the text is **not** checked —
the engine returns before the comparison, so `contains`/`expectedText` are dead weight
on such a step (`ASSERT_ABSENT_IGNORES_TEXT`).

`visible: false` is **not** absence: it only weakens the wait to `state: "attached"`,
and the element must still be in the DOM. The field remains for `<head>` elements and
for hidden nodes that are supposed to be there.

```json
{ "id": "popup-gone", "action": "web_assert",
  "data": { "selector": ".popup", "absent": true, "timeout": 5000 } }
```

**`web_evaluate` is also reconnaissance.** Whatever the script returns now reaches the
step result, and from there the digest's `extracted` (section 9) — not only the log.
`web_extract` values land in the same place. `step.storeAs` still chains **nothing**
between steps — the `STOREAS_DEAD` warning stays true — but it does now label the
value by name in `extracted`. To pass a value from one step to the next it is still
`data.storeResponseAs`.

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

**A failing step saves a screenshot by default** — `retry.screenshot: 'on_fail'`
applies even when the step carries no `retry` block at all: after the final failed
attempt the engine captures the viewport and puts the path in that step's
`screenshot`, where the HTML report, the `retibe_run_result` digest and
`detail:'screenshot'` (section 9) all pick it up. Turn it off with
`retry: { "screenshot": "never" }` on the step — worth doing where failures come in
bulk, since the frame costs up to 15 seconds on a hung page and 100–300 KB on disk,
and the screenshots directory has no retention policy. The other values: `'on_retry'`
captures a frame before every re-attempt (into `screenshots[]`, which the digest does
not show), and `'always'` does both.

**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 inside `data` is the most natural mistake, and until now it was the most
expensive one: the validator did not look there. It does now —
`DATA_FIELD_NOT_READ`, with a fix saying that in a scenario the config lives on the
STEP and only together with `data.screenshotFullPage`. Inside a fixture body
`data.visualRegression` does work: that is a different dispatcher, and it reads both
places.

**`{{faker.person.firstName}}` — the right name from the wrong library.** The engine
resolves `{{faker.*}}` against its own list of categories and maps `name.*` onto
@faker-js/faker's `person.*` and `address.*` onto its `location.*` itself. The
package's modern names therefore do not work here, and they fail silently:

- a path whose category is unknown comes back as the **literal text** — the form
  under test is filled with `{{faker.person.firstName}}`, character for character,
  and the step is green;
- a known category with an unknown method comes back as **that category's default**,
  because every inner `switch (method)` has a `default:`. `{{faker.internet.emial}}` produces
  a perfectly valid e-mail address, and nothing anywhere says the data is not what
  you wrote.

The validator catches both: `FAKER_PATH_UNKNOWN` ("is left in the string literally")
and `FAKER_METHOD_UNKNOWN`, which names the value the run will actually use. The same
path in thirty steps is one warning that lists the steps. The full list of
categories, methods and defaults is
`retibe_docs {section:'faker-test-data-generation'}`. The method-less shorthands
(`{{faker.uuid}}`, `{{faker.email}}`, `{{faker.city}}` and ten more) work; any other
category written without a method falls to its own `default:` — `{{faker.name}}`
reads as "a name" and gives you a first name with no surname.

**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:
`NO_ASSERTIONS`, `PHANTOM_ACTION` (with the exact substitute), `CANNOT_FAIL`,
`ASSERT_FIELD_IGNORED`, `VR_ON_WRONG_ACTION`, `TEMPLATE_IN_TELEGRAM`,
`DATA_FIELD_NOT_READ`, `FAKER_PATH_UNKNOWN`, `FAKER_METHOD_UNKNOWN`. If Claude
ignored them, ask it to run `retibe_validate_scenario` and go through each one.

The selector codes stand apart — `SELECTOR_UNSUPPORTED_PSEUDO`,
`SELECTOR_CONTAINS_BAD_SHAPE`, `SELECTOR_JQUERY_PSEUDO`: unlike the rest of the
warnings, such a step does not "pass while checking nothing" — it throws, or
silently switches off the one check it was written to make. They are warnings all
the same, and deliberately: a rule added this year must not make a scenario stored
last year unrunnable and unsaveable. Treat them as errors while authoring by
calling `retibe_validate_scenario strict:true`, which is what `/harden` does. And
one code is odd:
`PARAM_KEY_MISSING` appears only when the validator is called with `environment` —
otherwise there is nothing to check against, and the response says plainly that it
did not check (section 19).

**A scenario that checks nothing whatsoever is now caught.** Open, fill, click, and
not a single assertion: that is the `NO_ASSERTIONS` warning, and it is always first
in the list so that the 40-finding cap can never be what removes it.

"Checks an outcome" here means exactly what the engine can go red over: a
`web_assert` with `contains` (or with `absent: true`), `web_assert_url`,
`web_assert_title`, a `web_wait` with `waitForSelector`/`waitForUrl` (unless
`failOnTimeout: false` switched the failure off), `web_fill_form` with
`validateForm: true`, a `web_evaluate` whose script contains a `throw`, `web_login`
with `loginSuccessUrl`/`loginSuccessSelector`, `api_request` (any non-2xx fails the
step), `page_element` with `assertVisible`/`assertText`, `webrtc_verify_stream`,
`webrtc_wait_connection` — and in Telegram, a step-level `expectedResponse` and
`wait_for_message`. Not counted: `waitForSuccess` + `successSelector` and
`expectedUrl` on `web_fill_form`, and `waitForSelector` on `web_click` — the engine
only broadcasts a warning for those and the step still passes. When the only
candidates are a `use_fixture` or a `page_method`, the warning says so: their body is
not visible to the validator, so neither "yes" nor "no" would be honest.

**A `data` key the engine never reads** now has a name of its own:
`DATA_FIELD_NOT_READ`. The dispatcher spreads `data` wholesale into the case body, so
an unknown key costs nothing, raises nothing and does nothing — the step goes green
having done less than the JSON says. One mistake repeated across thirty steps is
reported as one warning listing those steps, not thirty; the `fix` is either the
near-miss ("Did you mean data.selectorTimeout?") or the exact answer from the engine
(`apiDelay` is read only by the Telegram `api_request`; `expectedStatus` only by
`api_test`). The analyzers — `accessibility`, `seo_analysis`,
`performance_analysis` — share one answer: they take no per-check switches and always
run their full built-in set.

**Two things it still cannot catch, as a matter of principle** — keep them in your
own head:

- **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 — and what it says is "the element is there, possibly
  hidden". If absence was meant, the field is `absent: true`;
- **the contents of a fixture** — the body of `use_fixture` is not visible to the
  validator.

The `/harden` prompt and `view:'storyboard'` (section 11) are still worth running:
the storyboard shows the same thing step by step — which steps can go red at all, and
which of those check an outcome rather than the mechanics.

### Every validator code

56 codes: 21 errors, 34 warnings, and `FRAME_BAD_SHAPE`, which is either one
depending on the shape of the field. An error means `retibe_save_scenario` will
refuse to save (`Refusing to save: N error(s)`) and `retibe_run_scenario` will refuse
to run (`Scenario is invalid (N error(s)); nothing was run.`); a warning means the
step saves, runs and passes having done less than the JSON says.
`retibe_validate_scenario strict:true` turns warnings into errors — but only in the
validator's own answer: saving and running do not read `strict`, and they always
refuse on errors and never on warnings.

The codes you will meet most often are explained above, in this section. The full
table — grouped by scenario and step shape, selectors, assertions, templates, frames,
actors and parameters — is one call away: `retibe_docs section:'validator-codes'`.

When a finding names a code you do not recognise there is a shorter path:
`retibe_validate_scenario view:'codes'` takes no scenario and answers with the list —
code, level, one line each — straight out of the validator. It is the same set as the
table and cannot fall behind it: the build fails if the two disagree.

---

## 8. 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 **local** run environment; the public endpoint does
not offer it at all — runs there execute on the platform, whose container this
session cannot inspect or modify). Their reliability differs — see section 6.

**Reconnaissance:** `retibe_crawl` (the site map — which pages exist and how many
forms, buttons and inputs are on them), `retibe_inspect_page` (one page's markup,
with selectors verified unique). A crawl returns counts, an inspection returns
selectors; only the second can go into a step. Section 5.

**Work:** `retibe_validate_scenario` (plus `view:'storyboard'` — section 11, and
`environment` — checks `{{params.*}}` keys against the real store, section 19),
`retibe_run_scenario` (plus `id`, to run a saved scenario without posting its body
back; plus `from_step` / `only_steps`, to run a slice — section 13),
`retibe_run_status` (plus `wait_for` and `since` — section 12), `retibe_run_result`
(plus `detail:` `log` / `markdown` / `junit` / `screenshot` — section 9),
`retibe_list_runs`, `retibe_compare_runs` (what changed between two runs — section
13), `retibe_stop_run`, `retibe_publish_run` (**local and composite only** — it
publishes a run that happened on your machine into reports and history).
`retibe.com/mcp` does not list the last one and does not need it: a run started there
is in reports, scenario history and the calendar the moment it finishes.

**`retibe_stop_run` stops an account, not a run.** The platform has one stop endpoint
per user: it kills the web test running as *you*, and it has no way to address a
particular `runId`. So the tool reads the active-run listing first, and if the run
actually in flight is not the `runId` you named it **refuses** — the real identifier
is named in the refusal text itself (`Your account is running X, not Y`) and no stop
request is sent. You can still do it: `confirm: true` means "yes, kill whatever is
running". A `queued` job cannot be cancelled either way. Every "did not stop" comes
back as **an error with a text**, not as an object with `stopped: false` — and it is
an answer, not a malfunction: when the platform says `No web test running`, the tool
returns that as an error instead of reporting that it stopped a run that never
existed.

There is a third outcome. The platform runs several workers, and the browser lives
only in the one that started the run, so the stop is sent to it over the cluster
channel. If it does not confirm in time, the answer is an error with `The stop was
sent to the worker running this test, but it has not confirmed yet`. That is
**not** "nothing was stopped": the run may be closing right now. Check
`retibe_run_status` before calling stop again.

**Platform:** `retibe_whoami` (plus `build` — which build is answering — and
`authVia`, which credential this session is using), `retibe_audit` (what an agent
changed, and when), `retibe_env` (plus `secretKeys` — which keys are flagged
sensitive), `retibe_scenarios` (plus `revisions:true` — a scenario's edit history),
`retibe_save_scenario` (plus `op:'revert'` to undo an edit, and `link_to` to wire a
checklist case in the same call), `retibe_history` (plus `detail:'steps'` and
`view:'flaky'` — section 13), `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. How it works, and which keys cannot
be written at all, is section 19.

`retibe_audit` answers "what did an agent change here while I was away". The platform
records every non-GET call made with an API token: method, path, status, entity and
its id. Request bodies are never recorded, so scenario steps, parameter values and
passwords physically cannot reach the trail. That is
also its limit: edits made from the browser, and by any session whose `retibe_whoami`
reports `authVia: "session"`, do **not** appear, so an empty answer means "no token
writes", never "nothing changed". Read-only.

The same trail is visible without the tool: a scenario or schedule row an API token
wrote last carries a `🤖 agent · <date>` badge in the UI, with the token's name in its
tooltip.

The default window, narrowing to a single entity, the rules that put the badge
out, and why tokens should be named for what they are —
`retibe_docs section:'agent-audit-log'`.

Approving in `retibe_visual_review` does not destroy the old baseline — it moves to
`history/<step>_v<N>_<time>.png`. `op:'versions'` shows what is in there, and
`op:'rollback'` with `version` and `confirm:true` puts one back. It is a file
operation: the review row's status does not change, and the difference shows up on
the next run.

**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, run one now, cost it with
`dry_run`, and attach an outgoing webhook). Section 14.

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

| Prompt | Arguments | What it does |
|---|---|---|
| `/author` | `url`, `goal`, and an optional `environment` | walks from page reconnaissance to a saved scenario with a checklist case linked to it |
| `/debug` | `runId` | walks from a red run to the cause: result, comparison with the last green run, the step matrix, the screenshot, the log |
| `/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 | runs the scenario past the blind spots of section 7 and the storyboard |

Each prompt's pipeline step by step — which tools it calls in which order, and where
the text branches on the backend — `retibe_docs section:'mcp-prompts'`.

**`retibe_history` with no arguments returns the index** — which scenarios have a
history at all, and the `historyId` of each. The run listing carries it too:
`retibe_list_runs` returns a `historyId` per run, and `null` there means the run has
no history row. The `historyId` itself is an md5 of the scenario's shape; a scenario
enters the index after its first run **on the platform**.

**An unknown argument is now an error, not silence.** Every tool validates strictly:
an unrecognised key is refused with `Unrecognized key: "<key>"`, and every tool in
`tools/list` carries `additionalProperties: false`, so the rule is
visible before the call rather than after it. The SDK used to drop unknown keys
without a word, and the call then answered a different question:
`retibe_actions {name:'web_evaluate'}` returned the whole 48-action catalog, as if no
action had been named. A confident wrong answer is worse than an error, because it
gets believed. There is one typo that accounts for most of these: `retibe_actions`
takes `action`; `name` is `retibe_examples`' argument.

The repository has **28 tools and 3 prompts** — 28 locally and in `composite`, 26 on
the public endpoint, which registers neither `retibe_doctor` nor
`retibe_publish_run`: both act on the machine that runs the browser. 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, because
deployment is manual (section 6).

---

## 9. Reading the result

**The run's state**

- **`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. Such a run
  does not cost a run: the quota charged at start is given back, so retrying is free
- **`unknown`** — the run has not finished. This is **not** "passed"
- **`kind`** — `web` or `telegram`: what executed the run, not where the digest was
  read from (that is `source`). A `telegram` run has no per-step verdicts by
  construction — the verdict is run-level, and `issues` says so in as many words
- **`resultSource`** — where the digest's steps came from. `stored_result` is the
  engine's own result: selector, action, screenshot, findings and scores. That is the
  usual case — a web run is stored whole. `step_projection` is the platform's thin
  projection: name, status and duration only. Empty `scores` under `step_projection`
  means "not stored", not "scored zero", and `issues` says so in plain words

**How far it got**

- **`stepsObserved`** — a count of step entries. Only `state` says a run finished.
  Exact where the engine published step boundaries as data (those rows are marked
  `✔` in the timeline), still an estimate where they had to be recognised from the
  log prose — see section 12
- **`steps`** — the step timeline with intervals. On a `✔` row the interval is the
  duration the engine reported; on a row without `✔` it is the gap between two
  recognised log lines, which is an estimate. `timelineCaveats` on every response
  says which one you are holding
- **`waitEndedBy`** — what ended the wait. `timeout` means "time ran out", not
  "nothing happened"
- **`next_offset`** — the cursor for your next poll

**Totals and findings**

- **`totals`** — `total / passed / failed / skipped`, plus `unknown`, and only when
  there are such steps. `unknown` is a step whose outcome was never recorded at all
  (a Telegram run stores only the scenario definition on the platform). It is never
  folded into `passed`: `passed: 0` next to `unknown: 5` means "nothing was
  verified", not "nothing passed". Steps with status `broken` or `error` count as
  failures and appear in `failingSteps`
- **`issues`** — the run's findings, most serious first, each labelled with the level
  the engine recorded: `[error] …`, `[warning] …`, `[info] …`. The order is not
  decoration: the engine appends the failed-step record and the critical-stop record
  LAST, behind every info line its analysers produced, so a list cut by arrival order
  dropped exactly the finding it is read for. A line with no prefix is a line nobody
  assigned a level to — the backend put it there itself (`infra_error`, "the run has
  not finished") — and those lines always come first
- **`issuesDropped`** — how many findings did not fit the per-level caps (`error` 10,
  `warning` 5, `info` 3, unlabelled 3). Only the levels that actually lost something
  appear: `{"info": 12}` means "there are twelve more infos", and no field at all
  means there was nothing more
- **`failingSteps[].durationMs`** — milliseconds, as the engine measured them. For a
  run saved before the engine started writing them, the duration is derived from the
  old field by the rule "≥ 100 is already milliseconds", and the reply then carries
  **`caveats`** stating exactly what that rule gets wrong: a step that genuinely ran
  for 100 s or more comes back 1000× shorter. `caveats` is about how the answer was
  produced, not about the site — those lines never join `issues`, and capping the
  reply never drops them. One hole that caveat does not cover: the auto-crawl
  (`mode:'auto'`) writes `durationMs` only from `a87c986` on, and its old field held
  milliseconds, not seconds. So on its runs saved before that, a step under 100 ms
  comes back 1000× **longer** — 40 ms reads as 40 s — and `caveats` says nothing,
  because the rule believes it guessed right

**What the failing step saw in the browser**

- **`consoleErrors`** — up to five browser console lines from a **failed** step:
  `[-1200ms] Uncaught TypeError: x is not a function`. The `[-1200ms]` is how long
  BEFORE the failure the line was logged
- **`networkErrors`** — up to five requests that failed or answered 4xx/5xx:
  `POST /api/login → 500 (-1300ms)`; the status is replaced by the reason
  (`net::ERR_CONNECTION_REFUSED`) when no response ever arrived

Both fields appear on failed steps only, and neither is cut at the step boundary: the
engine keeps a ring of page events for the whole run and copies the most recent
entries into the result. So an error at `-4200ms` almost certainly belongs to the
PREVIOUS step, not this one — that is the normal case, not a bug: the 500 arrives
during `web_click` and the next `web_assert` is what fails. Never read these lines
without their ages; that is how another step's error gets attributed to this one.
Parameter values (`{{params.*}}`) are masked as `***` in these lines, exactly as in
the stored log.

The digest's budget is 4096 bytes, and it is a promise, not a target: the
`truncated` markers are counted inside it. When the answer does not fit, the lists go
first (`issues` to three, `extracted` to three, `visual` to five, `failingSteps` to
three), then the browser context — before the error text, because it costs more:
each step keeps the **two** lines nearest the failure and `truncated` carries
`browser errors trimmed`. If it still does not fit, the context is kept on the first
failing step only (`failure context dropped`); then `links.note` shrinks,
`extracted` goes to one entry, and `failingSteps` and `issues` to one item each.
Below that is the budget floor, where
cutting is by weight: the context comes off the first step too
(`failure context dropped from every step`), `extracted` values and error text are
cut to 120 characters, and `visual` to one row. If the list of cuts itself does not
fit, it collapses into one line, `N cuts were made; …` — which cuts were made is then
lost, that cuts were made never is. `caveats` is never cut. An empty list is never
returned: "no such fields" with no context marker in `truncated` means the browser
was quiet, not that we hid something. The same lines exist outside
the digest: `GET /api/test/reports/<id>/run-result` returns them as
`steps[].consoleErrors` / `steps[].networkErrors`, in the same format and with the
same ages, up to ten lines of each kind.

**What the scenario read, and what it compared**

- **`extracted`** — what the scenario **read** off the page: a `web_extract` value,
  whatever `web_evaluate` returned, a `page_object` output, and the variables a step
  filed under `storeAs`. Each entry carries `step`, `action`, `storeAs` and `value`;
  an entry that came from the run's variable map has no `step`, because no single
  step stands behind it. The key is absent entirely when the scenario read nothing —
  which is not the same as the values having been dropped on the way. A value is
  capped at 2 KB and there are at most ten entries; what was cut says so in
  `truncated: true`, and what did not fit is counted in `extractedDropped`
- **`visual`** — what the run **compared**: one row per step that took a compared
  screenshot. `differencePercent` is the percentage of difference, `match` the
  step threshold's verdict. On the platform the row also carries `comparisonId` and
  `status` — the id and state of the review row that `retibe_visual_review` takes.
  The `visual` key is absent entirely when nothing was compared: an empty list would
  read as "everything matched", which is a claim about a comparison nobody made. A
  comparison whose percentage could not be read is not in `visual` at all — neither
  as zero nor as `null` — and is counted in a `caveats` line instead. Zero is the
  engine's answer for "the images matched", and substituting it for an unknown would
  pass a gap off as a success. The raw `GET /api/test/reports/<id>/run-result`
  behaves the same way: `differencePercent` is absent there when no percentage was
  recorded (the platform projection used to write `0`)
- `match` and `status` answer different questions and diverge normally: `match` is
  the step threshold's verdict, `status` is the server's `VISUAL_REGRESSION_THRESHOLD`
  verdict, which is what decides whether a change enters the review queue.
  `baseline: true` means the run **recorded** a baseline rather than comparing
  against one: its `differencePercent: 0` measures nothing

**Where to go next**

- **`reportUrl`** — the absolute URL of the HTML report on the platform. The raw
  result lives in its database, not on a path any file tool can read, which is why
  `detail:'raw'` on a remote run returns `reportUrl` instead of a path
- **`links`** — every artifact the run left behind: `report`, `junit`, `rawResult`,
  `allure`. Platform runs only. None of them opens anonymously: `report` is
  owner-checked now, and the other three always were. What each one needs is in
  `links.note`, in the same response; when the digest does not fit its budget,
  `links.note` shrinks to one sentence, "none of these opens anonymously", and
  `truncated` gains `links note shortened`. Every one of these addresses, like
  `reportUrl` and the screenshot paths, is built from the platform's public address
  (`https://retibe.com/…`), not from the internal `http://app:3000` the public
  endpoint itself uses to reach the platform
- **`historyId`** — the key `retibe_history` takes (the run's scenario_hash). `null`
  when there is a report but no run row behind it
- **`url`** — the address under test. It is never the scenario name; the name is
  `scenarioName`

Screenshots come back as paths, not as images — except for an explicit
`detail:'screenshot'`. For a run on the platform that path is an absolute URL
(`https://…/screenshots/…`), not the container path `/app/screenshots/…`, which
resolves only inside the runner.

**`retibe_run_result {runId, detail:'screenshot'}`** is the only reply in this server
that carries image bytes, and it never arrives on its own: you ask for it by name. It
returns the failing step's frame — JPEG, about 200 KB, 1024px wide — with a text
block beside it naming the step, its selector, where the file came from, and both
sizes: the original and what was sent. One step per call: the default is the first
failing step that has a frame, and `step:'<id>'` picks a different one. The ordinary
digest, `markdown` and `junit` never carry bytes, whatever the run recorded.

The picture is scaled down: read it for page state and layout, not for pixel detail.
The full-resolution original stays where `source` says. When there is no frame, the
tool says why — a green run is never photographed, and a failing step can lack one if
the run predates the `on_fail` default, the browser had already died when the step
gave up, or the result was read from the platform's step projection (`resultSource`
says which). On the platform `/screenshots` is owner-checked, so the backend fetches
the image itself, with the same `Authorization: Bearer` header its other calls use.

So a scenario is also a way to **read** a page, not only to check it: two steps —
`web_navigate` and `web_evaluate` with the script `document.title` — are enough for
the title to arrive in `extracted[0].value`. On the platform this is read from the
stored result: no second run, and no race against the 15-minute log ring.

**Counters in `retibe_list_runs`.** `passed` / `failed` / `total` come from the
platform's per-run statistics. `null` means "no counters were recorded" — the run
predates per-run statistics, or the platform predates this release — and it is **not**
a count of zero passing steps; a response containing one carries a `note` saying so.
`retibe_run_result` has the real totals in that case. Skipped steps (`skipped`) are counted on
their own and no longer land in `failed`; on older runs `failed` is still inflated by
the number of skips, and that is not recomputed retroactively.

### Taking the result out

`retibe_run_result` returns the digest as JSON. For the places JSON cannot go, the
same digest has three more shapes: `detail:'markdown'` — a ready block for a pull
request, kept under 3 KB; `detail:'junit'` — JUnit XML for CI, generated by the
platform (a local run has none until `retibe_publish_run`); `detail:'log'` — the last
300 lines of the run's log.

The report link is signed: it requires a login on the platform. `/reports` and
`/screenshots` are no longer served to everyone — either a browser session or the
same `Authorization: Bearer <token>` header this session uses. Only a guest run's
report stays public (`/reports/anonymous/…`). For a colleague without an account,
send the link from the Share button in the reports list: it is signed, opens with
the report's screenshots without signing in, and lasts 7 days.

What goes into each shape and what each one is limited to —
`retibe_docs section:'exporting-a-run-result'`.

### 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. A web run
  started while another is going joins the platform's queue (`state: 'queued'`, up
  to ten jobs per account) and starts when the slot frees up; a 409 comes back only
  when the queue is full or switched off on the instance.
- Runs from here spend the same monthly quota as runs started from the web UI. The
  quota is charged at start — otherwise it would not limit anything — and for a
  queued run "start" means **entering the queue**, not the moment it actually runs:
  ten queued jobs are ten `web_tests` spent at once. It is not charged a second time
  when it leaves the queue. A run that never executed (`infra_error`, or a job that
  died in the queue before the runner took it) is given back. A red test costs a
  run: it ran. So does a run you stopped by hand.
- `retibe_run_scenario` reads the remainder **before** it launches and refuses an
  exhausted one itself, with the numbers in the message:
  `web_tests quota exhausted: 20/20` — instead of a 403 that would arrive after the
  scenario had been resolved and the call already made. A successful reply carries
  `quota: {web_tests: '19/20', remainingAfterThisRun: 0}`. If the counter could not
  be read the run is **not** cancelled: a `quotaNote` comes back and the platform
  stays the authority on its own quota. A local or composite run never asks — it
  spends no `web_tests`.
- **Inspecting a page is a separate, cheaper quota.** `retibe_inspect_page` spends
  the `inspections` counter rather than `web_tests`: 60 a month on Free, 600 on
  Professional, unlimited on Corporate. What is left shows in `retibe_whoami` as
  `inspections: 3/60`. One inspection and one crawl at a time per account.
- **Internal network addresses are out of reach.** A URL that resolves into a private
  range — `127.0.0.1`, `10.x`, `172.16–31.x`, `192.168.x`, `169.254.169.254`,
  `localhost`, `*.internal`, IPv6 `::1`/`fc00::/7`/`fe80::/10` — is refused before the
  browser starts, and so are redirects and page subresources pointing at one. Schemes
  other than `http:` and `https:` (`file:`, `data:`, `ftp:`) are refused as well. A
  hostname that fails to resolve counts as blocked, not as allowed. These refusals do
  not spend quota. A self-hosted install can lift the check with the
  `INSPECT_ALLOW_PRIVATE=1` environment variable; it does not lift the scheme check.
  A local inspection does allow `localhost` (section 5).
- 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 what the team already has: a list of cases, some of which are
checked by hand every release. `retibe_checklists` shows what is still manual;
`retibe_checklist` and `retibe_checklist_item` create cases and link them to a
scenario, and a linked case then gets a result from every run on the platform. You
can link in the same call that saves the scenario: `link_to` on
`retibe_save_scenario`.

What breaks most often:

- **The link lives on the checklist, not on the case** — `scenario_id` is a checklist
  column. One checklist points at one scenario; two cases about different scenarios
  are two checklists.
- **The key is `scenario.id || scenario.name`, and there is no FK, no trigger and no
  reconciliation behind it.** Rename the scenario, give it an `id`, delete it — the
  link is gone, silently and for good. The only way to find out is
  `retibe_checklists verify_links:true`.

`retibe_docs section:'checklists-over-mcp'` — everything else: the full working loop,
creating a checklist together with its cases, the complete mechanics of the link and
what `scenarioKeySource` means, where a case's results come from and what
`retibe_publish_run` has to do with it, the three disagreeing coverage formulas and
the two counters that do not behave the way they look, `op:'record'` and its
idempotency on `run_id`, and what is deliberately absent from the tools.

---

## 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               tmpl  verdict
1  open  web_navigate  https://example.com        can fail
2  bad   web_assert    h1                         asserts nothing — data.text is never read; no data.contains
3  sec   security      —                          always green — writes its problems to findings and reports passed

Only step 1 can fail; the other 2 cannot make this scenario go red.
Every "can fail" above is about MECHANICS. No step checks an OUTCOME — nothing
here says what the site should look like after these actions.

canFail: [1]   asserts: []   opaque: []
```

That second line of the conclusion, and the `asserts` field, are the answer to "does
this scenario check anything at all". When an outcome IS checked, the line is
`Outcome checks: step(s) 3.` instead, and `asserts` names those steps. `opaque` is the
`use_fixture` and `page_method` steps: their body is not in the scenario, and the
storyboard says "I do not know" rather than guessing.

`view` takes `findings` (the default, as before), `storyboard` and `both`. A
`view:'storyboard'` does not hide validator errors behind a pretty table: when there
are any, the reply carries `blockingErrors` with the count and points you at
`view:'findings'` for them — the run would not survive long enough for the verdicts to
matter.

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

**`can fail` is not "checks an outcome".** The verdict says only whether the step can
go red. A step that goes red when the site is **down** and a step that goes red when
the site **answers wrongly** are different things, and the second kind is what
`asserts` counts. A full `canFail` beside an empty `asserts` means exactly one thing:
the scenario is green against a site that loaded and is broken. So the response
carries two more lists next to `canFail`:

| Field | What is in it |
|---|---|
| `asserts` | the step numbers that check an **outcome** — what should actually be on the page |
| `opaque` | the step numbers of `use_fixture` / `page_method`: their body lives elsewhere, so whether they assert is not knowable from here |

The full list of what counts as an outcome check (derived from the engine, not from
the documentation — it is the same list `NO_ASSERTIONS` counts), how the storyboard
tells `data.absent: true` from `data.visible: false`, the order in which the engine
evaluates `skipIf` and `condition` and why "`skipIf: {previousStepFailed: true}` plus
a real assertion" looks like a gate and is not one, where the verdicts come from, and
why `environment` and `unchecked` arrive here too —
`retibe_docs section:'storyboard-outcome-checks'`. The same list of actions, in
prose, is in section 7.

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

**If you waited for a failure, end the run.** `wait_for:'failure'` returns while the
run is still going: the engine walks on through the remaining steps unless they are
`critical`. If that was the failure you were waiting for, `retibe_stop_run` ends it
now — `retibe_run_status` says so in `next`. Waiting it out buys nothing; the quota is
already spent.

`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. A trailing `✔` means the
row was **read from data** rather than guessed: the engine publishes every step
boundary as its own event (`StepEventMeta`, the third and optional argument of the
broadcast callback), so that row's `spanMs` is the duration the engine reported and
its outcome is the status the engine recorded.

Rows without `✔` were recognised by matching Russian prose in the log, exactly as
before. Two kinds remain: a run on a platform image older than this release (during a
rollout `.com` and `.ru` may answer differently — that is expected and visible in
`timelineCaveats`), and the handful of lines the engine deliberately leaves unmarked
because they do not close a step: the recoverable error before a retry, the `⏰`
retry-timeout warning and the `🛑` critical abort. For those it is still true that:

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

Whatever the source:

- `(retried)` means the engine entered the step more than once; on a row without `✔`
  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.
- `timelineCaveats[0]` says where the rows came from: read from the engine's step
  events, recognised from prose, or — on a mixed run — both, noting that the rows
  without `✔` were guessed.
- `durationMs` on a step event is measured from step entry to the closing line, which
  is **before** the `afterEach` fixture runs. It is smaller than the step result's own
  `duration` by that fixture's time.
- In a multi-actor run the actor arrives with the step event on a `✔` row; on a row
  without `✔` it is read from the switch line, and one that was not recognised means
  "unknown", not "the previous one".

`stepsObserved` is exact for a run read end to end from the engine's step events.
Where boundaries still had to be recognised from prose the old limit holds: 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 an `id`.

`totalSteps` is the scenario's own step count. The platform reads it from the stored
scenario; when it cannot, the engine's plan line — printed before the first step
(`📋 План: N шагов`) — supplies it. `0` means nobody said, never that there are no
steps. This, not `stepsObserved`, is the denominator for "step 3 of 8".

`elapsedMs` is a finished run's **duration** and a running one's time since it
started. When the platform reports neither an end time nor a duration (an older
deployment) the field is **absent**: counting "now minus the start" on a run that
ended yesterday would report a day instead of nine seconds.

**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. The
ring also expires: it is Redis with a fifteen-minute TTL. Fifteen minutes after a run
ends the live tail is empty — with the result perfectly intact.

**A finished run has no step timeline — and the response says so.** Polling a
completed test almost always returns a `timeline` with no rows. The `timelineCaveats`
used to disappear along with the rows, leaving a response that said nothing about why
it was empty. Now `timelineCaveats` come back every time and carry the line
`The progress ring is not retained after a run completes (TTL 15 min)` — read
`retibe_run_result` for the verdict and the failing steps.

### The log that outlives the ring: `retibe_run_result detail:'log'`

The engine puts the last **300 lines** of its broadcast straight into the run result,
where they live as long as the result does. It is the only log a run that finished an
hour ago still has.

```
retibe_run_result runId:'webtest-1789360920418' detail:'log' grep:'❌|timeout'
```

Three things before you draw conclusions: this is a **tail, not a transcript** — the
beginning is what gets cut, and how many lines did not fit is written in `omitted`;
substituted `{{params.*}}` values are masked to `***` at write time inside the
engine, not at read time; screenshot frames never reach the log at all.

The tail's limits, how `grep` behaves (including when the expression does not
compile), why `available:false` is not "the run was silent", and why an auto-crawl
keeps no log — `retibe_docs section:'stored-run-log'`.

---

## 13. Run memory: what changed, and what flickers

A red run raises one question: **is this new?** The digest does not answer it — it
describes one run. Two tools do: `retibe_compare_runs` (what changed between two runs;
on the platform `runA` may be omitted and the last green run is used) and
`retibe_history detail:'steps'` (a `✓✗✓` row per step, oldest run on the left).

What to know up front:

- **`flakiness_score` does not answer it.** It is a share of failures, not a share of
  sign changes: `✓✓✗✗` (a regression), `✗✗✓✓` (already fixed) and `✓✗✓✗` (a flap) all
  score 50. The step verdict in `detail:'steps'` counts sign changes instead and
  separates `regressed`, `recovered` and `flaky`.
- **A green partial run is not a green scenario.** `from_step` / `only_steps` run a
  slice; such a run gets its own name (`… [partial 15-20]`), stamps no checklist
  cases, and spends the quota exactly as a full run does.

`retibe_docs section:'run-memory'` — everything else: the platform's three sources of
memory and what is missing from each, what `detail:'steps'` costs (`requests: 1`
versus N+1), how steps are matched and error text normalised, all seven `verdict`
values, the step-verdict table, run slicing with its automatic
setup prefix and the login trap in it, the recipe for "the run went red", and the
caveats about local runs and `historyId`.

---

## 14. The calendar and schedules

`retibe_calendar` reads (what is scheduled and what has already run);
`retibe_schedule` writes (create, edit, pause, delete, run one now, cost it with
`dry_run`, attach an outgoing webhook).

What bites on day one:

- **The default timezone is UTC.** Leave `timezone` out and `time: '09:00'` means
  09:00 UTC. This is the most common cause of "the schedule did not fire"; the truth
  is in `nextRunAt` in the response, and that is what you check with your eyes.
- **A schedule holds a copy of the scenario's steps, not a reference.** Edit the
  scenario and the schedule keeps running the old one until `op:'update'` with the
  same `scenario`.
- **Every firing spends a run from the monthly quota.** `daily` on the free plan (20
  `web_tests`) exhausts it in three weeks, after which every firing is refused. A
  recurring schedule is not switched off by that and does not pile up `fail_count` —
  a plan refusal is not a test failure — it simply waits for its next time. A
  one-shot (`once`) is switched off but not lost: `op:'resume'` once the quota
  resets, and the next scheduler sweep (once a minute) fires it. Either way the owner
  is told — by email, push and a `run.skipped` webhook, whichever are enabled. Cost
  it first with `dry_run:true`.

There are only four frequencies: `once`, `daily`, `weekly`, `monthly`. The tool does
not accept `cron`, and that is not caution: in the engine that branch reads the
expression once, only to check it is non-empty, and returns the top of the next UTC
hour.

`retibe_docs section:'calendar-and-schedules'` — everything else: why a repeated
`op:'create'` makes a second schedule, what `cron` and `interval` actually do through
the web API, `dry_run` field by field, the two sources of run counters and what the
truth costs, `view:'entries'`, `op:'run_now'`, what happens to a schedule whose firing is
refused and the `run.skipped` event, the outgoing webhook in full (payload,
signature, retries, `op:'webhook_test'`), and what the tools do not do.

---

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

A scenario's name and its top-level string `id` are not cosmetic: run history and the
checklist link hang off them, and afterwards this is almost impossible to redo.

- **Give every scenario a stable string `id`** shaped
  `<project>-<area>-<what is checked>`, lower case, hyphenated, and put the same value
  at the front of `name`. Without one the key becomes the platform's numeric id — and
  the checklist link then works for a run started from a `retibe_scenarios` object and
  **not** for the same scenario passed inline. Changing that key on a saved scenario
  is not cosmetic: `retibe_save_scenario` refuses and lists the checklists it would
  orphan.
- **There should be few scenarios, and they should be long.** The quota counts runs,
  not steps; one test at a time per account; bringing up a browser costs about a
  minute even for a tiny test. One scenario is one user journey end to end, 30–50
  steps — not five of six.
- **Give steps an `id`.** The step timeline, the case link, the storyboard and the diff on
  removal all key a step on `id || action`: two steps with no `id` and the same action
  are indistinguishable and count as one.

`retibe_docs section:'naming-scenarios'` — everything else: how `retibe_scenarios
search:` actually searches, the whole `keyChange` / `checklistsAffected` / `dry_run`
story when the key moves, the refusal on a taken name, how the tab (`category`) is
chosen and what a round-trip drops, where a long scenario starts to hurt and along
which seams to split it, how this maps onto checklists, and a short instruction you
can hand to Claude verbatim.

---

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

One scenario can drive **several browsers at once** — buyer and seller, caller and
callee, admin and user. Actors are declared as an `actors` object (the key *is* the
actor id, and that is what `step.actor` looks for); the `webrtc` block turns
multi-actor on. The name is historical — calls have nothing to do with it.

- **BOTH fields are required: `actors` and `webrtc.enabled: true`.** `actors` alone is
  not enough: everything goes into one browser, the `actor` fields are ignored, and
  nobody says so — the run is green and there were never two participants. The
  validator calls this `ACTORS_WITHOUT_WEBRTC`. It is the most expensive mistake in
  the subject, because it looks like success.
- **Steps take turns; they do not run at the same time.** There is no parallel step
  and no barrier — "at the same time" cannot be expressed. Synchronisation is a
  `web_assert` on an element that appears because of the other participant's action.

`retibe_docs section:'split-view-over-mcp'` — everything else: the full working
example, the two silent actor reassignments (`STEP_WITHOUT_ACTOR`, `UNKNOWN_ACTOR`),
which actor fields the engine reads and which it ignores (`browserArgs`, `device`,
`storageState` — no), why `splitPreview` is only the picture and why its frames never
reach MCP, `forceHeaded` on the platform, and exactly what is visible about actors in
the validator, the storyboard, the step timeline and the digest.

Working examples: `retibe_examples search:'multi-actor'` — they sit at the end of the
index and the default page does not reach them.

---

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

A card form, reCAPTCHA, Turnstile, Stripe Elements, anyone else's checkout — all of
it lives in an `<iframe>`, and a selector from the main document cannot see inside.
The step needs `data.frame`: a CSS selector for the iframe itself, a chain of such
selectors, `{ "name": … }` or `{ "url": … }` (that one is a regex). Nine actions read
it, from `web_click` to `web_evaluate`; coordinate taps, `page_*`, URL and title
assertions and anything inside a fixture do not — and the validator refuses rather
than staying quiet.

- **The engine reads `frame` and nothing else.** `frameSelector`, `iframe`,
  `frameName`, `frameUrl`, `frameLocator`, `iframeSelector` are not read at all — and
  `web_fill_form` swallows per-field errors by default. That is how you get a green
  run of a payment that never happened, which is why the validator treats every one
  of those names as an **error**.
- **`:contains()` does not work in `data.frame`.** `resolveFrame` waits for each link
  of the chain with a plain `waitForSelector` and `state: 'attached'`, bypassing the
  engine's own resolver — that is path B from section 7, and Chromium rejects the
  whole selector. Comma alternatives do work: a selector list is plain CSS.

`retibe_docs section:'frames-over-mcp'` — everything else: an example step, the four
shapes of `frame`, the full table of what a frame scopes and what it does not, the
resolve budget (`frameTimeout` → `selectorTimeout` → `timeout` → 10000 ms), the
`frame not found` diagnosis, and how frames look in the storyboard.

Working example: `retibe_examples name:'payment-iframe-checkout-example.json'`.
The engine's own frame reference: `retibe_docs section:'frames-iframes'`.

---

## 18. 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 decided not to cover at all. Keep `docs/TEST_COVERAGE.md` in
the repository: a table with one row per scenario (the stable string `id`, the
instance and the platform's numeric id — `com:242`; `retibe.com` and `retibe.ru` have
separate databases — the step count, the checklist, one sentence on what breaks if
the scenario goes red) and a **"Not covered, and why"** section under it. The second
matters more than the first: the table can be rebuilt from the platform in one call;
the reasons something was deliberately left alone cannot be rebuilt from anywhere.

One rule: **saved a scenario — update the file in the same pass.** And one honest
caveat: nothing forces this file to be true. On facts the endpoint is right
(`retibe_scenarios`, `retibe_checklists verify_links:true`); on intent the document is.

`retibe_docs section:'coverage-map'` — everything else: why the platform listing is
not enough, what to put in each column, when exactly to update, and the CI gate —
that each gate run spends a unit of quota, why exit code 2 is not a failure, and why
`scenario_id` is tied to one instance.

The gate is a composite GitHub action published as
[`retibe/gate`](https://github.com/retibe/gate): a job needs only `uses: retibe/gate@v1`
with `token` and `scenario_id`, no checkout. It is curl and jq inside, no browser in
the runner.

---

## 19. Parameters: giving a scenario its data

`{{params.*}}` is how a scenario gets its login, password and base URL. `retibe_env`
reads the keys, `retibe_param` writes them (`op:'set' key:'auth.password' value:'…'
secret:true`). The order is parameters first, scenario second.

- **The write is one-way.** The value goes in and comes back from no tool —
  `retibe_env` returns keys only. Hence `confirm:true` on an overwrite and on a
  delete: the old value cannot be read from here, and another scenario may be
  depending on it.
- **A key that does not exist is not a run-time error.** The resolver leaves the
  placeholder as it is: the literal string `{{params.auth.email}}` lands in the form
  field and the step counts as "done". Only `retibe_validate_scenario` with
  `environment` catches it — code `PARAM_KEY_MISSING`, a warning. Without the argument
  no check happens at all, and the response says so in `unchecked` rather than by
  staying silent.
- **A key is a position in a flat namespace, not a row in a table.** The store accepts
  a hyphen or a space, but the resolver (`[a-zA-Z0-9_.]`) will not substitute it; a
  repeated segment (`devices.devices`) is unrepresentable outright. The tool rejects
  both before writing, and re-reads every write, rolling it back if the key still does
  not resolve.

`retibe_docs section:'parameters-over-mcp'` — everything else: why `action: unchanged`
is not an overwrite, `secretKeys` in `retibe_env` and what still reaches the log, how
the flat namespace works and what `storedIn` tells you, what the `environment` check
deliberately leaves alone (`|default`, the `graphql` branch, an object key, Telegram),
how parameters behave in `composite` mode (`paramsSource`), and why a missing
environment is created silently.

---

## 20. When something is wrong

The table runs by subject: access and the endpoint's build, reconnaissance, saving a
scenario, a run and its result. On the left is what you will see verbatim; on the
right is the cause, not "try again".

**If a line is not here, it is not lost.** The symptoms of the subjects that moved
into the reference sit with their subject: run history and slicing —
`retibe_docs section:'run-memory'`, frames — `frames-over-mcp`, split view —
`split-view-over-mcp`, checklists — `checklists-over-mcp`, schedules —
`calendar-and-schedules`, parameters — `parameters-over-mcp`, the coverage map —
`coverage-map`, the agent trail — `agent-audit-log`. In each chapter it is the
"Troubleshooting" subsection. If you cannot tell which subject it is, search the
verbatim text: `retibe_docs search:'<line from the error>'`.

| 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 |
| `Too many requests for this token or address` | Too fast. This is neither a quota nor a revoked token: the gateway allows 60 calls a minute per token (bursting to 30) and 120 per address. The reply carries `Retry-After` — wait that many seconds and carry on. Retrying in a tight loop only keeps the bucket empty; the code is `-32004`, as against `-32003` for the quota |
| `Usage limit exceeded` | The platform refused the run: web_tests or security_scan ran out, visible in `retibe_whoami` |
| `web_tests quota exhausted: 20/20` | This is `retibe_run_scenario` refusing on its own: it asks for the remainder BEFORE starting rather than parsing a scenario for nothing. The counters are in `retibe_whoami` and reset with the billing period |
| `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. On the public endpoint this is a platform-side condition — `retibe_doctor` is not offered there, so read `retibe_whoami` (`platform.note`) and retry later; locally, run `retibe_doctor`. The run spent no quota — it is given back, so retrying is free |
| `tool not found` for `retibe_doctor` | You are on the public endpoint (or `RETIBE_BACKEND=remote`), which does not offer it: it inspects the machine that runs the browser, and there that machine is not yours. `retibe_whoami` → `platform.note` |
| `tool not found` for `retibe_publish_run` | The same thing from the other side: there is nothing to publish — a run started here is already in reports, history and the calendar. `retibe_whoami` → `platform.publishing` |
| `... is missing from this checkout` | The endpoint is on an image older than `d1069cc` — `knowledge/` was not in it. Needs a redeploy, see section 6 |
| `No web examples in this checkout` (or `No examples in this checkout`) | The same thing, about the `examples/` directory. The word `web` is there because on the public endpoint the index is filtered to web examples by default: Telegram does not run there |
| A link in a reply points at `http://app:3000/…` | The endpoint is on an image older than `a87c986`: back then the public sidecar built the report, screenshot, JUnit and Allure links from the internal address, which resolves nowhere outside. Opening it is pointless, however confidently `links.note` explains how. Now the sidecar simply refuses to start without `RETIBE_PUBLIC_BASE_URL`; `GET /health` → `publicBase` shows what links are built from. The cure is a redeploy (section 6) |
| The validator says something different from this file | Check the build: `retibe_whoami` → `build.sha` against `git rev-parse --short=12 origin/main`. Different — the endpoint is behind and needs a redeploy; the same — code and document genuinely disagree, see section 6 |
| `Refusing to inspect …: … may only target public hosts` | The platform does not inspect private networks — the reconnaissance goes out from its IP. An internal environment needs a public address, or self-hosting with `INSPECT_ALLOW_PRIVATE=1` (section 9) |
| `Inspection limit exceeded (N/M this month)` | The separate `inspections` quota ran out — it is not shared with `web_tests`. What is left is visible in `retibe_whoami` |
| `An inspection is already running for this account` | One inspection at a time per account. Wait for the answer — it arrives within 40 seconds — and retry |
| `The URL still contains {{params.…}} after resolving…` | The key does not exist in this environment. `retibe_env` shows which ones do; `retibe_param` creates it (section 19) |
| `No platform is configured, so there is nowhere to inspect` | Local mode without `RETIBE_BASE_URL`: reconnaissance lives on the platform and has no local equivalent. Either connect to the platform, or look at the page with the client's own browser tool — and say in the report that the selectors were not verified |
| `This account already has a crawl running` | One crawl at a time per account. If you kept the earlier `crawl_id`, read it with that; otherwise wait a minute — the platform tracks running crawls per worker process, so in a cluster the refusal can come from a worker that is not the busy one |
| `The crawl failed before it produced a map` | The crawl died on the first page, which usually means the start URL did not load. Check it with `retibe_inspect_page`, which reports the HTTP status |
| `Invalid arguments for prompt ...` | The prompts take required arguments: `/author` needs `url` and `goal`, `/debug` needs `runId`, `/harden` needs `scenarioPath`. Section 8 |
| `search:` finds nothing though the scenario exists | Search matches the name, the description and the scenario's own top-level string `id`, as a substring. It never matches the numeric platform id or the steps — open that scenario with `retibe_scenarios id:N` — section 15 |
| `Refusing to save: N error(s)` | The scenario has validator errors — the first five are listed in the refusal. Warnings, the three selector codes included, never block a save; `strict:true` has no effect on saving (section 7) |
| `Scenario is invalid (N error(s)); nothing was run.` | The same thing on a run: `retibe_run_scenario` validates the scenario before any browser starts and, on an error, spends neither a second nor any quota. The first error is named in the text; the rest come from `retibe_validate_scenario` (section 7) |
| `Refusing to save: this changes the key…` | The key the recorder matches checklists on (the scenario's string `id`, else its name) would move. Either restore the stored `id`/`name`, or pass `confirm:true` and re-point the checklists listed in `keyChange.checklistsAffected` straight away. `dry_run:true` shows the same thing and writes nothing (section 15) |
| `Refusing to save: this drops N step(s)` | The scenario being saved is shorter than the one on the platform. The refusal names the steps that would go (keyed by `id \|\| action`; an ellipsis means not all of them could be named). If that is a mistake, put every step you mean to keep back into the body; if the removal is intended, call again with `confirm:true` and the answer carries `stepsRemoved`. `dry_run:true` shows the diff and writes nothing |
| `stepsRemoved: null` after steps were removed | The removed steps share their `id \|\| action` key with steps that stayed, so the diff cannot name them one by one. It does not mean nothing was removed — `stepsBefore`/`stepsAfter` carry the count. Give the steps an `id` (section 15) |
| `No scenario with id N to update.` | This account has no such scenario — what used to arrive here was the platform's own 404. Check the id with `retibe_scenarios`, or save without `id` to create a new one |
| `Scenario N has no revision M — it keeps …` | The last twenty saves are kept and older ones are pruned. List them with a per-save step diff: `retibe_scenarios {id:N, revisions:true}` (section 8) |
| 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 |
| `No web test running. Nothing was stopped…` | Nothing is running on the account. That is the platform answering, not the tool failing; a queued job is not cancelled this way — wait for it to start, then stop it |
| `The stop was sent to the worker running this test, but it has not confirmed yet` | The run lives on another cluster worker; the stop reached it, the confirmation did not arrive in time. That is not "nothing was stopped": check `retibe_run_status` before retrying (section 8) |
| `Your account is running X, not Y` | The stop endpoint is per-account: stopping here would have killed `X`. Check `retibe_list_runs`, and if `X` really is the one to kill, repeat with `confirm: true` |
| `No screenshot to show for run …` | There is no frame, for one of four reasons: the run is green (a green run is never photographed), the failing step recorded none, the browser had already died when the step gave up, or the result was read from the platform's step projection — `resultSource` says which (section 9) |
| `This run recorded its screenshot as a path on the runner` | The run was local and you are reading it from the platform: nothing here serves a runner's path. Read the run on that machine, or open the report, which embeds the same frame |
| `JUnit is generated by the platform, and this run is not on it` | A local run. `retibe_publish_run` first, then `detail:'junit'` for the published `runId` (section 9) |
| `This backend keeps no stored run log` | This backend has no stored log at all. The live tail, while the run is in flight, comes from `retibe_run_status` (section 12) |
