Agent Protocol¶
This document describes what an agent binary must implement to communicate with a
Consortium listener. The framework defines the server-side protocol obligations (via
ConnectedAgentsService), but the wire protocol is defined by the listener
implementation. The protocol described here matches the built-in HTTP listener in
consortium/components/listeners/consortium/http/.
If you are writing an agent for a custom listener, replace the HTTP-specific details with whatever transport your listener implements. The three obligations remain the same regardless of transport: register, poll for tasks, and submit results.
The three-message loop¶
An agent's runtime loop has three phases that repeat for its entire lifetime:
1. Registration (once, on first connection)
Agent -> Listener: identity information
Listener -> Agent: assigned agent_id
2. Check-in / task poll (repeated at a configured interval)
Agent -> Listener: agent_id
Listener -> Agent: list of pending TaskLaunchMessageModel objects
3. Result submission (once per completed task)
Agent -> Listener: agent_id + task_id + output
Listener -> Agent: acknowledgement
1. Registration¶
The agent must register before the framework will accept check-ins from it. Registration associates the agent with a listener and creates its record in the framework.
HTTP listener¶
Request
POST /register (URL path is configurable via the listener's registration_url_paths option)
Content-Type: application/json
{
"payload_id": "<uuid>", # preferred: ID of the generated payload
"agent_type": "<string>", # fallback: agent type name (e.g. "recon_agent")
"user": "<string>", # OS username running the agent
"is_admin": <bool>, # elevated privileges
"os": "<string>", # operating system string
"version": "<string>", # OS version
"arch": "<string>", # CPU architecture (e.g. "x86_64")
"pid": <int>, # process ID
"locale": "<string>", # system locale (e.g. "en-US")
"local_ip": "<string>", # agent's local IP
"hostname": "<string>" # agent's hostname
}
Exactly one of payload_id or agent_type must be present. Using payload_id is
strongly preferred: it links the agent back to the generated payload and allows the
framework to resolve the agent type automatically. All other fields are optional but
improve the agent's detail view.
Response (200 OK)
The agent must store the returned agent_id. It is the agent's identity for all
subsequent requests.
Response on failure (401 Unauthorized)
The agent should disconnect and optionally retry after a delay.
2. Check-in and task retrieval¶
The agent polls the listener at a configurable interval to receive pending tasks. The
check-in is also how the framework records that the agent is alive (it updates
datetime_last_checked_in).
HTTP listener¶
Request
The Cookie header carries the agent_id returned during registration.
Response (200 OK)
[
{
"task_id": "<uuid>",
"command": "<capability name>",
"arguments": { "<option_name>": <value>, ... },
"data": {}
},
...
]
An empty array [] means no tasks are pending. The agent should sleep for its
configured delay and poll again.
Each task object in the array is a TaskLaunchMessageModel serialized via
to_json(). The agent executes each task independently:
to_json() is the server-side serialization method. Agent implementations should depend
on the JSON object shape shown above, rather than on that Python method name.
command: the capability name to execute (e.g."shell","info","download")arguments: the validated option values provided by the operatordata: additional structured data; may be emptytask_id: must be echoed back in all result messages for this task
Binary payloads (payload) are excluded from to_json(). If a listener transmits
binary payloads it does so via an additional channel (multipart, base64 encoding, etc.).
Response on failure (401 Unauthorized)
The agent should treat this as a de-registration signal and either reconnect ( re-register) or exit, depending on its configuration.
3. Result submission¶
After executing a task (or when a task fails), the agent submits a result. The result
must include the task_id returned in the original task message.
HTTP listener (JSON-only result)¶
Request
POST /results (URL path is configurable via results_url_paths)
Content-Type: application/json
Cookie: <agent_id>
{
"task_id": "<uuid>",
"success": <bool>,
"message": "<string>",
"data": { ... }
}
task_id: matches the task being completedsuccess:trueif the task succeeded,falseotherwisemessage: human-readable output or error descriptiondata: structured output; may be empty{}
Response (200 OK)
The listener responds with status 200 and an empty body. The agent can discard the response body.
HTTP listener (binary payload result)¶
When a task produces binary output (file download, screenshot, etc.), the agent uses multipart form data:
Request
POST /results
Content-Type: multipart/form-data; boundary=<boundary>
Cookie: <agent_id>
--<boundary>
Content-Disposition: form-data; name="json"
Content-Type: application/json
{"task_id": "<uuid>", "success": true, "message": "...", "data": {}}
--<boundary>
Content-Disposition: form-data; name="payload"
Content-Type: application/octet-stream
<binary data>
--<boundary>--
The json part carries the result metadata. The payload part carries the raw binary.
Multi-message tasks¶
Some capabilities require more than one round trip. A file download capability, for example, expects the agent to send a series of messages after the initial task is dispatched:
Server (sends) -> Agent: TaskLaunchMessageModel {command: "download", arguments: {source: "/etc/passwd"}}
Agent (sends) -> Server: {success: true, data: {type: "file", path: "/etc/passwd", size: 1024}}
Agent (sends) -> Server: {success: true, data: {type: "chunk"}, payload: <binary chunk>}
Agent (sends) -> Server: {success: true, data: {type: "end_of_transfer"}}
The capability's on_execute() method calls await self.recv_from_agent() once per
expected message. Each call blocks until the agent submits a result message with the
matching task_id. The agent must therefore submit multiple result messages for the
same task_id until the exchange is complete.
The wire format for each additional message is the same as a normal result submission.
Protocol implementation checklist¶
When implementing an agent for a custom listener, ensure the agent:
- Sends a registration message on startup and stores the returned
agent_id - Re-registers if the server responds with 401 Unauthorized to a check-in
- Polls for tasks at a configurable interval (with optional jitter)
- Executes each received task concurrently or sequentially (depending on the capability)
- Submits a result for every task received, even on failure
- Echoes back the
task_idfrom the task message in every result message - Handles multi-message exchanges by submitting multiple results with the same
task_iduntil the server capability signals completion - Disconnects gracefully when the listener stops sending responses
Complete HTTP agent loop (pseudocode)¶
# Registration
response = post("/register", {
"payload_id": PAYLOAD_ID,
"user": os.getlogin(),
"is_admin": is_admin(),
"os": platform.system(),
"hostname": socket.gethostname(),
...
})
agent_id = response["agent_id"]
# Main loop
while True:
# Poll for tasks
tasks = get("/tasks", headers={"Cookie": agent_id})
for task in tasks:
result = execute(task["command"], task["arguments"])
post("/results", {
"task_id": task["task_id"],
"success": result.success,
"message": result.message,
"data": result.data,
}, headers={"Cookie": agent_id})
sleep(SLEEP_TIME + random_jitter(SLEEP_TIME_JITTER))