{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://finmagine.com/developer/agent-recipes.schema.json",
  "title": "Finmagine Agent Recipes",
  "description": "Schema for /agent-recipes.json. Added per external review (Codex) to prevent field drift (required_tier/async/llm_required/status enums, steps[].tool presence) as recipes are hand-edited over time — validate developer/generate-agent-recipes.php's $recipes array against this before every regeneration.",
  "type": "object",
  "required": ["version", "generated_at", "recipes"],
  "properties": {
    "version": { "type": "string" },
    "generated_at": { "type": "string", "format": "date-time" },
    "note": { "type": "string" },
    "recipes": {
      "type": "array",
      "minItems": 1,
      "items": { "$ref": "#/$defs/recipe" }
    }
  },
  "$defs": {
    "recipe": {
      "type": "object",
      "required": ["id", "title", "description", "market", "required_tier", "async", "llm_required", "status", "steps", "output_contract"],
      "properties": {
        "id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
        "title": { "type": "string" },
        "description": { "type": "string" },
        "market": { "type": ["string", "null"], "enum": ["india", "us", "both", null] },
        "required_tier": { "type": "string", "enum": ["free", "api_pro", "api_max"] },
        "async": { "type": "boolean" },
        "llm_required": { "type": "boolean" },
        "status": { "type": "string", "enum": ["complete", "partial", "prerequisite"] },
        "quota": { "type": ["string", "null"] },
        "estimated_runtime": { "type": ["string", "null"] },
        "max_symbols": { "type": ["integer", "null"] },
        "steps": {
          "type": "array",
          "minItems": 1,
          "items": {
            "type": "object",
            "required": ["id", "tool", "description", "depends_on", "input_map", "transport", "external_call"],
            "properties": {
              "id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$", "description": "Stable, recipe-local step identifier — referenced by other steps' depends_on/input_map. Unique within the recipe, not globally." },
              "tool": { "type": "string", "description": "Must match a 'name' in mcpPublicToolDefs() — verify with developer/generate-agent-recipes.php's own tool-existence check before shipping any edit." },
              "description": { "type": "string" },
              "input_from": { "type": ["string", "null"], "description": "Free-text chaining hint for human/agent readability — kept alongside depends_on/input_map, not replaced by them. May still involve judgment (e.g. which symbols to carry forward) even when input_map covers the mechanical part." },
              "depends_on": { "type": "array", "items": { "type": "string" }, "description": "Step ids (within this recipe) whose response this step's request is built from. Empty array = no dependency (can run first, or independently). More than one entry = a genuine multi-parent DAG node, not always a straight line — see india_screen_to_committee's allocate_committee_risk step (3 parents)." },
              "input_map": { "type": "object", "additionalProperties": { "type": "string" }, "description": "requestParam -> 'stepId.dot.path[*].field' (wildcard maps every array element) or 'stepId.dot.path[N].field' (index a specific element). Resolved by a small hand-rolled evaluator, not a full JSONPath library. Values that resolve to arrays are passed through AS ARRAYS to the execution engine — never comma-joined here — so MCP tool arguments and POST JSON bodies receive real arrays; only a REST GET query string coerces one to a comma-separated value, and only via URLSearchParams' own native array-to-string coercion, not manual joining. An EMPTY object is valid and expected for steps whose inputs are a human/agent judgment call (e.g. which preset id to launch, which provider to use, a secret to save) — those need an interactive form in the Workflow Explorer, not silent auto-fill from a prior step." },
              "transport": { "type": "string", "enum": ["rest", "mcp"], "description": "Which calling convention the Workflow Explorer should use for this step. Every step across all 10 recipes is 'rest' today (every referenced tool has a REST route) — kept explicit rather than assumed so a future MCP-only tool (e.g. screen_natural_language) doesn't silently get treated as REST." },
              "external_call": { "type": "string", "enum": ["none", "finmagine_free_tier", "byo_key"], "description": "'none' = deterministic, no provider touched. 'finmagine_free_tier' = calls an LLM but uses Finmagine's own key (e.g. NL screener), not the user's. 'byo_key' = calls the user's own saved provider key and may consume real tokens/quota." },
              "max_items": { "type": ["integer", "null"], "description": "Optional cap when input_map's wildcard would otherwise map an unbounded array (e.g. 'up to 10 symbols')." },
              "fixed_params": { "type": "object", "description": "requestParam -> literal value, for params that must always be sent a fixed constant rather than chained from a prior step's response (e.g. market:'us' on a tool whose own default is 'india'). Distinct from input_map, which only resolves paths into stepResults — there is no way to express a constant through input_map's dot-path syntax. Found missing during the Workflow Explorer's first real execution of us_zero_cost_demo/us_trader_to_committee's fast_scan step, both of which describe 'Pass market:\"us\"' in prose but had no mechanical field carrying it." }
            }
          }
        },
        "output_contract": { "type": "string" },
        "failure_fallback": { "type": ["string", "null"] },
        "guardrail": { "type": ["string", "null"] },
        "note": { "type": ["string", "null"], "description": "Use for explicit gap/limitation disclosure on status:partial recipes — see us_momentum_setup_partial for the pattern." }
      }
    }
  }
}
