HTTP egress
Governing ADR: ADR-0043 (substrate HTTP egress). The contract — one request kind, one reply kind, an echo-correlated reply, a deny-by-default allowlist enforced on every redirect hop — is stable. One backend ships today (a blocking
ureqadapter); the trait is built to take more.
An actor never opens a socket. Outbound network is a capability like every
other: a component's reach is exactly the mailboxes it can address, and the
network is one of them — mail aether.http.fetch to the aether.http mailbox
and handle the aether.http.fetch_result that comes back. The bytes cross the
mail boundary; the actor holds no connection, no client, no socket of its own.
When you drive the engine over MCP, aether.http is how a run reaches a remote
service — a content API, a forge endpoint, anything that speaks HTTP — without an
out-of-band process. When you author a component, it's a request/reply exchange
like any other sink: send a Fetch, handle the FetchResult.
Why it exists
Handing a component a socket would defeat the split the whole engine is built on. The substrate owns I/O so that a loaded actor's reach is bounded by the mailboxes it can address; a raw socket hands arbitrary network access to untrusted code and that boundary is gone. Portability pushes the same way — a wasm guest has no socket primitives at all, so network access has to be a request the substrate services on the guest's behalf. Routing it through mail keeps egress identical in shape to the file, render, and audio sinks: one mental model, one boundary, network reach gated behind the mail wall.
A component reaches this adapter only by mailing aether.http, and the
deployer controls which hosts pass its allowlist. That check is not limited to
the initial URL: the adapter runs its own bounded redirect-follow loop and
re-applies the allowlist (and the HTTPS requirement) to every hop, so a 3xx
that points at a host outside the allowlist fails with the same
AllowlistDenied a direct request to that host would.
What it does
One mailbox, one operation. Everything addresses the aether.http mailbox.
A single request kind pairs with a single reply kind:
| Request | Fields | Reply | Ok carries |
|---|---|---|---|
aether.http.fetch | url, method, headers, body, timeout_ms | aether.http.fetch_result | status, headers, body |
Fetch is the request: url (String), method (an HttpMethod enum —
Get / Post / Put / Delete / Patch / Head / Options), headers (a
list of name/value HttpHeader pairs), body (raw bytes), and timeout_ms
(Option<u32> — None uses the chassis default). The method is an enum on the
wire rather than a string, so "get" / "GET" / "Get" can't disagree across
guests; the substrate maps each variant to its canonical HTTP verb.
FetchResult is the reply — an Ok / Err enum. Ok carries the HTTP
status, the response headers, and the response body. Err carries an
HttpError. Both arms echo the originating url.
Replies correlate by the echoed URL. A handler dispatches on the reply
kind, which on its own erases which request a given reply answers — so both
arms echo the request's url to restore that. A caller matches a reply to its
request on the kind plus that echoed URL, with no correlation id field on the
kind itself. The request body is deliberately not echoed: correlation needs
the identity of the request, not its contents, so a multi-megabyte upload
produces a small reply. A caller that fires the same URL twice back-to-back (a
non-idempotent POST, say) leans on the per-source correlation id the substrate
already threads through replies rather than a per-kind field.
HttpError is one of seven shapes. InvalidUrl(String) (unparseable URL,
no host, or — with HTTPS required — an http:// scheme on the initial URL or
a redirect hop), Timeout
(the request exceeded its deadline), BodyTooLarge (request or response body
over the cap), AllowlistDenied (the initial host, or a redirect hop's host,
is not on the allowlist),
Disabled (egress is turned off chassis-wide), AdapterError(String) (the catchall, preserving
backend detail like a DNS failure or TLS handshake error as free-form text), and
Closed (the capability closed before the fetch answered). The
first five are precise enough to branch on — Timeout → reconcile effects and
retry only when the request is idempotent, AllowlistDenied → a config issue,
BodyTooLarge → use a smaller/bounded response, Disabled → surface to the
operator; the sixth keeps the long tail addressable without schema churn.
Every hop is gated by a deny-by-default allowlist. Before issuing a
request — the initial one or any redirect hop — the adapter parses the URL and
checks its host by exact string match; there is no wildcard parsing. An empty
or unset allowlist denies every host, and a miss returns AllowlistDenied
before that hop's request touches the wire. Redirects are not left to the
ureq backend's default follower: the adapter disables automatic following
and runs its own loop, bounded by a fixed redirect budget, re-checking the
allowlist and the AETHER_HTTP_REQUIRE_HTTPS scheme requirement on every
Location before following it. A redirect to a denied host or an http://
hop under require_https fails with the same error a direct request would;
a redirect that stays within the allowlist follows to its final response.
Credential-bearing headers (Authorization, Cookie, Proxy-Authorization)
are dropped when a hop crosses to a different host, mirroring the protection
ureq's own follower gives. The knobs that set the allowlist — and the
response cap, timeout, and HTTPS requirement — are the worked HttpConfig
example on the Configuration page:
AETHER_HTTP_ALLOWLIST— comma-separated hostnames allowed for a request. Empty or unset denies every request; the check runs on the initial URL and every redirect hop.AETHER_HTTP_DISABLE— turn egress off entirely; every fetch repliesHttpError::Disabled. Useful for CI and fixtures that shouldn't touch the network.AETHER_HTTP_REQUIRE_HTTPS— reject an initialhttp://URL withInvalidUrl. Off by default; the allowlist is the primary gate.AETHER_HTTP_MAX_BODY_BYTES— cap on request and response body bytes. Default 16 MB. An oversize body on either side returnsBodyTooLarge.AETHER_HTTP_TIMEOUT_MS— default per-request timeout. Default 30 s. AFetchwith atimeout_msoverrides it per request.AETHER_HTTP_SECRETS(--http-secrets) — secrets the cap attaches as request headers, bound by exact host: a comma list of<host>/<header-name>=<secret-name>or<host>/bearer=<secret-name>. Names only; the values are files in the--secrets-dirdirectory. See Operator-bound secrets below.
Headers pass through, with three exceptions. A caller-set Host header is
stripped and logged at warn — the substrate derives Host from the URL, and
letting a component override it would route the server-side vhost past the
allowlisted host the TLS handshake actually reached. User-Agent is injected as
aether/<version> when the caller doesn't set one. A header the operator bound
a secret to for the request's host is replaced by the bound value (below).
Every other header — Authorization, Content-Type, Accept — passes through
unchanged.
Operator-bound secrets (ADR-0235). An API key is never something an actor
holds, sends, or names. The operator puts each secret in a named file under
--secrets-dir and binds it here by host and header —
--http-secrets api.anthropic.com/x-api-key=anthropic, or
api.muse.example/bearer=muse for Authorization: Bearer <value> (RFC 6750).
The cap reads the bound files once, at boot, and attaches each value itself:
- only to a request whose host exactly equals the bound host, after the allowlist check, on each hop — so a redirect to another host never carries it;
- only over HTTPS — a plain-
httprequest to a bound host is refused withInvalidUrlbefore anything is dialed; - replacing a caller-set header of the same name (compared case-insensitively), with a warning that names only the header and host;
- marked sensitive, so a debug print of the request shows
Sensitive.
A host has one secret per header: a second binding for the same host and header
fails boot, naming the host, header, and both secret names — as do a bound host
missing from the allowlist and a header name that is not an HTTP token. A
disabled cap loads nothing. Fetch has no field that names a secret, so an
actor can neither choose one nor see one; every actor whose fetch reaches a
bound host gets the header. The walkthrough — files, modes, systemd / Docker /
Kubernetes, --print-config, rotation — is the
Supplying secrets recipe.
One request at a time—and a known exception to the normal blocking rule. The
adapter is backed by blocking ureq and currently runs on the capability's
dispatcher thread: a long fetch holds that actor's line until it finishes or
times out. The same limitation exists in the file sink. Do not copy this shape
into new capabilities; use the sanctioned offload/settlement primitives from
Concurrency and blocking. Moving these legacy sinks off the
dispatcher is a separate architectural correction.
The cap is wired on the desktop and headless chassis. The in-process substrate-harness
chassis omits it, so aether.http is not a registered mailbox there and mail to
it warn-drops like any unaddressed name.
Where this page's authority ends: parallel egress paths
aether.http is the general-purpose egress mailbox, and the AETHER_HTTP_*
knobs gate it. A provider component owns no HTTP client of its own: it mails
aether.http.fetch (ADR-0159), so this page's allowlist, body cap, timeout, and
secret bindings govern it — its API key is an --http-secrets binding here,
never component config. Network reach by
other routes — a subprocess run through aether.process, the RPC links between
the hub and its engines — is outside this page; a deployer locking down egress
reckons with that configuration separately. Content generation is a subject of its own
(Content generation).
How to use it
From a component. Declare depends(HttpCapability) and send the Fetch
kind:
#![allow(unused)] fn main() { ctx.send::<HttpCapability>(&Fetch { request_id: 1, url: "https://api.example.com/v1/status".into(), method: HttpMethod::Get, headers: Vec::new(), body: Vec::new(), timeout_ms: None, }); }
A send is fire-and-forget; timeout_ms: None uses the chassis default timeout.
The result arrives later as its own mail, which you receive like any other kind:
#![allow(unused)] fn main() { #[handler::response] fn on_fetch_result(&mut self, ctx: &mut WasmCtx<'_>, result: FetchResult) { match result { FetchResult::Ok { request_id, status, body, .. } => { /* request_id tells you which fetch */ } FetchResult::Err { request_id, error, .. } => { /* branch on error */ } } } }
A ctx that omits its actor is typed by it: the macro reads WasmCtx<'_> as
WasmCtx<'_, Self>, so the ctx reaches only the actors the component declares
with depends(R). The ctx's type arguments are receiver, sender, mode: the actor first, the
sender the handler requires second (Anyone when it states none), and the
reply mode third (WasmCtx<'_, Self, Anyone, Unchecked>); spell
WasmCtx<'_, Erased> for the untyped view.
Both arms echo the caller-minted request_id you stamped on the Fetch, so a
component with several fetches outstanding tells them apart by it — match it
against whatever state you were waiting to fill. The cap dispatches fetches
concurrently under a per-sender bound (ADR-0158), so two requests to the same
URL can be in flight at once; request_id is what disambiguates their replies,
where the echoed url (still on both arms, for logs) cannot. Custom headers,
another method, a body, or a per-request timeout are fields on the same Fetch.
The kinds live with the capability in
aether-http/src/kinds.rs (ADR-0121).
From an agent over MCP. send_mail rides settlement and hands back the
correlated reply, so a fetch is a single call: mail aether.http.fetch to
aether.http and the FetchResult comes back with it — no polling. The host
in the initial URL has to be on the chassis allowlist (AETHER_HTTP_ALLOWLIST at
spawn_substrate time) or the reply is AllowlistDenied. describe_kinds
carries the exact param schema for Fetch if you need it.
From an operator, for an authenticated API. Don't put a key in a Fetch
header. Bind it at spawn instead — spawn_substrate(args=["--secrets-dir", "<dir>", "--http-allowlist", "api.example.com", "--http-secrets", "api.example.com/bearer=example"]) — and the cap attaches it to every fetch to
that host. --print-config lists each secret's name and status in its
SECRETS section, never a value.
How to extend or reuse it
The seam is the backend trait. A new backend is an implementation of
HttpAdapter — one method, fetch, taking a validated request and returning the
response or a typed HttpError. The adapter owns allowlist enforcement (on the
initial URL and every redirect hop), URL validation, redirect behavior, the
body cap, and timeout application; the cap just moves bytes between the wire
and the adapter. The
ureq adapter is the one that ships and is
the reference for what an adapter is expected to enforce. The mail surface
doesn't change when the backend does — a component sending Fetch is untouched.
The allowlist is a chassis-wide stopgap ahead of per-component capability
declarations. A component that legitimately needs one host also reaches every
other host on the list. Narrowing that to per-component scope is future
capabilities work, tracked in ADR-0043's follow-ups. Streaming bodies (chunked
reads and sends, rather than the buffered Vec<u8> today) are deferred there
too — they tie to the byte-handle design.
Where to read more
- The transport decision, the backend choice, the body cap and timeout rationale, and the stopgap allowlist — ADR-0043.
- Secrets as named files, the host binding, and the injection rules — ADR-0235 and the Supplying secrets recipe.
- The
AETHER_HTTP_*knobs as a workedConfigderive, and how a per-spawn layer reaches them — Configuration. - Why a single
send_mailreturns the fetch's reply — the settlement contract on Tracing & settlement, and the tool surface on The MCP harness. - How a component receives a reply kind in a
#[handler::<class>]— Components & lifecycle.