AI plans & models
vk ai compiles prose into a plan IR, caches it, and replays it model-free. This page is
the reference for that IR and for choosing a model.
A plan
Section titled “A plan”{ "version": 1, "package": "com.example.app", "platform": "android", "steps": [ … ] }steps is a list of nodes. Every node is one of six types.
1. Command leaf
Section titled “1. Command leaf”The only node that touches the device.
{ "type": "command", "command": "text", "positionals": ["@email_input", "user@example.com"], "flags": [{ "name": "clear", "value": "true" }]}A boolean flag is written as {"name": "clear", "value": "true"}.
Commands the grammar permits:
| Command | Flags |
|---|---|
launch |
--clear, --no-restart |
stop |
— |
tap |
— |
text |
--clear, --enter |
type |
— |
key / back / home / enter |
— |
swipe |
--on |
assert |
--text, --gone |
wait |
--gone, --timeout |
screenshot |
— |
device |
subcommand is the first positional: set / get / reset / caps |
A command outside this table is rejected, never run — the same gate is applied to the compile output and to every model repair.
2. if-present
Section titled “2. if-present”An optional interstitial — a permission dialog, a “what’s new” sheet.
{ "type": "if-present", "selector": "text:Allow", "body": [ … ] }The guard waits for its selector to settle before deciding the UI is not there. See the settle window.
3. repeat
Section titled “3. repeat”A bounded loop that runs until a selector appears.
{ "type": "repeat", "selector": "@target_row", "cap": 10, "body": [ … ] }- A hard iteration
cap. - A structural no-progress early exit — if the screen stops changing, it stops.
- A repeat that never sees its selector FAILS. It is not a best-effort loop.
4. when
Section titled “4. when”Ordered n-way dispatch, with an optional else. No match and no else is a failure.
Use when for “the app is in one of these states”; use if-present for “this may or may not
be there”.
5. while-present
Section titled “5. while-present”Loops while a selector is present, with a bound counter.
{ "type": "while-present", "selector": "@dismiss", "bind": "n", "cap": 5, "body": [ … ] }bind starts at 0 and is referenced as {{ctx.n}}.
6. read
Section titled “6. read”Capture a value off the screen into a variable for a later step.
{ "type": "read", "selector": "@order_number", "field": "text", "into": "orderId" }field is one of text, desc, id, idShort.
Placeholders
Section titled “Placeholders”| Placeholder | Resolves to |
|---|---|
{{ctx.NAME}} |
A value bound by read or a loop counter |
{{env.NAME}} |
An environment variable, read at replay time |
{{uuid}} |
A UUID, generated once per run |
{{timestamp}} |
The run’s timestamp |
{{run_id}} |
The run id |
Never inline a secret. Use {{env.NAME}}; an unset or empty variable fails the step
rather than typing an empty string.
Nesting
Section titled “Nesting”Control nodes nest one level. The exception: if-present and while-present may go one
deeper, so repeat { when { while-present { … } } } and repeat { when { if-present { … } } }
are legal. Three levels is not. Why the IR is kept shallow:
Plan IR & the replay engine.
State modifiers on a control node
Section titled “State modifiers on a control node”A leaf writes a state modifier as a flag. A control node has nowhere to hang a flag, so it appends the modifier to the selector string — which is exactly where a toggle guard belongs:
{ "type": "if-present", "selector": "@mode_video --not-selected", "body": [ … ] }See Selectors.
Rules the compiler follows
Section titled “Rules the compiler follows”Each of these closes a way for a test to pass without doing what it says:
- Use
--enabledwhen tapping a button the app disables until a form is valid. - Guard shared-handler pickers and toggles with
--not-selected/--not-checked. An unguarded tap flips the control and still exits0. assertis verification-only and terminal. It is never healed.tapandtextauto-scroll, so never compile a repeat-until-visible loop.- Prefer resource-id and accessibility selectors over text.
- Translate literally and minimally. The one exception is
screenshot, inserted liberally — free on replay, and it never affects the result.
The grammar does not yet teach --index, so prose like “tap the first Continue” has no
compiled form; name a unique id or text instead
(#37).
The compile must cover the test
Section titled “The compile must cover the test”A compile can come out short, and a plan that stops part-way through the test asserts nothing after that point: it would run green, be cached as a pass, and replay against every later build. So every fresh compile is checked against the prose it came from, on two signals:
- size — the plan has far fewer steps than the test states instructions
- the ending — the plan never references what the test’s closing instructions name
Either one buys one guided recompile, with the finding handed back to the model. A plan
that still trips a check is rejected: vk ai exits 1, the plan is not cached, and
the next run compiles again. Under vk suite the test goes red, and --retries recompiles.
The same check disqualifies a seed: a cached plan that does not cover its own prose is not
offered to the model as a starting point.
VERIKUN_NO_COMPILE_CHECK=1 turns the check off. To see a compile’s size, vk ai prints
compiled N top-level step(s) on stderr; --json reports it as planSteps, and so does the
suite manifest.
Repair
Section titled “Repair”When a step’s selector stops resolving, the model gets the live screen and a strict two-way decision:
| Decision | Meaning |
|---|---|
repair |
Return one replacement command leaf serving the same user-facing purpose |
give_up |
Return a reason. The test fails — which is the correct result. |
“Same purpose” means the same user-facing action, not merely “a tappable element exists”. Every repair goes through the same grammar gate as the original compile.
Models
Section titled “Models”--model picks the model and its provider. There is no --provider flag; the backend is
derived from the model name.
| Model | Backend | Key |
|---|---|---|
claude-haiku-4-5 |
Anthropic | ANTHROPIC_API_KEY |
claude-sonnet-4-6 (default) |
Anthropic | ANTHROPIC_API_KEY |
claude-opus-4-8 |
Anthropic | ANTHROPIC_API_KEY |
claude-fable-5 |
Anthropic | ANTHROPIC_API_KEY |
gpt-5.4-mini |
OpenAI | OPENAI_API_KEY |
gpt-5.4 |
OpenAI | OPENAI_API_KEY |
gpt-5.5 |
OpenAI | OPENAI_API_KEY |
gpt-4.1 |
OpenAI | OPENAI_API_KEY |
codex-cli |
the logged-in codex CLI |
none |
cursor-cli |
the logged-in cursor-agent CLI |
none |
An unknown --model exits 2 with the allowlist, rather than a raw 404 from a provider.
gpt-4.1 is the one non-reasoning model in the list: --effort has no effect on it, and its
cache reads bill at a different multiplier (see Cost & budget).
The CLI backends
Section titled “The CLI backends”codex-cli and cursor-cli shell out to an already-logged-in coding-agent CLI, so you
need no API key at all — spend goes to your existing ChatGPT or Cursor subscription.
- Their reported cost is
$0, so--max-cost-usdand--cost-overrideare inert no-ops. The run is bounded by the repair cap and--timeoutinstead. - Each CLI picks its own underlying model; there is no way to name one yet (#24).
- They run read-only in a neutral temp directory, so they never touch your working tree.
- Their output is gated by the same plan parser and grammar checks as every other provider.
--max-cost-usd <n> (default 3, per test), --timeout <dur> (default 15m) and
--cost-override <in/out> bound a run; replay is always $0, because running a plan calls no
model. The estimate formula and the cache multipliers:
How the estimate is calculated; how
the ceiling behaves on a breach: The budget; the cost
line each run prints:
Reading the cost line. A non-zero compile
on a repeat run means the plan is not being cached — see
Troubleshooting.
The plan cache
Section titled “The plan cache”Plans live in ./.verikun/plans/, keyed by the test prose + package + app build + platform,
and gated by a compiler fingerprint (verikun’s version plus the grammar, repair and
section prompt text).
- A fingerprint mismatch is a miss, so updating verikun recompiles rather than replaying a plan an older compiler produced.
- The compile is cached immediately, so an unchanged test never recompiles — unless it was rejected for not covering the test, which is never written.
- A green run re-persists the healed plan, so the next run is free again.
- Seeding from a prior build ignores the fingerprint — an older plan is still a fine starting point, provided it covers its own prose. A seed never crosses platforms.
- A test assembled from
@includefragments is keyed on the resolved text, and each chunk is additionally cached under its own text — so shared prose is compiled once across a suite. Prose that states no step (a title, a summary) is folded into the chunk of its own file that states the steps. - Concurrent runs that miss the same key serialise: the first compiles, the rest wait and
take its result, printing
compiled by a concurrent run (waited 0.5s). A hit takes no lock.VERIKUN_NO_PLAN_LOCK=1turns this off. --recompile(alias--no-cache) ignores what is on disk, but still accepts an entry a run racing this one writes, so N lanes do not each pay for one fragment.
Persisting the cache in CI: Self-healing in CI. The rules: Contracts.