Talking to the World
This chapter is a tour of the capabilities and standard library modules you'll use to build real programs: files, HTTP clients and servers, TLS, JSON, SQL, and time with time zones. Each section is short, because by now the pattern is familiar: the capability decides what's possible, and the module does the work.
Files
Fs is a capability for one directory and everything under it. Paths are relative to that directory.
fn main(root: Root) -> Result[(), IoError] {
let fs = root.fs
fs.make_dir("journal")?
let j = fs.under("journal")
j.write("monday.txt", "rain\n")?
j.append("monday.txt", "then sun\n")?
j.write("tuesday.txt", "wind\n")?
for name in j.list("")? {
let text = j.read(name)?
root.stdout.print("{name}: {text.lines().join(", ")}")
}
root.stdout.print("exists: {j.exists("monday.txt")}, is a directory: {fs.is_dir("journal")}")
j.remove("tuesday.txt")?
root.stdout.print("left: {j.list("")?}")
Ok(())
}$ mkdir work && tessera run main.tsr --grant fs=work
monday.txt: rain, then sun
tuesday.txt: wind
exists: true, is a directory: true
left: ["monday.txt"]
$ ls work/journal
monday.txt
The methods are read, write, append, list (sorted names), make_dir, remove (a file or an empty directory), rename, info, exists and is_dir, plus the narrowing methods under and read_only from chapter 12. Reading and writing deal in Str; convert with to_bytes() and to_str() for binary data.
fs.rename(from, to) moves a file or directory in one step, replacing whatever file was at to. That makes it the safe way to update a file: write the new version under another name, then rename it over the old one. If the program stops halfway, the old file is still whole, and a reader never sees half of each. fs.info(path) describes what is at a path, as a FileInfo with size in bytes, modified_ms (milliseconds since 1970, like Clock.now_ms()), is_file and is_dir.
// Writes a new version next to the old one, then swaps it in with one rename,
// so a reader sees either the whole old file or the whole new one.
fn save(fs: Fs, path: Str, text: Str) -> Result[(), IoError] {
let temp = "{path}.tmp"
fs.write(temp, text)?
fs.rename(temp, path)
}
fn main(root: Root) -> Result[(), IoError] {
let fs = root.fs
save(fs, "notes.txt", "buy tea\n")?
save(fs, "notes.txt", "buy tea\nwater the plants\n")?
let info = fs.info("notes.txt")?
root.stdout.print("notes.txt: {info.size} bytes, file {info.is_file}, directory {info.is_dir}")
let age = root.clock.now_ms() - info.modified_ms
root.stdout.print("changed in the last minute: {age < 60_000}")
match fs.info("drafts.txt") {
Ok(_) => root.stdout.print("drafts.txt exists"),
Err(e) => root.stdout.print("no drafts: {e.message()}"),
}
fs.rename("notes.txt", "../notes.txt")
}$ mkdir work && tessera run main.tsr --grant fs=work
notes.txt: 25 bytes, file true, directory false
changed in the last minute: true
no drafts: fs: `drafts.txt` does not exist
error: Denied("fs: path escapes the granted directory")
$ ls work
notes.txt
Both ends of a rename must be inside the granted directory, as for every other path, so the last rename, which tries to move the notes out of work, is refused. A read-only Fs refuses renames, as it refuses every change.
HTTP
The http module has a client and a server. Here they are in one program, talking over the loopback interface:
use http
fn route(r: http.Request) -> http.Response {
match (r.method, r.path) {
("GET", "/hello") => http.text(200, "hello!"),
("POST", "/echo") => http.text(200, "you sent {r.body.len()} bytes"),
_ => http.text(404, "nothing at {r.path}"),
}
}
fn client(net: Net, port: Int) -> List[Str] {
let base = "http://127.0.0.1:{port}"
let mut log: List[Str] = []
for path in ["/hello", "/nope"] {
match http.get(net, base + path) {
Ok(r) => log.push("GET {path}: {r.status} {r.body}"),
Err(e) => log.push("GET {path}: {e}"),
}
}
match http.post(net, base + "/echo", "text/plain", "some text") {
Ok(r) => log.push("POST /echo: {r.status} {r.body}"),
Err(e) => log.push("POST /echo: {e}"),
}
log
}
fn main(root: Root) -> Result[(), IoError] {
let l = root.listen.on("127.0.0.1:0")?
let port = l.port()
let net = root.net.only("127.0.0.1")
let results = group g {
// answer three requests, then stop
g.go(|| {
let _ = http.serve(l, 3, route)
})
g.go(|| client(net, port))
}
for line in results[0] {
root.stdout.print(line)
}
Ok(())
}$ tessera run main.tsr --grant listen=127.0.0.1:0 --grant net=127.0.0.1
GET /hello: 200 hello!
GET /nope: 404 nothing at /nope
POST /echo: 200 you sent 9 bytes
On the server side, a handler is a plain function from http.Request to http.Response. It gets no capability unless you give it one: route above can't touch files or the network, whatever a request says. http.serve(l, n, handler) answers n requests; http.serve_forever(l, handler) keeps going, running each connection as a task. A handler that panics gets a 500 response, and the server carries on.
root.listen.on("127.0.0.1:0") binds a free port, which l.port() reports; the grant listen=127.0.0.1:0 allows exactly that. Listening on [::] accepts both IPv6 and IPv4.
On the client side, http.get, http.post and http.request (any method, any headers) take a Net, which decides which hosts can be reached. root.net.get(url) is a simpler client built into the capability. Every status is a successful response, including 404 and 500; an Err means the server couldn't be reached or didn't speak HTTP.
There are also streaming forms, for bodies that are large or never end: http.request_lines passes each line of a response to a function as it arrives, and http.serve_stream lets a handler write its response in pieces.
TLS
HTTPS needs nothing new. http.get and Net.get use TLS for https:// URLs, and net.connect_tls(addr) opens a TLS connection directly. The server's certificate is always verified, and there is no switch to turn that off.
use http
// An HTTPS client: Net.get and http.get speak TLS for https:// URLs and
// always verify the server's certificate.
fn status(net: Net, url: Str) -> Str {
match http.get(net, url) {
Ok(r) => "{url}: {r.status}",
Err(e) => "{url}: {e}",
}
}
// An HTTPS server: the certificate and key are text the program reads
// with the capability that holds them, here a read-only Fs.
fn serve_tls(listen: Listen, certs: Fs) -> Result[(), IoError] {
let cert = certs.read("cert.pem")?
let key = certs.read("key.pem")?
let l = listen.on_tls("0.0.0.0:8443", cert, key)?
Err(http.serve_forever(l, |r| http.text(200, "hello over TLS")))
}
fn main(root: Root) {
root.stdout.print(status(root.net.only("example.com"), "https://example.com/"))
if root.args().contains("serve") {
match serve_tls(root.listen, root.fs.read_only()) {
Ok(_) => (),
Err(e) => root.stdout.print("{e}"),
}
}
}$ tessera run main.tsr
https://example.com/: Denied("net: example.com:443 is not granted")
The book runs this program without a network grant, so the client is denied before anything leaves the machine. With --grant net=example.com, it fetches the page over TLS.
To serve TLS, read the certificate and key with whatever capability holds them, usually a read-only Fs, and pass the text to listen.on_tls(addr, cert, key). That's all: no separate grant, and no file the runtime opens behind your back. Everything else, accept, http.serve and the rest, works unchanged on the TLS listener.
JSON
The json module parses and writes JSON. A parsed document is a json.Value, an enum with a variant for each kind of JSON value:
use json
struct Repo {
name: Str,
stars: Int,
}
fn parse_repos(text: Str) -> Result[List[Repo], Str] {
let v = json.parse(text)?
let items = v.as_list() ?? []
Ok(items.map(|item| Repo {
name: item.get("name")?.as_str() ?? "?",
stars: item.get("stars")?.as_int() ?? 0,
}))
}
fn to_json(r: Repo) -> json.Value {
json.Obj([json.field("name", json.Str(r.name)), json.field("stars", json.Num(r.stars.to_float()))])
}
fn main(root: Root) {
let text = "[\{\"name\": \"tessera\", \"stars\": 120\}, \{\"name\": \"book\", \"stars\": 7\}]"
match parse_repos(text) {
Ok(repos) => {
for r in repos {
root.stdout.print("{r.name}: {r.stars} stars")
}
let top = repos.filter(|r| r.stars > 100).map(to_json)
root.stdout.print(json.stringify(json.Arr(top)))
},
Err(e) => root.stdout.print("bad JSON: {e}"),
}
root.stdout.print("{parse_repos("[1, 2")}")
}$ tessera run main.tsr
tessera: 120 stars
book: 7 stars
[{"name":"tessera","stars":120}]
Err("expected `,` or `]` at byte 5")
v.get(key) and v.at(i) reach into objects and arrays, and as_str(), as_int(), as_list() and friends give the payload as an Option. That makes ?. and ?? the natural way to read a document whose shape you don't control. json.parse returns a Result whose error says what was expected and where; json.stringify writes compact JSON.
Inside a Tessera string, JSON's braces need a backslash, \{, since braces mean interpolation.
SQL
The sql module talks to SQLite:
use sql
fn main(root: Root) -> Result[(), IoError] {
let db = sql.memory()?
db.exec("CREATE TABLE books (id INTEGER PRIMARY KEY, title TEXT NOT NULL, year INTEGER)", [])?
for (title, year) in [("Dune", 1965), ("Neuromancer", 1984), ("Hyperion", 1989)] {
db.exec("INSERT INTO books (title, year) VALUES (?, ?)", [sql.Text(title), sql.Int(year)])?
}
let rows = db.query("SELECT title, year FROM books WHERE year > ? ORDER BY year", [sql.Int(1980)])?
for row in rows {
root.stdout.print("{row.text("title") ?? "?"} ({row.int("year") ?? 0})")
}
// a value that looks like SQL is still just a value
let sneaky = "x'); DROP TABLE books; --"
db.exec("INSERT INTO books (title) VALUES (?)", [sql.Text(sneaky)])?
let count = db.query_one("SELECT count(*) AS n FROM books", [])?
root.stdout.print("{count?.int("n") ?? 0} books")
db.close()
}$ tessera run main.tsr
Neuromancer (1984)
Hyperion (1989)
4 books
sql.memory() opens a fresh in-memory database; sql.open(fs, "app.db") opens a file inside the directory an Fs grants, read-only if the Fs is. A sql.Db is a plain value that names the connection: code that holds one can use that database and nothing else.
Values always go in as parameters: each ? in the SQL takes the next item of the list, as sql.Text, sql.Int, sql.Float, sql.Blob or sql.Null. There's no way to paste a value into the SQL text through the API, so the classic injection above is stored as an ordinary title. query returns every row, query_one the first, and row.text("title"), row.int("year") read columns as Options, None for NULL. db.transaction(|| ...) runs a closure between BEGIN and COMMIT, and rolls back on an error or a panic.
The module calls SQLite's C library, but your program doesn't need the foreign grant: the standard library trusts this one module to call SQLite, and the module keeps the database inside the Fs it was given. SQL can't attach other database files, so it can't reach outside the grant either.
Time
Clock.now_ms() gives the time as milliseconds since 1970. The time module turns those numbers into dates, with pure functions:
use time
fn main(root: Root) -> Result[(), IoError] {
let out = root.stdout
// a fixed moment, so the output is the same every run; a real program
// would use root.clock.now_ms()
let now = time.parse_iso("2026-11-14T09:30:00Z") ?? 0
out.print(time.iso(now))
out.print(time.format(now, "%A %d %B %Y, %H:%M"))
let due = now + time.days(3) + time.hours(2)
out.print("due {time.iso_date(due)}, in {(due - now) / time.hours(1)} hours")
out.print("a month later: {time.iso_date(time.add_months(now, 1))}")
let madrid = time.load_zone(root.fs, "Europe/Madrid")?
let tokyo = time.load_zone(root.fs, "Asia/Tokyo")?
out.print("Madrid: {time.format_in(now, madrid, "%H:%M %Z")}")
out.print("Tokyo: {time.format_in(now, tokyo, "%H:%M %Z")}")
let summer = time.parse_iso("2026-07-01T09:30:00Z") ?? 0
out.print("Madrid in July: {time.format_in(summer, madrid, "%H:%M %Z")}")
Ok(())
}$ tessera run main.tsr --grant fs=/usr/share/zoneinfo:ro
2026-11-14T09:30:00.000Z
Saturday 14 November 2026, 09:30
due 2026-11-17, in 74 hours
a month later: 2026-12-14
Madrid: 10:30 CET
Tokyo: 18:30 JST
Madrid in July: 11:30 CEST
time.iso, time.format (with strftime-style patterns) and time.parse_iso convert between numbers and text, in UTC or at a fixed offset. time.days(3) and friends are durations in milliseconds, so date arithmetic is addition. add_months keeps the time of day and clamps the day to the month's end.
Named time zones, with their daylight saving rules, come from the system's time zone database, read through an Fs: time.load_zone(fs, "Europe/Madrid"), with --grant fs=/usr/share/zoneinfo:ro. Once loaded, a time.Zone is plain data, and format_in, parts_in and the other functions that use it are pure. A program can also ship a zone file with embed_bytes and read it with time.zone_from_tzif, and then needs no Fs at all.
The listing uses a fixed moment so that its output is the same every time the book is built. A real program would start from root.clock.now_ms().
Data formats
Most data a program exchanges comes in a handful of formats, and the standard library reads and writes them without needing any capability:
// Data in common formats: a CSV table, a URL, a hash and base64. All of it
// is pure, so this program needs nothing but Stdout.
use base64
use csv
use hash
use url
fn main(root: Root) -> Result[(), dyn Error] {
let out = root.stdout
let rows = csv.parse_maps("name,city\nAda,London\n\"Hopper, Grace\",New York\n")?
for row in rows {
out.print("{row["name"]} lives in {row["city"]}")
}
let u = url.parse("https://example.com:8443/search?q=mosaic+tile&page=2#top")?
out.print("host {u.host}, port {u.port ?? 443}, path {u.path}")
let params = u.params()?
out.print("q = {url.param(params, "q") ?? ""}")
out.print(url.build_query([("city", "New York"), ("tag", "a&b")]))
out.print(hash.sha256_hex("abc".to_bytes()))
out.print(hash.hmac_sha256_hex("key".to_bytes(), "message".to_bytes()))
out.print(base64.encode_str("tessera"))
Ok(())
}$ tessera run main.tsr
Ada lives in London
Hopper, Grace lives in New York
host example.com, port 8443, path /search
q = mosaic tile
city=New%20York&tag=a%26b
ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
6e9ef29b75fffc5b7abae527d58fdadb2fe42e7219011976917343065f58ed4a
dGVzc2VyYQ==
csvreads and writes CSV, including quoted fields with commas and line breaks in them.csv.parsegives rows of strings andcsv.parse_mapsgives one map per row, keyed by the header; errors name the line.urlsplits a URL into its parts and puts it back together, and encodes and decodes query strings, keeping repeated keys in order.hashcomputes SHA-256, SHA-1 and MD5, and HMACs with them.hash.equalcompares two digests in constant time, which is what checking a signature needs.base64andhexturn bytes into text and back.
For random bytes that are safe to use as keys and tokens, rand.bytes(n) reads the operating system's generator. rand.int and rand.float are fast and fine for games and sampling, but not for secrets. The uuid module builds on rand.bytes: uuid.v4(root.rand) makes a random UUID, and uuid.v7(root.rand, root.clock) one that sorts by time. Running a program with --seed makes all of them repeat, which is what tests want.
Command-line tools
flags reads options from root.args(): you describe them once, and get parsing, defaults, typed values and a help text. log writes leveled messages, as text or JSON lines, through a Logger that holds the Stdout or Stderr it writes to (chapter 12):
// A command-line tool: flags from the arguments, and a logger that holds
// the Stderr it writes to.
use flags
use log
fn main(root: Root) -> Result[Int, dyn Error] {
let spec = flags.spec("greet", "Greet someone, a number of times.")
.option("name", "n", "who to greet").default("world")
.option("times", "t", "how many times").default("1")
.flag("verbose", "v", "log what happens")
let p = spec.parse(root.args())?
if p.help {
root.stdout.write(spec.help())
return Ok(0)
}
let level = if p.bool("verbose") { log.Debug } else { log.Warn }
let l = log.to_stderr(root.stderr).level(level)
let times = p.int("times")?
l.field("times", "{times}").debug("starting")
for _ in 0..<times {
root.stdout.print("Hello, {p.str("name")}!")
}
Ok(0)
}$ tessera run main.tsr -- --name Ada -t 2
Hello, Ada!
Hello, Ada!
$ tessera run main.tsr -- -v
DEBUG starting times=1
Hello, world!
$ tessera run main.tsr -- --help
Usage: greet [options]
Greet someone, a number of times.
Options:
-n, --name NAME who to greet (default: world)
-t, --times TIMES how many times (default: 1)
-v, --verbose log what happens
-h, --help show this help
$ tessera run main.tsr -- --colour red
error: flags: unknown flag --colour
Parsing is pure: spec.parse takes a list of strings and returns the values or an error, so it is easy to test. The logger only writes messages at its level or above, and adds a timestamp only when it is given a Clock with with_clock.
Other modules
The standard library has a few more pure modules: text (padding, repeating, counting words), format (thousands separators, hexadecimal, percentages, aligned columns), list (sorting with a comparison, inserting and removing at a position), path (joining, cleaning and splitting file paths, and path.walk to list a directory tree through an Fs), regex (regular expressions that never backtrack, so every search takes time proportional to the pattern times the text) and errors (from chapter 8). tessera doc --std --html -o std.html writes documentation for all of them.
Summary
Fs reads, writes, renames and inspects files under one directory. The http module serves and fetches, with handlers that hold no capability unless you give them one. TLS is always verified and needs no extra grant. json, csv, url, hash, sql and time cover data, databases and dates; flags and log cover command-line tools. Only the parts that touch the world need a capability.
The last tool in the box is the biggest one: calling C.