---
title: "Byte streams: talking to things outside the engine"
description: "A world often needs to speak to something that is not part of it — a socket on another machine, an external process on localhost…"
section: "Topics"
slug: "topics-byte-streams"
canonical: "https://origozero.ai/docs/topics-byte-streams"
updated: "2026-10-02T05:42:32.204059429+00:00"
tags: ["documentation", "guide"]
---

# Byte streams: talking to things outside the engine

A stream is opened by URL. The **scheme** picks the transport, so `tcp://` reaches a **connection** on the network and `loopback://` stays in this process — and a transport added later reaches Luau under its own scheme without this API changing shape.

It goes both ways. `stream.open` **dials out**: Zero is the one that connects, and it must find the peer already up. `stream.listen` **holds an address** and answers whoever dials in — the shape to reach for when the thing on the other end is a **phone**, a **browser**, an **external viewer** or a **driver process** that starts, stops and restarts on its own schedule, or when several of them connect at once. Either way what you end up holding is the same thing: a stream handle you write to, drain, and close.

## Dial out: open, write, read, close

```lua
-- The URL is scheme://target[?k=v]. open() hands back a promise:
-- task.await it to get the stream handle once the transport is up.
local handle = task.await(stream.open("tcp://127.0.0.1:9000"))

local outcome = stream.write(handle, "hello\n")   -- bytes, never blocks
local chunk   = stream.read(handle)               -- everything buffered, "" when nothing

local s = stream.status(handle)
print(s.transport, s.state, s.bytesIn, s.bytesOut, s.pending, s.capacity)

stream.close(handle)
```

`write` takes a Luau string and `read` returns one. Luau strings already carry arbitrary bytes, so binary crosses both ways unharmed — no base64, no escaping. `stream.read(handle, max)` drains at most `max` bytes, which is how you pull a fixed-size record out of the buffer and leave the rest.

A connect that is refused raises out of `task.await`, so wrap it when a failure is expected:

```lua
local ok, handleOrErr = pcall(task.await, stream.open("tcp://127.0.0.1:9000"))
if not ok then warn("connect failed: " .. tostring(handleOrErr)) end
```

## Listen, accept

`stream.listen` holds an address. Every peer that dials it becomes an ordinary stream, taken one at a time from `stream.accept`:

```lua
-- Hold an address. listen() hands back a promise, like open().
local listener = task.await(stream.listen("tcp://127.0.0.1:9000"))
print(stream.listenerStatus(listener).address)   -- the address to hand a peer

-- Each tick: take everything that arrived, then drive what you hold.
local peers = {}
while true do
    local peer = stream.accept(listener)
    if not peer then break end
    table.insert(peers, peer)
end

for _, peer in peers do
    stream.write(peer, frame)              -- the same write as a dialled stream
    local said = stream.read(peer)         -- the same read
end
```

`accept` answers `nil` when nothing is waiting, which is why the loop above is the shape: call it until it says nil and you have taken every peer that arrived this tick. `stream.listenerStatus(listener).pending` is how many are still queued behind it.

An accepted connection is a stream handle like any other — `write`, `read`, `status` and `close` all behave exactly as they do for one `stream.open` returned, and nothing about it records which side dialled.

### Who can reach it

**The host in the URL is the interface bound, and the whole of what decides who can reach the listener.** There is no default to get wrong — a listen URL has no target-less spelling, so you write the reach you mean:

| URL | Who can connect |
|---|---|
| `tcp://127.0.0.1:9000` | Programs on **this machine only**. Nothing off the box can reach it. |
| `tcp://0.0.0.0:9000` | **Any host that can route to this machine** on that port — every device on the same wifi, and whatever else the network lets through. |

`stream.listenerStatus(listener)` reports what you actually got: `address` is the address the operating system resolved the bind to, and `reach` is `"thisMachine"` or `"network"`. A port of `0` asks the operating system to choose a free one, which that same `address` then tells you — the way to take a port without picking one that is already busy.

Serving a phone or another machine means `0.0.0.0`, and means anything else on that network can connect too. Bind loopback unless you want that.

### A listener belongs to the chunk that opened it

An address is exclusive: hold it twice and the second bind is refused. So a listener's life follows the chunk that opened it — **the chunk whose own code called `stream.listen`**, which is the module holding that line even when something else called into it. When that chunk runs again — a module hot-reload, a cleared `require` cache — the listener **and the connections it answered** are closed, and the new run binds the address for itself. The peer sees its connection close and dials back in, which is the behaviour you want from a viewer that should follow your edits.

`stream.listeners()` names that chunk on every entry, as `owner`, so which run an address is tied to is read rather than guessed. A chunk that never runs a second time keeps its listener until something closes it by hand — closing by `handle` from that same list is how the address comes back.

Closing a listener by hand is the other case, and it does less: `stream.close(listener)` stops it answering new peers and closes the ones nobody took, while the connections `accept` already handed over keep running until you close them.

### Finding what is open

A handle is the only way to reach a listener or a stream, and `stream.listeners()` and `stream.streams()` are how you get one back. Each lists what is open in the order it was opened, and each entry carries what `stream.listenerStatus` / `stream.status` reports about it plus two more things: the `handle` it is addressed by, and the `owner` — the chunk its life follows.

```lua
for _, l in stream.listeners() do
    print(l.handle, l.address, l.reach, l.accepted, l.owner)
end
```

That makes reclaiming an address a filter rather than a guess. A run that ended without closing its listener — a chunk that raised between the bind and storing the handle — leaves the address held, and this is what finds it:

```lua
for _, l in stream.listeners() do
    if l.address == "127.0.0.1:9000" then stream.close(l.handle) end
end
```

`stream.streams()` answers the same question one level down, for the connections themselves: which are open, what each is carrying, how much is still queued on it, and which listener's owner it came in under.

## Which transports this build carries

`stream.transports()` is a capability probe, and the thing to call before opening or listening on an unfamiliar scheme:

```lua
local t = stream.transports()
if not t.tcp.supported then warn(t.tcp.reason) end         -- can't dial it here
if not t.tcp.listen then warn(t.tcp.listenReason) end      -- can't hold an address here
```

It names every scheme this build knows and answers both directions for each: `supported` for `stream.open`, `listen` for `stream.listen`, each carrying its own reason when false. A scheme can have one and not the other. A typo'd scheme is absent from the table entirely, which is what tells it apart from a real transport this engine does not carry.

- **`loopback://name`** — carries written bytes back out of the same stream. It works on every platform, needs nothing on the other end, and is what you reach for to test a protocol, to buffer between two pieces of content, or to read frames back in Luau before writing them on somewhere else. Its peer is itself, so nothing dials it and it cannot be listened on.
- **`tcp://host:port`** — dials a TCP peer, and holds an address for peers that dial in. Both directions are native-only, and no scheme holds an address in a browser.
- **`tty://device?baud=…`** — opens a serial device node: a USB CDC board such as an ESP32 (`/dev/ttyACM0`, `/dev/ttyUSB0`, `COM5` on Windows), or a controller paired over classic Bluetooth SPP (`/dev/rfcomm0`, after an OS-level `rfcomm bind`). Native-only, and a device node is opened rather than bound, so nothing dials it. `stream.serialPorts()` reports which devices are attached; its own section is below.
- **`ble://device?service=…&write=…&notify=…`** — connects to a Bluetooth Low Energy device over GATT, on a desktop engine and in a browser alike. It is the wireless transport a web world reaches a device through, since a browser has no way to open a serial port or a classic-Bluetooth link. Its own section is below.

## Serial: which port is the board on today

A serial device's path is not stable. An ESP32 over USB CDC comes up at `/dev/ttyACM0` or `/dev/ttyACM1` depending on what was plugged in first, and moves across `COM3`-`COM5` on Windows. Hardcode one and the content breaks the next time the board is replugged, without being able to say which port to use instead. `stream.serialPorts()` is how you find it:

```lua
for _, p in stream.serialPorts().ports do
    if p.vendorId == 0x303A then                       -- Espressif
        local board = task.await(stream.open(p.url .. "?baud=115200"))
    end
end
```

**The `path` is the part that moves; what a USB device advertises is the part that holds still.** Match on `vendorId`/`productId` to find a model of board, or on `serialNumber` to tell two of the same board apart, and open the `url` that entry carries — it is ready for `stream.open`, which is also how you avoid writing the `tty://` plus absolute device node triple slash (`tty:///dev/ttyACM0`) wrong. `kind` names the bus the device is on (`usb`, `pci`, `bluetooth`, `unknown`) and only a `usb` one carries identifiers; `manufacturer` and `product` are what to show a person picking from a list.

**An empty list means this machine has nothing attached — not that this build cannot enumerate.** Those are three separate answers, and content that collapses them takes the wrong branch:

```lua
local r = stream.serialPorts()
if not r.supported then
    warn(r.reason)     -- no serial bus here at all — what a browser reports
elseif r.error then
    warn(r.error)      -- the OS refused this enumeration; a later call may answer
elseif #r.ports == 0 then
    warn("no serial device attached")   -- an ordinary result, not a failure
end
```

## Bluetooth: reaching a device over GATT

A `tcp` stream needs a host and a port. A GATT peer needs three things, and all three are in the url: the **device** to connect to, the **service** on it that holds the conversation, and the **characteristics** the bytes travel over — one this engine writes to, one it subscribes to for what the device sends back.

```lua
local paw = task.await(stream.open(
    "ble://Paw*?service=6e400001-b5a3-f393-e0a9-e50e24dcca9e"
    .. "&write=6e400002-b5a3-f393-e0a9-e50e24dcca9e"
    .. "&notify=6e400003-b5a3-f393-e0a9-e50e24dcca9e"
))
```

The device is the name it advertises. `*` takes any device offering the service — the usual form in a browser, where the person at the machine picks from the chooser it shows — and a trailing `*` matches a prefix, so `Paw*` finds `Paw-0417`.

`write` and `notify` are named for what this engine does with them. A datasheet's `rx`/`tx` are written from the peripheral's side, so each end of the link reads them as the other one: a peripheral's RX characteristic is the one a central **writes** to, and its TX is the one a central **subscribes** to. On a Nordic UART Service that makes `write=…0002…` and `notify=…0003…`; on an HM-10-style module with one bidirectional characteristic, both name `ffe1`. Every UUID may be written 16-bit (`ffe0`), 32-bit, or in full.

Three optional parameters: `chunk` bounds what one packet carries (1-512 bytes; left out, it is the negotiated MTU where the platform reports one and the 20 bytes every BLE connection carries where it does not), `writeMode` is `withResponse` (the default, one acknowledgement per packet) or `withoutResponse` (unacknowledged, for throughput), and `timeout` is how many seconds finding and connecting to the device may take before the stream closes naming what it looked for.

A GATT link carries packets rather than a byte flow, and the transport keeps your write boundaries where it can: a write that fits one packet is delivered as one packet, and a longer one is split across as many as it needs. A device that treats one write as one message receives what was written.

### Connecting is a state, not a wait

A `ble` stream is handed back the moment it exists, and reports the rest of its connecting as state — because finding a device takes as long as the device takes to advertise, and in a browser it takes the person at the machine choosing one:

```lua
local s = stream.status(paw)
if s.state == "opening" then            -- looking for the device
elseif s.state == "permissionPending" then
    print(s.error)                      -- what the person has to do: click to choose a device
elseif s.state == "denied" then
    print(s.error)                      -- they said no; ask again when they are willing
elseif s.state == "open" then           -- connected, subscribed, carrying bytes
elseif s.state == "closed" then
    print(s.error)                      -- no device answered, or the connection ended
end
```

**A browser opens its device chooser only while a click is still in effect.** A stream opened outside one waits for the next click, and says so: `permissionPending` with `error` carrying what the person at the machine has to do. That wait is the normal path rather than a fault, which is why it is a state you read rather than an error the open raised. `denied` is the answer being no — terminal the way `closed` is, and told apart from it because a refusal can be asked for again once they are willing.

Writes are taken throughout. What you write while it connects is queued and goes out when it does, so opening and writing in one breath delivers.

`stream.transports().ble` answers the question before any of that: in a browser the page itself reports whether that browser offers Web Bluetooth, whether the page has the secure context it needs, and whether the machine has a radio switched on, and `reason` carries which of those it is.

## Writes never block — they answer

`stream.write` returns immediately with one of four outcomes, and each means something different for the caller:

| Outcome | Meaning | What to do |
|---|---|---|
| `"accepted"` | The bytes are queued for the peer. | Carry on. |
| `"full"` | Backpressure: the peer is alive but draining slower than you are producing. | Retry once it catches up; watch `pending` against `capacity`. |
| `"closed"` | The stream is finished, or the handle names no open stream. | Reopen — retrying never succeeds. |
| `"tooLarge"` | The write is bigger than the stream's whole outbound capacity, so it can never fit at any queue depth. | Split it, or reopen with a bigger `outboundCapacity`. |

`stream.open(url, { inboundCapacity = N, outboundCapacity = N })` sets those bounds (65536 bytes each by default). Size them to the largest single thing you will write — a whole video frame, a whole message — times the jitter you want absorbed.

### Accepted means queued, and close carries the queue

`"accepted"` means the bytes are **queued for the peer**, not that they have gone out: `write` returns to you immediately and the transport puts them on the wire after the call. `pending` on `stream.status(handle)` is exactly what is queued and not yet handed over, and it falls to zero as the bytes go.

`stream.close(handle)` carries that queue out before the connection ends. So the shape a request-and-one-response has — write the answer, close the connection, both in the same tick — delivers:

```lua
stream.write(peer, response)
stream.close(peer)               -- the queued response goes out, then the connection ends
```

A peer that has stopped reading altogether holds that finish for thirty seconds; past that the connection ends and whatever is still queued ends with it. Nothing is counted as dropped for it — `droppedWrites` and `droppedBytes` count backpressure alone, writes that were refused where you could still see them. When you must **know** what you wrote reached the socket, watch `pending` reach zero before you close, which is the same number backpressure is read from:

```lua
stream.write(peer, response)
while (stream.status(peer) :: any).pending > 0 do task.wait() end
stream.close(peer)
```

`stream.status(handle)` is the running account: `state` (`"opening"` / `"permissionPending"` / `"open"` / `"denied"` / `"closed"`), the bytes carried each way, `pending` against `capacity`, the writes and bytes dropped, and `error` holding the most recent transport failure for the life of the stream.

A listener has the same pre-loss shape in its own terms. `stream.listen(url, { backlog = N })` bounds the connections held for `stream.accept`, and `stream.listenerStatus(listener)` reports `pending` against `capacity` for them. At the backlog the listener stops taking connections from the operating system, which leaves the rest in the kernel's own queue — a peer waits to be answered rather than being answered by a listener that then forgets it. `accepted` counts what `accept` has handed over. `stream.listen(url, { inboundCapacity = N, outboundCapacity = N })` sets the byte bounds every connection it answers is built with.

## What travels a stream

Bytes, and only bytes — the framing is yours. A stream carries what you write in the order you wrote it, so a protocol with messages in it needs a length, a delimiter or a fixed record size that both ends agree on. That is a few lines of Luau on top, and it keeps the wire format in content where it can change without an engine build.

Two things in the engine already speak streams:

- **`frameStream`** carries the frames a camera renders into a stream, so what the engine draws reaches another program as raw pixels, live — the **frame-streaming** guide.
- **`stream.read` in a task** is how content consumes an incoming feed: drain once a frame, parse what is complete, leave the remainder buffered for the next tick.

## Finding the rest

`lsp.methods("stream")` lists this API and `lsp.describe("stream.listen")` gives its exact signature. For requests to a web service rather than a raw connection, `http.get_json` / `http.post_json` are the higher-level surface; where the peer speaks HTTP and wants a path rather than a socket, `httpServer.route` serves one from this engine (the **http-server** guide).

The model to hold: one URL names one transport and one address, a handle names one open pipe or one held address, and everything above that — messages, frames, protocols — is content you write on top of the bytes.
