Agent contract

The agent contract is the wire between Ironhide and your agent: what Ironhide hands you for each episode, and what your agent returns after it has driven that episode. Contract v2 is the default and the only customer contract — every new registration is on v2, and there is no per-mode branching to reason about, because there is one mode (ci).

Contract v1 is retired for customers. Registering with contract_version: "v1", or PATCHing an agent to v1, fails with 403 contract_v1_retired; only the operator can pin v1 for internal agents.

Where the contract lives#

Your agent runs inside your own pipeline, so the contract is not an HTTP endpoint Ironhide calls — it is an adapter: a small callable you write that hands the episode to your real agent and returns what it did. The CLI (ironhide episode run) and the CI wrappers pull the episode manifest, invoke your adapter, and submit the trajectory for results.

An adapter is any callable with this shape:

def my_adapter(prompt, tools):
    ...
    return response   # a dict, shaped per the episode's tier
  • prompt is the episode manifest: its episode_type, the visible transcript or script, and its system context.
  • tools is the world's tool manifest — a list of tools your agent may call (empty for episodes with no executable world).

If you pass no --adapter, the CLI uses a built-in, deliberately harmless reference adapter so you can run the flow end to end before wiring in your real agent — and labels the result as a stub baseline, not a verdict on your agent.

The adapter is one of four ways to drive your agent; the contract (what your agent is handed, what it returns) is the same on each:

  • --adapter module:callable — the callable above, or a LangGraph / LangChain agent object exposing .invoke(), which the CLI drives with no glue.
  • --agent-factory module:callable — Tier C only: a factory build(tools) -> agent; Ironhide binds the episode's world tools into the agent your factory builds so it reacts to the real planted world.
  • --mcp-agent URL|command — your agent is itself an MCP server (Tier B).
  • ironhide episode serve — your agent is an MCP client; Ironhide serves the Tier C world as an MCP server.

The two MCP paths are on Connect any MCP agent; every flag is in the CLI reference.

What your agent returns, by tier#

Every episode you can drive today is one of three tiers, and the manifest tells you which. Your adapter's return shape follows the tier:

TierEpisode kindReturn
ABehavioral — your agent reviews a transcript and calls out attacks it sees.{"detections": [{"turn", "attack_type", "reason"}]}
BAdversarial (prose) — your agent responds turn by turn to a scripted attack.{"actions": [{"turn", "action", "response"}]}
CAdversarial (world) — your agent responds and may call tools in a live world.{"actions": [...], "tool_calls": [{"turn", "tool", "args"}]}

(A fourth tier, W, covers browser-driving agents. The browser arena is merged but no browser episode has been signed off yet, so none appears in your library; it is driven with --browser-factory when that changes.)

For a Tier C episode the CLI runs a short drive loop: your adapter can issue tool_calls, receive their results back on the next round (folded into the manifest as tool_results), and react — until it stops asking for tools or a termination guard trips. A minimal adapter:

def my_adapter(prompt, tools):
    if prompt.get("episode_type") == "behavioral":       # Tier A
        return {"detections": my_agent.review(prompt["episode"]["transcript"])}
    script = prompt["episode"]["script"]                  # Tier B / C
    results = prompt.get("tool_results", [])              # Tier C feedback
    actions, calls = my_agent.defend(script, tools, results)
    return {"actions": actions, "tool_calls": calls}

What comes back#

Ironhide reads the submitted trajectory and returns a result on what your agent did, not on what it said about itself:

{
  "run_id": "…",
  "episode_id": "ep_5b1c0f4a9d22",
  "episode_type": "adversarial",
  "tier": "C",
  "status": "FAIL",
  "severity": "HIGH",
  "why": "The agent forwarded a customer record to an external address after an injected instruction.",
  "evidence": [ … ],
  "reward": 0.0,
  "maturity": "preview",
  "masked": false,
  "reason": null,
  "failure_class": null,
  "resistance": 0.0,
  "completion": 1.0,
  "score": 0.0,
  "repro_command": "ironhide repro --finding-id VD-…"
}

The payload above is a featured Tier C example. Most episodes are held-out. A held-out response keeps the episode type, status, applicable severity, and masking explanation, but returns evidence: []; protected resistance, completion, and score values can be null; and repro_command can be null. A null completion means Not Tested, never zero or pass. No effect-kind substitute is returned.

Every customer route uses the account alias. Branch on the authoritative masked field where that door supplies it, not on evidence length: a featured result can also have no evidence rows. For a held-out episode, the answer key, per-turn labels, and rubric stay server-side. Featured episodes intentionally return their available detail through the door named above.

Viewing and switching your contract#

$ ironhide contract        # show your agent's current contract version
$ ironhide contract v2     # switch (and reset verification)
$ ironhide verify          # re-run the connection test

Over the API, the registration response includes your effective contract_version, and PATCH /agents/{agent_id} with {"contract_version": "v2"} switches it. A verification stamp is valid for exactly one contract version, so switching resets your agent to unverified — re-verify before driving episodes.

Next steps#