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.get(url)→Ok({ status, body })orError(reason). The response is a record with the integer HTTPstatusand thebodyas a string.http.post(url, body)→ same result shape;bodyis sent as the request payload.
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.
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.
#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.