Writing a command
You are adding or changing a read-only command that drives the user's own Chrome
over the DevTools Protocol. The target is one closed loop: evidence you
replayed yourself → a decoded carrier → a command → a green offline suite → a
live expectation you tightened by hand.
Nothing here is finished at "it returned data". A command that returns plausible,
wrong data is worse than one that fails, because nobody goes looking.
Before anything
Read AGENTS.md, docs/STATUS.md
and docs/areas/_platform.md. Then the domain
file for whatever you are touching. If you are about to write network logic and
you have not replayed the response yourself in this session, stop — that is D-004
and it is the rule everything else here rests on.
While developing you drive Chrome through the Chrome DevTools MCP tools: open a
page, take a snapshot, evaluate a script in page context, list network requests,
read one response. That is how you look. The shipped command never goes through
MCP — it talks to Chrome through src/runtime/cdp.mjs.
The hard stops
These are not style. Each one exists because ignoring it produced wrong data
that survived a green check.
- A challenge — CAPTCHA,
429, an IP block — is a full stop. No interaction, no
retry, no weakened validation. Record what you saw and stop.
- Never text-scan the primed origin for a challenge. It answers nothing: it may
be the block page itself, and the
robots.txt primed with before D-081
contains the word captcha in its own directives (F-044).
- Never hardcode an identifier Avito owns: region, category, filter, saved
search, photo size, sort label.
npm run lint fails the build over it, and the
only escape is // vocabulary-ok: <reason> for text that quotes an example.
- Never render the catalog. Call
primeOrigin, then same-origin fetch.
- One request per call. The one exception in the whole repository is the bounded
bootstrap recovery in
search.
Step 0 — is this in scope?
Three questions:
- Is the data visible in a normal browser to this user? If not, stop — nothing
here works around access control.
- Does it arrive as HTML, JSON, or hydration state? If not, it is out of scope.
- Does it need a push channel? If yes, find the HTTP equivalent; if there is
none, stop.
The carrier note
Pick the carrier before you write code. Every time you reach Step 4, produce
this note. Without it, do not create a file in src/commands/.
Carrier: SSR_BOOTSTRAP | PAGE_FETCH_JSON | HYDRATION | VISIBLE_DOM
Contract: visible-ui | internal-unstable
Evidence:
- observed request/state: <endpoint or state path>
- replay result: <status + content-type + the shape of a non-empty sample>
- postcondition carrier: <what proves the request actually applied>
Fallbacks: <what happens when the primary drifts, and why that is not a guess>
The question is never "is an API better than the DOM". It is does this source
have a contract with anyone. On this site the answer is already known and
written down: the SSR bootstrap is the primary carrier for everything catalog
shaped, the internal JSON endpoints are internal-unstable and validated
fail-closed, and the visible DOM is a last resort no command reads (D-064). See
references/carrier-selection.md before you
deviate from that.
Decision tree
START
│
▼
Read the domain file + docs/site-memory.md + docs/endpoints.json
│ hit: the endpoint and the fields are already known
│ → still replay it. Memory older than 30 days is stale by rule.
│ miss → keep going
▼
Look at the page with the Chrome DevTools MCP tools (references/recon.md)
│
▼
Find the carrier: network → hydration state → SSR script → visible DOM
│
▼
Replay it yourself, same-origin, from a primed page
│ 401/403 → the session, not the code. Stop and say so.
│ 200 + HTML where JSON was expected → wrong carrier, go back
│ 200 + empty → is that Avito's answer or your parameters? Prove which.
▼
Write the carrier note
│
▼
Decode the fields — compare at least one against the visible page by eye
│ (references/silent-failures.md before you trust anything)
▼
Design the answer (references/output-design.md)
│
▼
Write src/commands/<name>.mjs — and a page half only if you need one
│ (references/command-template.md, references/typed-errors.md)
▼
Offline suite: npm test ── red ──→ fix the command, never the assertion
│
▼
Live: npm run verify <name> ── red ──→ fix-command skill
│
▼
Seed the expectation, then TIGHTEN IT BY HAND (references/live-expectations.md)
│
▼
Compare the values against the visible page, field by field
│ wrong → back to decoding
▼
Write the memory: docs/areas/<domain>.md, docs/STATUS.md, the commit message
│
▼
npm run check ── green ──→ DONE
Runbook
[ ] 1. Read docs/STATUS.md and the domain file. Check docs/PLAN.md for whether
this work is already scoped as a phase.
[ ] 2. Read the site memory:
[ ] docs/endpoints.json — is the endpoint already known?
[ ] docs/site-memory.md — the pitfalls section, in full
[ ] evidence/ — grep the stored samples for the field names you are
about to use; that is what a stored sample is for
[ ] a hit does NOT let you skip the replay; it lets you skip the search
[ ] 3. Recon with the Chrome DevTools MCP tools (references/recon.md):
[ ] open the page in a tab of the user's own Chrome
[ ] list the network requests, find the one that carries the data
[ ] read one response in full, not a summary of it
[ ] if the data is in the document rather than an XHR, read the SSR
bootstrap script, not the hydrated DOM
[ ] 4. Replay the candidate yourself from a primed origin:
[ ] status 200
[ ] the payload contains the target data, not markup or telemetry
[ ] you can name the field that will serve as the postcondition
[ ] 5. Write the carrier note. Mandatory artefact before any code.
[ ] 6. Decode the fields:
[ ] self-describing → use the key
[ ] known code → docs/field-map.json
[ ] unknown code → compare two records that differ in exactly one visible
way; never guess from the name
[ ] check at least one decoded value against the visible page by eye
[ ] 7. Design the answer (references/output-design.md):
[ ] one object; an envelope plus a list where there are several of
something. Identical across every element → envelope; different
between them → element
[ ] camelCase, aligned with the neighbouring commands
[ ] at most 40 declared fields, at most 3 objects deep
[ ] order: identity → what was asked → what came back → the list
[ ] reuse the vocabulary in src/runtime/schema.mjs before writing a regex
[ ] nullable where Avito can legitimately withhold it — and say why
[ ] if this is a listing command, import LISTING_ITEM; do not restate it
[ ] 8. Write the command:
[ ] copy the closest existing neighbour rather than starting blank
[ ] descriptor first: description, args with help text, output schema,
the hand-written `type` beside it, example
[ ] write `type` as you write the schema, with the comment on each field
that says what it means and what its null says — that comment is the
only reason it is hand-written
[ ] declare Avito's response in src/schemas/ and read it with `decode(...)`
[ ] reach for src/browser/commands/carriers.mjs before writing a page
half: one document read and one JSON GET are already there
[ ] a page half of your own only for what needs a real DOM, and then it
is one exported function that fetches and decides nothing
[ ] every known failure throws a typed error (references/typed-errors.md)
[ ] 9. Offline suite:
[ ] add a suite that runs the real command against synthetic routes —
`browserPage(routes)` in tests/carrier.mjs drives both halves
[ ] assert the request budget: how many requests, which, and in what order
[ ] assertOutput(COMMAND, answer) — the contract, not just the key count
[ ] npm test green
[ ] 10. Live verify:
[ ] run it once for real and read the answer
[ ] write expectations/<name>.mjs: `args`, and `output` as a schema over
the whole answer. Put in what is true of THIS request — the subject
it resolved, the sort it applied, the page it returned. Formats true
of every request belong in the output schema instead.
[ ] reach for the envelope first: it carries what the command proved
[ ] name the tightest thing that is true: z.literal over a regex, a
regex over .min(1)
[ ] .length(n) when the count is a fact about the route, .refine for
anything a list must satisfy together
[ ] run verify again and confirm it still passes
[ ] 11. Compare the values against the visible page, field by field. Not
"did it return data" — "is this the same number the page prints".
[ ] 12. Write the memory:
[ ] a new fact or decision → its domain file, one line of
"what we observed → what follows", with the next free F-0xx / D-0xx
[ ] the session narrative → the commit message. There is no journal
file and you are not to start one (AGENTS.md)
[ ] changed state → docs/STATUS.md; remaining work → docs/PLAN.md
[ ] an anonymised full response sample →
evidence/<name>-<YYYYMMDDHHMM>.json, with cookies, tokens and
personal fields stripped before saving
[ ] delete any scratch dumps you left outside evidence/ and /tmp
[ ] 13. npm run check
When a step stalls
| Stuck at |
What you see |
Go to |
| 3, recon |
no XHR carries the data |
the SSR bootstrap script in the document |
|
the document has no bootstrap either |
the hydration global, then the visible DOM |
| 4, replay |
401 / 403 |
the session, not the code. Stop and tell the user. |
|
200 but HTML |
wrong carrier — back to step 3 |
|
200 but empty |
prove which: change one parameter and see if it moves |
| 6, decoding |
two fields both look right |
references/silent-failures.md §3 — compare against a record whose value you can read on the page |
| 9, offline |
an assertion is in the way |
the assertion is right until proven otherwise. Fix the command. |
| 10, verify |
a pattern rule fails |
check the value against the page first. Value right → the pattern is too tight. Value wrong → your mapping is wrong. Never relax the expectation to get green. |
|
breaks its own contract |
the answer stopped matching its schema, and the message names the path. Either the response no longer has the field, or your args changed the shape. Find out which before touching anything. |
|
a field is always null |
the field path is wrong — back to step 6 |
| 11, comparison |
the number is off by a factor |
units. Avito's and yours disagree. |
References
| File |
When |
| references/recon.md |
Step 3: driving Chrome through MCP, and what to look at in what order |
| references/carrier-selection.md |
Step 5: the carrier classes, their contracts and their real maintenance cost |
| references/output-design.md |
Step 7: the envelope rule, naming, types, order, the ceilings and what a field is worth |
| references/command-template.md |
Step 8: file layout, the descriptor, the split between the Node half and the page half |
| references/typed-errors.md |
Step 8, before writing the body: which error goes where, and the three silent anti-patterns |
| references/offline-suites.md |
Step 9: synthetic carriers, budget assertions, what an offline suite can and cannot prove |
| references/live-expectations.md |
Step 10: what an expectation claims, and what belongs in the output schema instead |
| references/silent-failures.md |
Step 6 and Step 11: the ways a green check hides wrong data |
Conventions this repository holds to
- A command module default-exports
defineCommand({...}). The descriptor is the
contract: --help prints it, the checks read it, the CLI enforces it.
- The answer is one object, a
z.strictObject in output, strict at every
level: an undeclared key is a failure rather than a value a caller was never
told about. type beside it is the same contract as TypeScript, hand-written,
and npm run check:commands refuses a name that is in one and not the other.
- A command is
src/commands/<name>.mjs. Everything it decides lives there,
in src/site/ and in src/schemas/. src/browser/ is only what a browser
is needed for — a same-origin fetch and a real DOM — and most commands reach
the two shared entry points in src/browser/commands/carriers.mjs rather than
writing a page half at all. Shared page code goes in src/browser/prelude/,
which is inlined into every call under two rules src/browser/README.md states.
- Known failures throw one of the five typed errors. Never
return [] from a
catch, never a sentinel value, never Math.min on an argument the caller gave you.
- Raw dumps live in
evidence/ (anonymised, committed) or /tmp (anything
else). Never in src/, never in the repository root.
- A comment says what the code cannot: an Avito fact, a trap, a contract not
visible from the signature. Never what you changed, never why you did not do
the other thing, never a restatement of the line below. See the comment rules
in AGENTS.md before writing a file header.
- Site memory is written every round. No memory → use this skill → memory
exists → next time the same work is five minutes.
Stuck
- Diagnosis:
npm run check, then the domain file, then the repair skill.
- Field decoding: compare two records that differ in exactly one visible way. If
that fails, return the raw value and iterate — do not name it something you
cannot prove.
- Endpoint missing: it is probably in the SSR bootstrap rather than an XHR. This
site puts almost everything there.
Do not guess. A wrong guess passes verify and the user finds out from garbage.
1---2name: write-command3description: Use when writing a new command for this repository, or changing what an existing one returns. Takes you from first look at a page, through picking a carrier and decoding fields, to a green offline suite and a live expectation. For a command that used to work and now fails, use fix-command instead.4---56# Writing a command78You are adding or changing a read-only command that drives the user's own Chrome9over the DevTools Protocol. The target is one closed loop: **evidence you10replayed yourself → a decoded carrier → a command → a green offline suite → a11live expectation you tightened by hand.**1213Nothing here is finished at "it returned data". A command that returns plausible,14wrong data is worse than one that fails, because nobody goes looking.1516## Before anything1718Read [AGENTS.md](../../../AGENTS.md), [docs/STATUS.md](../../../docs/STATUS.md)19and [docs/areas/_platform.md](../../../docs/areas/_platform.md). Then the domain20file for whatever you are touching. If you are about to write network logic and21you have not replayed the response yourself in this session, stop — that is D-00422and it is the rule everything else here rests on.2324While developing you drive Chrome through the Chrome DevTools MCP tools: open a25page, take a snapshot, evaluate a script in page context, list network requests,26read one response. That is how you *look*. The shipped command never goes through27MCP — it talks to Chrome through `src/runtime/cdp.mjs`.2829## The hard stops3031These are not style. Each one exists because ignoring it produced wrong data32that survived a green check.3334- A challenge — CAPTCHA, `429`, an IP block — is a full stop. No interaction, no35 retry, no weakened validation. Record what you saw and stop.36- Never text-scan the primed origin for a challenge. It answers nothing: it may37 be the block page itself, and the `robots.txt` primed with before D-08138 contains the word `captcha` in its own directives (F-044).39- Never hardcode an identifier Avito owns: region, category, filter, saved40 search, photo size, sort label. `npm run lint` fails the build over it, and the41 only escape is `// vocabulary-ok: <reason>` for text that quotes an example.42- Never render the catalog. Call `primeOrigin`, then same-origin fetch.43- One request per call. The one exception in the whole repository is the bounded44 bootstrap recovery in `search`.4546## Step 0 — is this in scope?4748Three questions:49501. Is the data visible in a normal browser to this user? If not, stop — nothing51 here works around access control.522. Does it arrive as HTML, JSON, or hydration state? If not, it is out of scope.533. Does it need a push channel? If yes, find the HTTP equivalent; if there is54 none, stop.5556## The carrier note5758**Pick the carrier before you write code.** Every time you reach Step 4, produce59this note. Without it, do not create a file in `src/commands/`.6061```md62Carrier: SSR_BOOTSTRAP | PAGE_FETCH_JSON | HYDRATION | VISIBLE_DOM63Contract: visible-ui | internal-unstable64Evidence:65- observed request/state: <endpoint or state path>66- replay result: <status + content-type + the shape of a non-empty sample>67- postcondition carrier: <what proves the request actually applied>68Fallbacks: <what happens when the primary drifts, and why that is not a guess>69```7071The question is never "is an API better than the DOM". It is **does this source72have a contract with anyone**. On this site the answer is already known and73written down: the SSR bootstrap is the primary carrier for everything catalog74shaped, the internal JSON endpoints are `internal-unstable` and validated75fail-closed, and the visible DOM is a last resort no command reads (D-064). See76[references/carrier-selection.md](references/carrier-selection.md) before you77deviate from that.7879## Decision tree8081```82START83 │84 ▼85Read the domain file + docs/site-memory.md + docs/endpoints.json86 │ hit: the endpoint and the fields are already known87 │ → still replay it. Memory older than 30 days is stale by rule.88 │ miss → keep going89 ▼90Look at the page with the Chrome DevTools MCP tools (references/recon.md)91 │92 ▼93Find the carrier: network → hydration state → SSR script → visible DOM94 │95 ▼96Replay it yourself, same-origin, from a primed page97 │ 401/403 → the session, not the code. Stop and say so.98 │ 200 + HTML where JSON was expected → wrong carrier, go back99 │ 200 + empty → is that Avito's answer or your parameters? Prove which.100 ▼101Write the carrier note102 │103 ▼104Decode the fields — compare at least one against the visible page by eye105 │ (references/silent-failures.md before you trust anything)106 ▼107Design the answer (references/output-design.md)108 │109 ▼110Write src/commands/<name>.mjs — and a page half only if you need one111 │ (references/command-template.md, references/typed-errors.md)112 ▼113Offline suite: npm test ── red ──→ fix the command, never the assertion114 │115 ▼116Live: npm run verify <name> ── red ──→ fix-command skill117 │118 ▼119Seed the expectation, then TIGHTEN IT BY HAND (references/live-expectations.md)120 │121 ▼122Compare the values against the visible page, field by field123 │ wrong → back to decoding124 ▼125Write the memory: docs/areas/<domain>.md, docs/STATUS.md, the commit message126 │127 ▼128npm run check ── green ──→ DONE129```130131## Runbook132133```134[ ] 1. Read docs/STATUS.md and the domain file. Check docs/PLAN.md for whether135 this work is already scoped as a phase.136[ ] 2. Read the site memory:137 [ ] docs/endpoints.json — is the endpoint already known?138 [ ] docs/site-memory.md — the pitfalls section, in full139 [ ] evidence/ — grep the stored samples for the field names you are140 about to use; that is what a stored sample is for141 [ ] a hit does NOT let you skip the replay; it lets you skip the search142[ ] 3. Recon with the Chrome DevTools MCP tools (references/recon.md):143 [ ] open the page in a tab of the user's own Chrome144 [ ] list the network requests, find the one that carries the data145 [ ] read one response in full, not a summary of it146 [ ] if the data is in the document rather than an XHR, read the SSR147 bootstrap script, not the hydrated DOM148[ ] 4. Replay the candidate yourself from a primed origin:149 [ ] status 200150 [ ] the payload contains the target data, not markup or telemetry151 [ ] you can name the field that will serve as the postcondition152[ ] 5. Write the carrier note. Mandatory artefact before any code.153[ ] 6. Decode the fields:154 [ ] self-describing → use the key155 [ ] known code → docs/field-map.json156 [ ] unknown code → compare two records that differ in exactly one visible157 way; never guess from the name158 [ ] check at least one decoded value against the visible page by eye159[ ] 7. Design the answer (references/output-design.md):160 [ ] one object; an envelope plus a list where there are several of161 something. Identical across every element → envelope; different162 between them → element163 [ ] camelCase, aligned with the neighbouring commands164 [ ] at most 40 declared fields, at most 3 objects deep165 [ ] order: identity → what was asked → what came back → the list166 [ ] reuse the vocabulary in src/runtime/schema.mjs before writing a regex167 [ ] nullable where Avito can legitimately withhold it — and say why168 [ ] if this is a listing command, import LISTING_ITEM; do not restate it169[ ] 8. Write the command:170 [ ] copy the closest existing neighbour rather than starting blank171 [ ] descriptor first: description, args with help text, output schema,172 the hand-written `type` beside it, example173 [ ] write `type` as you write the schema, with the comment on each field174 that says what it means and what its null says — that comment is the175 only reason it is hand-written176 [ ] declare Avito's response in src/schemas/ and read it with `decode(...)`177 [ ] reach for src/browser/commands/carriers.mjs before writing a page178 half: one document read and one JSON GET are already there179 [ ] a page half of your own only for what needs a real DOM, and then it180 is one exported function that fetches and decides nothing181 [ ] every known failure throws a typed error (references/typed-errors.md)182[ ] 9. Offline suite:183 [ ] add a suite that runs the real command against synthetic routes —184 `browserPage(routes)` in tests/carrier.mjs drives both halves185 [ ] assert the request budget: how many requests, which, and in what order186 [ ] assertOutput(COMMAND, answer) — the contract, not just the key count187 [ ] npm test green188[ ] 10. Live verify:189 [ ] run it once for real and read the answer190 [ ] write expectations/<name>.mjs: `args`, and `output` as a schema over191 the whole answer. Put in what is true of THIS request — the subject192 it resolved, the sort it applied, the page it returned. Formats true193 of every request belong in the output schema instead.194 [ ] reach for the envelope first: it carries what the command proved195 [ ] name the tightest thing that is true: z.literal over a regex, a196 regex over .min(1)197 [ ] .length(n) when the count is a fact about the route, .refine for198 anything a list must satisfy together199 [ ] run verify again and confirm it still passes200[ ] 11. Compare the values against the visible page, field by field. Not201 "did it return data" — "is this the same number the page prints".202[ ] 12. Write the memory:203 [ ] a new fact or decision → its domain file, one line of204 "what we observed → what follows", with the next free F-0xx / D-0xx205 [ ] the session narrative → the commit message. There is no journal206 file and you are not to start one (AGENTS.md)207 [ ] changed state → docs/STATUS.md; remaining work → docs/PLAN.md208 [ ] an anonymised full response sample →209 evidence/<name>-<YYYYMMDDHHMM>.json, with cookies, tokens and210 personal fields stripped before saving211 [ ] delete any scratch dumps you left outside evidence/ and /tmp212[ ] 13. npm run check213```214215## When a step stalls216217| Stuck at | What you see | Go to |218|---|---|---|219| 3, recon | no XHR carries the data | the SSR bootstrap script in the document |220| | the document has no bootstrap either | the hydration global, then the visible DOM |221| 4, replay | 401 / 403 | the session, not the code. Stop and tell the user. |222| | 200 but HTML | wrong carrier — back to step 3 |223| | 200 but empty | prove which: change one parameter and see if it moves |224| 6, decoding | two fields both look right | [references/silent-failures.md](references/silent-failures.md) §3 — compare against a record whose value you can read on the page |225| 9, offline | an assertion is in the way | the assertion is right until proven otherwise. Fix the command. |226| 10, verify | a `pattern` rule fails | check the value against the page **first**. Value right → the pattern is too tight. Value wrong → your mapping is wrong. Never relax the expectation to get green. |227| | `breaks its own contract` | the answer stopped matching its schema, and the message names the path. Either the response no longer has the field, or your args changed the shape. Find out which before touching anything. |228| | a field is always `null` | the field path is wrong — back to step 6 |229| 11, comparison | the number is off by a factor | units. Avito's and yours disagree. |230231## References232233| File | When |234|---|---|235| [references/recon.md](references/recon.md) | Step 3: driving Chrome through MCP, and what to look at in what order |236| [references/carrier-selection.md](references/carrier-selection.md) | Step 5: the carrier classes, their contracts and their real maintenance cost |237| [references/output-design.md](references/output-design.md) | Step 7: the envelope rule, naming, types, order, the ceilings and what a field is worth |238| [references/command-template.md](references/command-template.md) | Step 8: file layout, the descriptor, the split between the Node half and the page half |239| [references/typed-errors.md](references/typed-errors.md) | Step 8, before writing the body: which error goes where, and the three silent anti-patterns |240| [references/offline-suites.md](references/offline-suites.md) | Step 9: synthetic carriers, budget assertions, what an offline suite can and cannot prove |241| [references/live-expectations.md](references/live-expectations.md) | Step 10: what an expectation claims, and what belongs in the output schema instead |242| [references/silent-failures.md](references/silent-failures.md) | Step 6 and Step 11: the ways a green check hides wrong data |243244## Conventions this repository holds to245246- A command module default-exports `defineCommand({...})`. The descriptor is the247 contract: `--help` prints it, the checks read it, the CLI enforces it.248- The answer is one object, a `z.strictObject` in `output`, strict at every249 level: an undeclared key is a failure rather than a value a caller was never250 told about. `type` beside it is the same contract as TypeScript, hand-written,251 and `npm run check:commands` refuses a name that is in one and not the other.252- A command is `src/commands/<name>.mjs`. Everything it decides lives there,253 in `src/site/` and in `src/schemas/`. `src/browser/` is only what a browser254 is needed for — a same-origin fetch and a real DOM — and most commands reach255 the two shared entry points in `src/browser/commands/carriers.mjs` rather than256 writing a page half at all. Shared page code goes in `src/browser/prelude/`,257 which is inlined into every call under two rules `src/browser/README.md` states.258- Known failures throw one of the five typed errors. Never `return []` from a259 catch, never a sentinel value, never `Math.min` on an argument the caller gave you.260- Raw dumps live in `evidence/` (anonymised, committed) or `/tmp` (anything261 else). Never in `src/`, never in the repository root.262- A comment says what the code cannot: an Avito fact, a trap, a contract not263 visible from the signature. Never what you changed, never why you did not do264 the other thing, never a restatement of the line below. See the comment rules265 in [AGENTS.md](../../../AGENTS.md) before writing a file header.266- Site memory is written every round. No memory → use this skill → memory267 exists → next time the same work is five minutes.268269## Stuck270271- Diagnosis: `npm run check`, then the domain file, then the repair skill.272- Field decoding: compare two records that differ in exactly one visible way. If273 that fails, return the raw value and iterate — do not name it something you274 cannot prove.275- Endpoint missing: it is probably in the SSR bootstrap rather than an XHR. This276 site puts almost everything there.277278Do not guess. A wrong guess passes verify and the user finds out from garbage.