How opfor delivers conversation context to an HTTP agent target across turns, and how it threads a session id. One model, shared by opfor run (config file, wizard, SDK), the MCP server tool, and opfor hunt.
There are two independent decisions.
1. Stateless vs stateful — who holds the history
stateful | The target… | opfor sends per turn |
|---|
false | has no memory (raw LLM APIs — OpenAI, Groq, Anthropic-compat, LiteLLM, vLLM). | the full {role, content} history as a chat-completions messages array. No session id; forces OpenAI shape. |
true (default) | keeps conversation history server-side, keyed by a session id opfor passes. | only the current turn’s prompt + the session id. |
2. Client-owned vs server-owned — who mints the session id (stateful only)
| Mode | Who mints the id | Turn 1 | Examples |
|---|
| client-owned | opfor | opfor sends its id from turn 1 | LangGraph thread_id, Rasa sender_id, most custom agents |
| server-owned | the target | opfor sends no id, then echoes the one the target returns | OpenAI Responses previous_response_id, MCP Mcp-Session-Id, cookie sessions |
Does your target return a session id in its response (body field, header, or Set-Cookie)? That’s server-owned. Does it just expect you to supply an id it keys on? That’s client-owned. Raw LLM API with no session concept? That’s stateless.
The session config
"session": {
"send": { "in": "body" | "header", "name": "…" }, // where opfor WRITES the id
"receive": { "in": "body" | "header" | "set-cookie", "name": "…" } // where opfor READS a returned id
}
- Presence of
receive selects server-owned mode; its absence is client-owned.
send is always required when session is set, and name on it is required.
send.in: "body" → name is a dot-path in the request body; "header" → an HTTP header name.
receive.in: "body" or "header" both require a non-empty name. "set-cookie" is the only receive location where name is optional — it captures the returned cookie pair, echoed back via a Cookie header (set send to { "in": "header", "name": "Cookie" }).
- Legacy alias:
sessionIdField: "x" (CLI) / sessionField: "x" (SDK) is shorthand for session: { send: { in: "body", name: "x" } } — client-owned, body. Still honored; prefer session for new configs.
Server-owned turn-1 flow
- Turn 1 — the request goes out with no session id.
- opfor reads the returned id via
receive and remembers it.
- Turns 2+ — that id is echoed via
send.
If a server-owned target returns no id, opfor falls back to a client-minted id and logs a warning.
Per-surface syntax
| Surface | How to configure |
|---|
opfor run / config file | target.stateful + target.session (or legacy target.sessionIdField) — see config reference |
| SDK | target.stateful + target.session (or legacy target.sessionField) — see SDK reference |
| MCP server tool | agent_session_send_in / agent_session_send_name / agent_session_receive_in / agent_session_receive_name — see MCP server |
opfor hunt | --target-config <file> (a run-style target block); or --stateful --session-field <name>; or the --ui setup form — see autonomous mode |
Examples
Client-owned (body)
Server-owned (body)
"session": { "send": { "in": "body", "name": "session_id" } }
"session": {
"send": { "in": "body", "name": "session_id" },
"receive": { "in": "body", "name": "session_id" }
}
Other target types
- Local-script (
target.type: "local-script"): sessionId is always included in the stdin JSON on multi-turn attacks; your script owns the history. session / stateful do not apply.
- Browser extension: drives a live chat UI in the DOM — there is no session id to configure.
For opfor hunt, each attack thread invents its own id for client-owned targets. For server-owned targets, each thread captures the target’s returned id independently — and because the session belongs to the target, forking a thread starts a new server session (opfor can’t fork a session it doesn’t own).