{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://mercurialsolo.github.io/mantis/reference/plan.schema.json",
  "title": "Mantis micro-plan",
  "description": "Top-level shape of a Mantis micro-plan JSON file. Two equivalent forms are accepted: (1) a flat array of step objects (the original shape), or (2) an object {steps, runtime} where the optional 'runtime' block declares plan-level defaults (proxy, cost cap, time cap) so the plan is self-describing instead of requiring every caller to remember the right submission flags. Step types accept additional optional fields documented in 'docs/getting-started/plan-formats.md' and 'docs/getting-started/concepts.md'. The schema is intentionally permissive (additionalProperties is true at every level) so plans can carry plan-specific fields without breaking validation — IDE autocomplete still covers the well-known fields.",
  "oneOf": [
    {
      "type": "array",
      "items": {
        "$ref": "#/$defs/Step"
      }
    },
    {
      "type": "object",
      "additionalProperties": true,
      "required": ["steps"],
      "properties": {
        "steps": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/Step"
          }
        },
        "runtime": {
          "$ref": "#/$defs/Runtime"
        }
      }
    }
  ],
  "$defs": {
    "Runtime": {
      "type": "object",
      "additionalProperties": true,
      "description": "Plan-level runtime defaults. Submission-time arguments (CLI flags, HTTP body fields) override these when set explicitly; absent submission args fall back to the plan's declared defaults.",
      "properties": {
        "proxy_disabled": {
          "type": "boolean",
          "description": "When true, the runner skips proxy setup and connects directly. Useful for plans whose target site is proxy-hostile (Cloudflare-protected SaaS that whitelists the test environment's IP)."
        },
        "proxy_provider": {
          "type": "string",
          "enum": ["privateproxy", "oxylabs", "iproyal"],
          "description": "Which proxy backend to use when proxy is enabled. Defaults to the runtime's MANTIS_PROXY_PROVIDER env var (today: iproyal). Prefer ``privateproxy`` for residential exits — uses PRIVATEPROXY_* env vars."
        },
        "proxy_city": {
          "type": "string",
          "description": "Preferred proxy exit city — passed to the proxy provider's session API."
        },
        "proxy_state": {
          "type": "string",
          "description": "Preferred proxy exit state (US two-letter code)."
        },
        "max_cost": {
          "type": "number",
          "minimum": 0,
          "description": "Per-run cost ceiling in USD. The runner halts when exceeded."
        },
        "max_time_minutes": {
          "type": "integer",
          "minimum": 1,
          "description": "Per-run wall-clock ceiling in minutes. The runner halts when exceeded."
        },
        "compute_backend": {
          "type": "string",
          "enum": ["computer_plane", "browser_use_plane"],
          "default": "computer_plane",
          "description": "Which compute plane runs this plan. Default ``computer_plane`` (Xvfb + Chrome + xdotool, CUA-pure, stealth-capable — #696). Set ``browser_use_plane`` for plans that need DOM-aware reads (current URL, tab management, link-target peek, semantic click roles — #785). Plan-level value overrides submission-time defaults; absent in the plan, the submitter's default is used, then the global default ``computer_plane``. Pure-CUA stays the path for stealth-sensitive sites — Browser-Use Plane does NOT promise CF/Turnstile parity at v1."
        },
        "state_repeat_threshold": {
          "type": "integer",
          "minimum": 0,
          "default": 0,
          "description": "Loop-stuck guard (#782): halt when this many consecutive iterations have an identical (url, screenshot, visible-text) fingerprint. 0 disables. Plan-level opt-in — recipes can override per-loop. Halts with class `loop_stuck`."
        },
        "pinned_source_url_pattern": {
          "type": "string",
          "default": "",
          "description": "Off-source-drift guard (#782): URL pattern the loop expects to stay inside. Accepts an exact origin (`https://news.ycombinator.com`) or a glob (`https://news.ycombinator.com/*`). Empty disables. Halts with class `off_source_drift` after `off_source_step_budget` consecutive off-pattern steps."
        },
        "off_source_step_budget": {
          "type": "integer",
          "minimum": 0,
          "default": 0,
          "description": "Off-source-drift guard companion (#782): how many consecutive off-pattern steps tolerated before halting. 0 disables the guard regardless of pattern."
        }
      }
    },
    "Step": {
      "type": "object",
      "additionalProperties": true,
      "required": ["intent", "type"],
      "properties": {
        "intent": {
          "type": "string",
          "minLength": 1,
          "description": "Single sentence in natural language. The brain (Holo3 / Claude) reads this verbatim — keep it concrete and one action wide."
        },
        "type": {
          "type": "string",
          "description": "Step kind. The enum lists the well-known types the runtime understands today; unknown types are not rejected (additionalProperties: true) but will fall back to a generic handler.",
          "enum": [
            "navigate",
            "navigate_back",
            "wait",
            "click",
            "double_click",
            "scroll",
            "drag",
            "key_press",
            "type_text",
            "loop",
            "holo3",
            "claude",
            "extract_data",
            "extract_url",
            "fill_field",
            "submit",
            "select_option",
            "right_click",
            "paginate",
            "filter",
            "done",
            "tool_call",
            "capture_link_in_new_tab"
          ]
        },
        "name": {
          "type": "string",
          "description": "tool_call steps: the registered host-tool name (see `register_tool` on MicroPlanRunner or GymRunner). The brain emits this verbatim; the runner short-circuits env.step and routes through the tool channel."
        },
        "args": {
          "type": "object",
          "description": "tool_call steps: keyword arguments passed to the registered handler. Shape matches the schema the host declared at registration time."
        },
        "url": {
          "type": "string",
          "description": "Target URL for navigate steps. Plans may use {{template}} placeholders the caller substitutes before submitting."
        },
        "wait_for_load": {
          "type": "boolean",
          "description": "navigate steps: block until the page reports load complete before returning."
        },
        "seconds": {
          "type": "number",
          "minimum": 0,
          "description": "wait steps: number of seconds to sleep before the next step."
        },
        "loop_count": {
          "type": "integer",
          "minimum": 0,
          "maximum": 50,
          "description": "loop steps: maximum iterations. Server-side cap is MANTIS_MAX_LOOP_ITERATIONS (default 50); higher values are silently clamped."
        },
        "loop_target": {
          "type": "integer",
          "minimum": -1,
          "description": "loop steps: zero-based index of the step to jump back to. -1 (default) means 'the step immediately after the matching section start'."
        },
        "steps": {
          "type": "array",
          "description": "loop steps: inline body executed each iteration. Recursive — nested loops are allowed.",
          "items": {
            "$ref": "#/$defs/Step"
          }
        },
        "max_steps": {
          "type": "integer",
          "minimum": 1,
          "description": "holo3 steps: maximum brain actions allowed before forced termination of this step."
        },
        "hint": {
          "type": "string",
          "description": "holo3 steps: extra detail the brain sees alongside the intent — usually a target-element description or a UI affordance hint."
        },
        "budget": {
          "type": "integer",
          "minimum": 0,
          "description": "Maximum brain actions for the step (legacy field; prefer 'max_steps' for new plans)."
        },
        "section": {
          "type": "string",
          "description": "Logical section label (e.g. 'setup', 'extraction', 'pagination'). Sections gate which steps run after a failed gate."
        },
        "required": {
          "type": "boolean",
          "description": "If true and the step fails after retries, the whole pipeline halts. Default false."
        },
        "gate": {
          "type": "boolean",
          "description": "If true, this is a verification gate — must pass before the runner advances to the next section. Default false."
        },
        "verify": {
          "type": "string",
          "description": "Expected outcome of the step in natural language. Surfaced to Claude in the verification prompt."
        },
        "claude_only": {
          "type": "boolean",
          "description": "If true, skip Holo3 and have Claude read the screenshot directly (used for extract_data / extract_url and verification gates)."
        },
        "target_role": {
          "type": "string",
          "description": "click / capture_link_in_new_tab steps (Browser-Use Plane only — #781): semantic role of the intended target ('title', 'comment_count', 'author', 'domain_badge', 'vote_button', ...). Resolver consults the site recipe; falls back to vision when no recipe entry matches. Pure-CUA executors ignore this field. Recipes live under `docs/recipes/<site>.md` (see `docs/recipes/news_ycombinator_com.md`)."
        },
        "source_selector": {
          "type": "string",
          "description": "capture_link_in_new_tab steps: CSS selector (or vision-grounded target) for the source anchor. The handler opens that anchor in a new tab, reads its URL, closes the tab, and returns to the source tab. Requires `runtime.compute_backend: browser_use_plane` (#779)."
        },
        "emit": {
          "type": "string",
          "enum": ["current_url", "current_url_title"],
          "description": "capture_link_in_new_tab steps: which fields to emit per opened link. Default: current_url (just the URL)."
        },
        "on_no_navigation": {
          "type": "string",
          "enum": ["skip", "retry", "halt"],
          "default": "skip",
          "description": "capture_link_in_new_tab steps: what to do when the new tab opens but does not navigate (e.g. the anchor was `href=#`). Default: skip."
        },
        "grounding": {
          "type": "boolean",
          "description": "If true, enable ClaudeGrounding for this step — useful when Holo3 mis-clicks a small or visually-ambiguous target."
        },
        "reverse": {
          "type": "string",
          "description": "Natural-language description of how to undo the step (e.g. 'Press Alt+Left'). Used by the agentic-recovery loop."
        },
        "notes": {
          "type": "string",
          "description": "Free-form author notes. Ignored by the runtime — useful for documenting plan-specific assumptions."
        },
        "extract": {
          "type": "object",
          "description": "claude steps: structured extraction schema. The brain returns one row matching 'fields' (or up to 'max_items' rows).",
          "additionalProperties": true,
          "properties": {
            "schema_name": {
              "type": "string",
              "description": "Identifier used in logs and CSV output filenames."
            },
            "fields": {
              "type": "array",
              "items": {
                "$ref": "#/$defs/ExtractField"
              },
              "description": "Ordered list of fields to extract."
            },
            "max_items": {
              "type": "integer",
              "minimum": 1,
              "description": "Cap on how many rows the brain may return. Omit for single-row extraction."
            }
          }
        },
        "params": {
          "type": "object",
          "description": "Structured payload for form-shaped step types: fill_field {label, value, aliases?}, submit {label, aliases?}, select_option {dropdown_label, option_label}, right_click {label, aliases?}, navigate {wait_after_load_seconds?}."
        },
        "hints": {
          "type": "object",
          "description": "Per-step grounding + verification hints. Recognised keys: layout ('listings' | 'single'), spam_indicators, spam_label, entity_name; and for click/submit steps the post-action success contract: expect_url_contains (str|list — every substring must appear in the post-action URL, else demote wrong_target), expect_url_excludes (str|list — substrings that must NOT appear), expect_text_present (str|list — for IN-PLACE submits that don't navigate, e.g. a LinkedIn connect where the modal closes, the URL stays the same and the button flips to 'Pending'; the listed phrase confirms success so the step isn't falsely demoted to submit_failed), fallback_url (a predictable URL the recovery layer navigates to after repeated click misses)."
        }
      }
    },
    "ExtractField": {
      "type": "object",
      "additionalProperties": true,
      "required": ["name", "type"],
      "properties": {
        "name": {
          "type": "string",
          "minLength": 1,
          "description": "Field key. Becomes a column in the CSV output."
        },
        "type": {
          "type": "string",
          "description": "Loose Python type hint: 'str', 'int', 'float', 'bool'.",
          "enum": ["str", "int", "float", "bool"]
        },
        "required": {
          "type": "boolean",
          "description": "If true, a row missing this field is dropped as a partial extraction."
        },
        "example": {
          "type": "string",
          "description": "Example value the brain sees in the extraction prompt."
        }
      }
    }
  }
}
