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 tierpromptis the episode manifest: itsepisode_type, the visible transcript or script, and its system context.toolsis 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 factorybuild(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:
| Tier | Episode kind | Return |
|---|---|---|
| A | Behavioral — your agent reviews a transcript and calls out attacks it sees. | {"detections": [{"turn", "attack_type", "reason"}]} |
| B | Adversarial (prose) — your agent responds turn by turn to a scripted attack. | {"actions": [{"turn", "action", "response"}]} |
| C | Adversarial (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 testOver 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#
- Verify the connection: the connection test.
- Running an evaluation: drive an episode with your adapter and read the result.
- Attack taxonomy: the categories a result can convict on.
