HTTP hooks, step by step
How a supervisor middleware service uses the new HttpHooks contract, with one WHOLE_BODY request, one DUPLEX response, and one DUPLEX request that writes headers after reading the body. Messages are shown as proto3 JSON with the .proto field names. bytes fields are shown as text instead of base64.
1. The model
For every HTTP message a stage is selected for, OpenShell opens one bidirectional gRPC stream to that stage. Requests use HttpHooks.EvaluateRequest and responses use HttpHooks.EvaluateResponse. A request with two selected stages opens two request streams; its response opens separate response streams.
Events (OpenShell → service)
preflight, begin, whole_body, input_chunk, input_end, session_end
Results (service → OpenShell)
preflight_result, whole_body_result, output_start, output_chunk, finish, deny (requests) or block_delivery (responses)
Lifecycle of one stream
In DUPLEX, input and output are independent. The service may send output_start right after begin or hold it until it has read all input, and chunk counts on the two sides need not match. finish is valid only after input_end. A deny or block_delivery can end the stream at any point.
Chaining. Each stage's preflight shows the headers produced by the stage before it. A stage that continues without the body hands its headers on immediately. A WHOLE_BODY stage hands them on at whole_body_result, and a DUPLEX stage at output_start. Until every stage finishes, OpenShell holds back the final byte or terminating chunk, so a late denial never leaves the recipient with a message that looks complete.
2. Opting in
A service keeps the existing HTTP_REQUEST and HTTP_RESPONSE bindings. It opts into HTTP hooks by requiring the openshell.supervisor-middleware.http-hooks capability. Gateways and supervisors that predate HTTP hooks don't support it, so they reject the service at Describe instead of calling the legacy RPCs.
SupervisorMiddleware.Describe response
{
"name": "pii-guard",
"extension": {
"protocol_version": { "major": 1, "minor": 0 },
"implementation_name": "example/pii-guard",
"implementation_version": "0.3.0",
"supported_capabilities": [
"openshell.supervisor-middleware.contract",
"openshell.supervisor-middleware.http-hooks"
],
"required_capabilities": [
"openshell.supervisor-middleware.http-hooks"
]
},
"bindings": [
{ "operation": "SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST",
"phase": "SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS",
"max_payload_bytes": 262144 },
{ "operation": "SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_RESPONSE",
"phase": "SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN",
"max_payload_bytes": 262144 }
]
}
Listing the capability only in supported_capabilities is rejected: older peers would accept that service and call it through the legacy hooks. Serve HttpHooks on the same endpoint as SupervisorMiddleware.
Gateway registration
[[openshell.supervisor.middleware]]
name = "pii-guard"
grpc_endpoint = "https://pii-guard.internal:50051"
tls_ca_cert_path = "/etc/openshell/pii-guard-ca.pem"
max_payload_bytes = 262144
timeout = "500ms"
Sandbox policy
network_middlewares:
pii:
middleware: pii-guard
order: 10
on_error: fail_closed
endpoints:
include: ["api.example.com", "llm.example.com"]
HTTP hooks always fail closed, so the gateway rejects on_error: fail_open for this service.
3. Example 1: WHOLE_BODY request redaction
The sandbox posts a small JSON message to api.example.com. The stage wants to see the complete body, replace any email address, and send the result on. A body of known, bounded size is the natural fit for WHOLE_BODY: one input, one terminal result.
eventpreflightHead of the request, the modes OpenShell can offer, and the limits. No body yet.
{ "preflight": {
"request": {
"target": { "scheme": "https", "host": "api.example.com", "port": 443,
"method": "POST", "path": "/v1/messages", "query": "" },
"headers": [
{ "name": "content-type", "value": "application/json" },
{ "name": "content-length", "value": "76" },
{ "name": "content-digest", "value": "sha-256=:n4bQ...=:" }
]
},
"phase": "SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS",
"context": { "request_id": "req-7c1e", "sandbox_id": "sbx-01J...", "sandbox": "agent-1",
"workspace": "default",
"originating_process": { "binary": "/usr/bin/python3", "pid": 0,
"ancestors": ["/usr/bin/bash"] } },
"middleware_name": "pii-guard",
"config": {},
"permitted_body_modes": ["HTTP_BODY_MODE_WHOLE_BODY", "HTTP_BODY_MODE_DUPLEX"],
"unavailable_body_modes": [],
"limits": { "max_chunk_bytes": 65536, "max_whole_body_bytes": 262144,
"idle_timeout": "30s" },
"declared_body_bytes": 76
} }resultpreflight_result.inspect.whole_bodyChoose WHOLE_BODY with a cap at or below max_whole_body_bytes.
{ "preflight_result": {
"inspect": { "whole_body": { "max_body_bytes": 65536 } }
} }eventbeginThe head this stage's body belongs to: the preflight head plus this stage's own preflight mutations (none here).
{ "begin": { "headers": [
{ "name": "content-type", "value": "application/json" },
{ "name": "content-length", "value": "76" },
{ "name": "content-digest", "value": "sha-256=:n4bQ...=:" }
] } }eventwhole_bodyExactly one complete body, plus trailers if the message was chunked.
{ "whole_body": {
"data": "{\"to\":\"ops@example.com\",\"text\":\"Ping jane.doe@example.com about the outage\"}",
"trailers": []
} }resultwhole_body_resultReplace the body, drop the stale integrity header, and record an audit-safe finding.
{ "whole_body_result": {
"replacement": "{\"to\":\"ops@example.com\",\"text\":\"Ping [email] about the outage\"}",
"header_mutations": [
{ "remove": { "name": "content-digest" } }
],
"diagnostics": {
"findings": [ { "type": "pii.email", "label": "Email address", "count": 1,
"severity": "low" } ]
}
} }
OpenShell owns framing, so the stage does not touch content-length; the 63-byte replacement is framed by OpenShell. It does not update integrity headers, so the stage removes content-digest (or writes a new value).
eventsession_endBest effort, no reply. The stage may also close its result stream right after whole_body_result.
{ "session_end": { "reason": "MIDDLEWARE_SESSION_END_REASON_NORMAL" } }Variant: deny instead
If the policy says email addresses must never leave the sandbox, the stage returns deny at whole_body time instead. OpenShell answers the sandbox with 403 and error: middleware_denied, including the middleware name and the validated reason_code. The free-form reason stays in the service's own logs.
{ "deny": { "diagnostics": { "reason_code": "pii_email", "reason": "matched 1 address in text" } } }
4. Example 2: DUPLEX response redaction (server-sent events)
The sandbox streams a completion from llm.example.com as text/event-stream. An open-ended stream has no complete body, so WHOLE_BODY isn't offered and the preflight says why. The stage picks DUPLEX: it reads input chunks, holds back only the bytes it can't decide on yet, and writes its own output stream.
The interesting part: a secret spans two input chunks. The stage can't release the first chunk as is, so its output chunks don't line up with the input.
eventpreflightResponse head, plus the request headers as the request chain forwarded them. WHOLE_BODY is unavailable with reason OPEN_ENDED.
{ "preflight": {
"response": {
"target": { "scheme": "https", "host": "llm.example.com", "port": 443,
"method": "POST", "path": "/v1/chat/completions", "query": "" },
"status_code": 200,
"headers": [
{ "name": "content-type", "value": "text/event-stream" },
{ "name": "cache-control", "value": "no-cache" }
],
"request_headers": [
{ "name": "content-type", "value": "application/json" },
{ "name": "accept", "value": "text/event-stream" }
]
},
"phase": "SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN",
"context": { "request_id": "req-9a40", "sandbox_id": "sbx-01J...", "sandbox": "agent-1",
"workspace": "default" },
"middleware_name": "pii-guard",
"config": {},
"permitted_body_modes": ["HTTP_BODY_MODE_DUPLEX"],
"unavailable_body_modes": [
{ "mode": "HTTP_BODY_MODE_WHOLE_BODY",
"reason": "HTTP_BODY_UNAVAILABLE_REASON_OPEN_ENDED" }
],
"limits": { "max_chunk_bytes": 65536, "max_whole_body_bytes": 0,
"idle_timeout": "30s" }
} }
declared_body_bytes is unset because the length isn't known. max_output_body_bytes is unset because response output streams to the sandbox without a total cap. The request carried Authorization after credential injection, but request_headers is recorded before injection, so the stage never sees it. Because a response stage may inspect bodies, OpenShell sent Accept-Encoding: identity upstream, so the body is not compressed.
resultpreflight_result.inspect.duplexDUPLEX has no settings. From here the stage owns every output byte.
{ "preflight_result": { "inspect": { "duplex": {} } } }eventbeginHead this stage's body belongs to.
{ "begin": { "headers": [
{ "name": "content-type", "value": "text/event-stream" },
{ "name": "cache-control", "value": "no-cache" }
] } }resultoutput_startSent right away: the stage doesn't need the body to set headers. No output_body_bytes, so OpenShell frames the response as chunked.
{ "output_start": {
"header_mutations": [
{ "write": { "name": "x-pii-guard", "value": "redacting",
"on_existing": "EXISTING_HEADER_ACTION_OVERWRITE" } }
]
} }
If this is the last stage, the response head now goes to the sandbox, so the client starts receiving events immediately.
eventinput_chunk #1Ends mid-secret. The stage keeps it as working state and sends nothing.
{ "input_chunk": { "data": "data: {\"delta\":\"Your key is sk-live-4f9a" } }eventinput_chunk #2Completes the first event and carries a second one.
{ "input_chunk": { "data": "c2e1d07b\"}\n\ndata: {\"delta\":\" - rotate it.\"}\n\n" } }resultoutput_chunk #1First event, redacted. OpenShell flushes each server-sent event chunk.
{ "output_chunk": { "data": "data: {\"delta\":\"Your key is [secret]\"}\n\n" } }resultoutput_chunk #2Second event, unchanged. DUPLEX sends unchanged bytes too.
{ "output_chunk": { "data": "data: {\"delta\":\" - rotate it.\"}\n\n" } }eventinput_endUpstream finished. Sent exactly once, even for an empty body.
{ "input_end": { "trailers": [] } }resultfinishTerminal success, valid only after input_end. Now OpenShell sends the terminating chunk.
{ "finish": {
"diagnostics": { "findings": [ { "type": "secret.api_key", "label": "API key",
"count": 1, "severity": "high" } ] }
} }idle_timeout (30 s) and, after input_end, return something within 30 s. Time the upstream spends producing tokens doesn't count. If the stage instead found a secret it must not let through at all, it would return block_delivery: OpenShell then aborts the response, which has already started, without an error body.5. Example 3: DUPLEX request with headers computed from the body
Some stages need the complete body before they can write headers, such as a signer that adds a body hash. DUPLEX supports this: the stage reads all input first, then sends output_start with the header and the exact output length. Nothing goes upstream before output_start, because the head isn't committed until then.
eventpreflightThis route buffers request output before contacting the upstream, so the preflight reports the cap in max_output_body_bytes.
{ "preflight": {
"request": { "target": { "scheme": "https", "host": "dynamodb.us-east-1.amazonaws.com",
"port": 443, "method": "POST", "path": "/", "query": "" },
"headers": [ { "name": "content-type", "value": "application/x-amz-json-1.0" } ] },
"phase": "SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS",
"permitted_body_modes": ["HTTP_BODY_MODE_WHOLE_BODY", "HTTP_BODY_MODE_DUPLEX"],
"limits": { "max_chunk_bytes": 65536, "max_whole_body_bytes": 262144,
"idle_timeout": "30s", "max_output_body_bytes": 10485760 },
"declared_body_bytes": 39
} }
SigV4 signing buffers up to 10 MiB. Output beyond max_output_body_bytes fails the message with body_over_limit.
resultpreflight_result.inspect.duplex
{ "preflight_result": { "inspect": { "duplex": {} } } }eventbegin → input_chunk → input_endThe stage reads everything first.
{ "begin": { "headers": [ ... ] } }
{ "input_chunk": { "data": "{\"Action\":\"PutItem\",\"Item\":{\"id\":\"42\"}}" } }
{ "input_end": { "trailers": [] } }resultoutput_start → output_chunk → finishHeaders that depend on the body, an exact length for Content-Length framing, then the bytes.
{ "output_start": {
"header_mutations": [
{ "write": { "name": "x-body-sha256", "value": "3f1c...e9",
"on_existing": "EXISTING_HEADER_ACTION_OVERWRITE" } }
],
"output_body_bytes": 39
} }
{ "output_chunk": { "data": "{\"Action\":\"PutItem\",\"Item\":{\"id\":\"42\"}}" } }
{ "finish": {} }
If the output differs from output_body_bytes, the stage fails with middleware_lifecycle_violation. With a declared length, OpenShell drops any input trailers, because Content-Length framing can't carry them.
6. Service skeleton
Pseudocode for a request handler. A response handler has the same shape, with block_delivery in place of deny.
async def EvaluateRequest(events):
pre = (await events.next()).preflight
if not wants(pre.request.target, pre.config):
yield {"preflight_result": {"continue_without_body": {}}}
return
modes = pre.permitted_body_modes
if WHOLE_BODY in modes and pre.declared_body_bytes is known and small:
yield {"preflight_result": {"inspect": {"whole_body": {
"max_body_bytes": min(pre.limits.max_whole_body_bytes, MY_CAP)}}}}
await events.next() # begin
body = (await events.next()).whole_body.data
if must_block(body):
yield {"deny": {"diagnostics": {"reason_code": "pii_email"}}}
return
yield {"whole_body_result": {"replacement": redact(body),
"header_mutations": [{"remove": {"name": "content-digest"}}]}}
return
if DUPLEX in modes:
yield {"preflight_result": {"inspect": {"duplex": {}}}}
await events.next() # begin
yield {"output_start": {}} # chunked output
scanner = StreamingRedactor() # holds back undecided bytes
async for ev in events:
if ev.input_chunk:
for out in scanner.feed(ev.input_chunk.data):
yield {"output_chunk": {"data": out}} # non-empty, <= max_chunk_bytes
elif ev.input_end:
for out in scanner.flush():
yield {"output_chunk": {"data": out}}
yield {"finish": {}}
return
# No mode fits and the stage must see bodies: say so with a reason code.
yield {"deny": {"diagnostics": {"reason_code": "body_not_inspectable"}}}
7. Rules worth remembering
| Topic | Rule |
|---|---|
| Ordering | Preflight needs preflight_result or a denial. WHOLE_BODY ends with whole_body_result or a denial. DUPLEX sends output_start before any output_chunk, even for empty output, and ends with finish after input_end. An unset, unknown, or out-of-order result fails closed. |
| Ending the stream | After a terminal result, close the result stream or wait for the best-effort session_end. Both are fine. |
| Body eligibility | No body modes for encoded bodies, and for bodyless, partial, or no-transform responses. No WHOLE_BODY when the declared length exceeds the limit or for open-ended responses. No DUPLEX for a response when the request or response isn't HTTP/1.1, because the client couldn't detect a truncated body. Read unavailable_body_modes to tell these apart. |
| WHOLE_BODY limits | Up to the stage payload limit (at most 4 MiB). Two minutes to receive, process, and deliver. A body over the selected cap fails; there's no fallback to DUPLEX. |
| DUPLEX limits | Chunks up to max_chunk_bytes (64 KiB or the payload limit, whichever is smaller). No total time or size cap unless max_output_body_bytes is set. 30-second stall timeout on the stage. |
| Headers | Credential, routing, framing, hop-by-hop, and content coding headers are protected. Request headers that carry credential placeholders are omitted and can't be changed. Integrity headers are not updated for you. A stage's mutations apply as a unit: one invalid change fails the stage. |
| Trailers | Trailer mutations can modify or remove trailers the input carries, but can't add new names. |
| Failures | Always fail closed. Before forwarding starts, a request denial or failure and a response block_delivery become 403, and a response failure becomes 502. After forwarding has started, OpenShell closes or aborts without completing the message. |