Skip to content

HTTP Actions

Two Actions over one engine in C++, for making HTTP requests from anywhere an Action can be run — Python, a Flow, or a model's tool call.

An ingestion service can inspect status and headers immediately, stream NDJSON records to workers as the body arrives, and verify trailers concurrently. This is useful application plumbing even when no model or agent participates.

Action For
make_http_request HTTP with nothing hidden: a port per protocol concern
web-fetch the fetch()-shaped adapter, for a caller that wants a document

Why HTTP uses an Action

An ordinary HTTP client returns one Response object after receiving headers, body, and trailers. A11 exposes these fields on separate streaming ports. This lets a caller

  • branch on the status while the body is still arriving, and never read one it did not want;
  • give the body to a parser and the trailer section to a checksum verifier, concurrently;
  • read responses the server pushed off a port, which a fetch()-shaped API has no way to express at all.

make_http_request

Inputs

Port Type Meaning
url string one, required absolute http/https URL
method string one GET when omitted
request_body bytes stream the request body, in order
options JSON one see Options

request_body distinguishes the input from the response body output. Feeding it is optional; closing it empty sends a bodyless request.

Outputs

One per concern, in the order they become readable.

Port Type Meaning
status_code integer one the final status, written before the body
headers JSON one lower-cased name → value; repeats joined ", " (set-cookie with \n)
fields JSON stream every field as [name, value], wire order, repeats intact
body bytes stream body chunks as they arrive
trailers JSON one the trailer section, {} when there was none; after body ends
redirects JSON stream one {url, status, location} per hop followed
pushes JSON stream one record per pushed response — see below
connection JSON one {url, http_version, secure, reused}

headers and fields are the same data twice, on purpose. A joined map is what resp.headers["content-type"] needs; a joined map also destroys repeated fields, which is precisely the detail this action exists to preserve.

status_code, not status

Flow reads x.status as the outcome of the step called x, whatever ports it declares — so a port named status would be unreachable from a flow. The same constraint applies to any action meant to be composed.

A 4xx is a response

make_http_request fails only when there is no response. A 404 was answered by a server that was reached, so it arrives on status_code with its body intact. A caller that wants a failure can make one from the status; the reverse is not recoverable. Treating every 4xx response as an action failure would also discard an error document that may contain details the caller needs.

Pushed responses

A push carries a head and a body, and one port cannot interleave several bodies without additional framing. Each pushed body therefore gets its own node, and the record on pushes carries that node's ID.

{"method": "GET", "url": "...", "path": "/style.css", "status": 200,
 "headers": {...}, "request_headers": {...}, "body": "<node id>"}

Read it with node(rec.body) in Flow, or action.get_node_map().get(rec["body"]) in Python. Needs options.accept_pushes; without it nothing is ever pushed, because the connection advertises SETTINGS_ENABLE_PUSH: 0 and a peer cannot spend your streams on responses you did not ask for.

Options

All optional.

Key Default Meaning
max_redirects 5 0 returns the 3xx as the response
timeout 300 seconds; the tighter of this and x-a11-deadline wins
request_body "buffer" "buffer" reads the port to its end and sends a content-length; "stream" sends each chunk as it arrives
http_version "auto" "auto", "2" or "1.1"
accept_pushes false accept HTTP/2 server pushes
reuse_connection true share a connection with other requests to the peer
max_body_bytes 32 MiB bound on the request and response bodies
user_agent a11-http/1 sent when no user-agent header is given
headers an object merged over the action's own headers
tls {verify_peer, ca_file, certificate_file, key_file}
omit output port names to close without writing

"stream" supports uploads whose length is not known in advance and therefore cannot include content-length. Streaming bodies cannot be replayed after a redirect; use "buffer" when redirects are possible.

web-fetch

The same engine with the protocol turned down. Inputs are the same; outputs are status_code, ok (below 400), headers, text, json, body, and items.

json closes without a value when the body is not valid JSON. This does not fail the action.

items decodes the body by content type, so a caller does not have to:

Content type One item per
text/event-stream SSE event, {event, data, id, json?}, as it arrives
application/x-ndjson, application/jsonl line, as it arrives
application/json holding an array element

Headers

An action header that does not begin with x-a11- is sent as an HTTP request header, verbatim. So Flow's with "accept": "application/json" and forward headers "authorization" are already HTTP header syntax, and A11's own framework headers — a deadline, a trace — stay out of the request. options.headers wins over an inherited value of the same name, and is how a literal x-a11-... header reaches a peer that wants one.

Connections

Concurrent requests to the same peer share one HTTP/2 connection. When its last request finishes, the connection closes; A11 does not retain idle pooled connections.

HTTP/1.1 is not shared, because A11's HTTP/1.1 connection carries one request by design. connection.http_version says which you got.

Registering them

from a11.actions import ActionRegistry
from a11.sdk import http

registry = ActionRegistry()
http.register(registry)                     # both
http.register(registry, low_level=False)    # only web-fetch

The two are separately selectable because they answer to different amounts of trust: a gateway happy to let a caller fetch a document may not want to hand out streamed uploads, arbitrary methods and server pushes.

From Python

from a11.sdk import http

async with await http.fetch("https://example.com/rows.ndjson") as response:
    if not await response.ok():
        raise RuntimeError(await response.text())
    async for row in response.aiter_items():
        ...

The request and fetch helpers wire the requested outputs and drain the remaining protocol ports.

a11.sdk.http.client

Driving the HTTP Actions from Python without wiring ports by hand.

make_http_request has eight output ports because HTTP has eight things to say, and that is exactly what a Flow wants. A Python caller who wants three of them does not want to open eight nodes to find out, so this module does the wiring: request and fetch feed the inputs, run the action, and hand back a Response that reads the ports as they are asked for.

from a11.sdk.http import client

response = await client.fetch("https://example.com/index.html")
print(response.status, await response.text())

Nothing here adds behaviour: it is the same Action either way, and a caller that wants a port at a time can use a11.sdk.http.actions directly. What it adds is that the ports nobody asked for are drained rather than left for the garbage collector, which is the one piece of bookkeeping the port model does not do for you.

Response

Response(action: Action, *, ports: Iterable[str])

What an HTTP Action produced, read port by port.

A Response exists as soon as the action has been started, which is before the response has arrived: status and headers await the ports that carry them, and the body is read only on request. Awaiting status separately lets a caller decide whether to read the body before it arrives.

action property

action: Action

The running action, for anything this wrapper does not cover.

status async

status() -> int | None

The response status code.

headers async

headers() -> dict[str, str]

Response header fields, lower-cased, repeats joined.

aiter_bytes async

aiter_bytes() -> AsyncIterator[bytes]

The response body, chunk by chunk, as it arrives.

read async

read() -> bytes

The whole response body.

ok async

ok() -> bool

Whether the status is below 400. web-fetch only.

text async

text() -> str

The body as text.

Uses web-fetch's own text port where there is one, and decodes the bytes otherwise, so this works for either action.

json async

json() -> Any

The body parsed as JSON, or None when it is not JSON.

aiter_items async

aiter_items() -> AsyncIterator[Any]

The decoded items: SSE events, NDJSON values, or array elements.

trailers async

trailers() -> dict[str, str]

The trailer section after the body; empty when there was none.

Only meaningful once the body has been read: that is where trailers are on the wire, so this awaits the body first.

aiter_fields async

aiter_fields() -> AsyncIterator[tuple[str, str]]

Every response header field in wire order, repeats intact.

redirects async

redirects() -> list[dict[str, Any]]

The hops that were followed, in order.

connection async

connection() -> dict[str, Any]

How the exchange was carried: url, http_version, secure, reused.

pushes async

pushes() -> AsyncIterator[tuple[dict[str, Any], Any]]

Each pushed response, paired with the node carrying its body.

A push has a head and a body, and one port cannot interleave several bodies; the record names a node instead, and this resolves it. Requires options={"accept_pushes": True}.

drain async

drain() -> None

Reads every port nobody asked for, and waits for the action.

A port with an unread value keeps its writer open, so a caller that took the status and walked away would leave the run unfinished. request and fetch used as context managers do this on the way out.

request async

request(url: str, *, method: str | None = None, body: bytes | Iterable[bytes] | None = None, headers: Mapping[str, str] | None = None, options: Mapping[str, Any] | None = None) -> Response

Start a make_http_request and return its Response.

Returns as soon as the request is under way, so Response.status can be awaited before deciding whether to read the body.

Parameters:

Name Type Description Default
url str

Absolute http or https URL.

required
method str | None

Request method; GET when omitted.

None
body bytes | Iterable[bytes] | None

Request body, whole or in pieces.

None
headers Mapping[str, str] | None

HTTP request headers, set as action headers.

None
options Mapping[str, Any] | None

The action's options document; see its schema.

None

Returns:

Type Description
Response

The response, whose ports are read as they are asked for. Use it as an

Response

async context manager, or call

Response

drain, so the ports nobody

Response

wanted are not left open.

fetch async

fetch(url: str, *, method: str | None = None, body: bytes | Iterable[bytes] | None = None, headers: Mapping[str, str] | None = None, options: Mapping[str, Any] | None = None) -> Response

Start a web-fetch and return its Response.

The fetch()-shaped path: a 4xx or 5xx is reported by ok rather than raised, so an error document can still be read.

Args: as request.

Returns:

Type Description
Response

The response.

a11.sdk.http.actions

The two native HTTP Actions, and how to register them.

  • make_http_request is HTTP with nothing hidden. Every concern the protocol keeps separate gets a port of its own -- the status, the header fields, the body, the trailer section, the redirect chain, the responses the server pushed, and how the connection was carried -- so a caller can act on the status while the body is still arriving, or hand the body to one consumer and the trailers to another.
  • web-fetch is the same machinery with the protocol turned down: a status, a header map, and the body as text, as JSON, as bytes, or decoded into a stream of items.

Install both on a registry with register:

from a11.actions import ActionRegistry
from a11.sdk.http import actions

registry = ActionRegistry()
actions.register(registry)

Each Action's schema and handler are also importable on their own:

from a11.sdk.http.actions import WEB_FETCH, WEB_FETCH_HANDLER, WEB_FETCH_SCHEMA

registry.register(WEB_FETCH, WEB_FETCH_SCHEMA, WEB_FETCH_HANDLER)

The handlers are NativeActionHandler handles rather than Python callables: pass one wherever a handler is accepted and the C++ implementation runs directly.

For driving them from Python without wiring ports by hand, see a11.sdk.http.client, whose request and fetch do the draining and hand back an object.

Headers

An action header that does not begin with x-a11- is sent as an HTTP request header, verbatim. That makes Flow's with "accept": "application/json" and forward headers "authorization" HTTP header syntax already, and keeps A11's own framework headers -- a deadline, a trace -- out of the request. Anything that cannot be spelled as an A11 header name (or a literal x-a11- header a peer genuinely wants) goes in options.headers instead, which also wins over an inherited value of the same name.

register

register(registry: ActionRegistry, *, low_level: bool = True, adapter: bool = True) -> None

Register the HTTP Actions on registry.

Parameters:

Name Type Description Default
registry ActionRegistry

Registry to register on.

required
low_level bool

Register make_http_request.

True
adapter bool

Register web-fetch.

True

The two are separately selectable because they answer to different amounts of trust. A gateway happy to let a caller fetch a document may not want to hand out streamed uploads, arbitrary methods, and server pushes; serving only web-fetch is how it says so.