Skip to content

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.

{ "version": 1, "package": "com.example.app", "platform": "android", "steps": [ … ] }

steps is a list of nodes. Every node is one of six types.

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.

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.

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.

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

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

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.

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.

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.

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.

Each of these closes a way for a test to pass without doing what it says:

  • Use --enabled when 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 exits 0.
  • assert is verification-only and terminal. It is never healed.
  • tap and text auto-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).

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.

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.

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

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-usd and --cost-override are inert no-ops. The run is bounded by the repair cap and --timeout instead.
  • 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.

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 @include fragments 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=1 turns 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.