> ## Documentation Index
> Fetch the complete documentation index at: https://docs.boxlite.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Reach a service running inside a box

> Open a tunnel to a port inside a Cloud box to reach an HTTP server, a WebSocket endpoint, or any TCP service — and understand the box's outbound boundary.

A box that runs a web app, a dev server, or an SSH daemon is only useful once something can reach it. On BoxLite Cloud you ask a box for a tunnel to one of its ports, and that tunnel gives you a public URL and a byte stream. What the box itself is allowed to reach on the way out is a separate control, covered at the end of this page.

## Prerequisites

* An API key from the console, exported as `BOXLITE_API_KEY`. See [API keys](/cloud/api-keys).
* `pip install boxlite`, and the REST URL exported as `BOXLITE_REST_URL`. See [Quickstart](/cloud/quickstart).
* A box you can start, and a service inside it that listens on a TCP port.

## Inbound: reach a service inside the box

### How a tunnel reaches your service

You do not open a port on the platform. You ask one specific box for a tunnel to one specific port inside it, and `await box.network.tunnel(port)` prepares it.

Behind that single call: the SDK asks the Cloud API to prepare the tunnel (`POST /v1/boxes/{id}/network/tunnel` if you are driving the REST API yourself), opens a TLS connection to the public proxy, and issues an HTTP `CONNECT`. The proxy connects on to the runner hosting your box, and the runner connects to the guest port. From there the path is your service's socket: the tunnel moves bytes and nothing along it interprets your protocol.

Two consequences shape everything below.

* **Your service must bind `0.0.0.0`.** The tunnel terminates on the box's network interface, so a server bound to `127.0.0.1` inside the box is unreachable — the same reason port forwarding needs `0.0.0.0` on a box you run yourself, explained in [Network access](/manage-sandbox/network-access).
* **A prepared tunnel carries exactly one connection.** See [A tunnel is one-shot](#a-tunnel-is-one-shot).

### Serve HTTP from a box and get its URL

This script creates a box, starts an HTTP server inside it, prints the tunnel's public URL, makes a request over the tunnel, and removes the box in `finally`.

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
# cloud_tunnel_http.py — serve HTTP from a Cloud box and reach it through a tunnel
# Run: python cloud_tunnel_http.py
import asyncio
import os
import time

from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions

GUEST_PORT = 18080
IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"


async def http_get(tunnel, path: str = "/") -> bytes:
    """Send one HTTP request over one tunnel. connect() consumes the tunnel."""
    connection = await tunnel.connect()
    try:
        request = (
            f"GET {path} HTTP/1.1\r\n"
            "Host: localhost\r\n"
            "Connection: close\r\n\r\n"
        ).encode()
        await connection.write(request)

        response = bytearray()
        while True:
            chunk = await connection.read(64 * 1024)
            if not chunk:  # an empty read means the far side closed the stream
                break
            response.extend(chunk)
        return bytes(response)
    finally:
        await connection.close()


async def main() -> None:
    # export BOXLITE_API_KEY=<YOUR_API_KEY> before running
    rt = Boxlite.rest(BoxliteRestOptions(
        url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"),
        credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]),
    ))

    box_id = None
    try:
        box = await rt.create(
            BoxOptions(image=IMAGE),
            name=f"tunnel-http-{int(time.time())}",
        )
        box_id = box.id
        await box.start()

        # Bind 0.0.0.0: the tunnel arrives on the box's network interface,
        # so a server on 127.0.0.1 inside the box cannot be reached.
        launch = await box.exec("sh", args=[
            "-c",
            f"nohup python3 -m http.server {GUEST_PORT} --bind 0.0.0.0 "
            "> /tmp/server.log 2>&1 & echo launched",
        ])
        await launch.wait()

        # uri() is synchronous and does not consume the tunnel.
        tunnel = await box.network.tunnel(GUEST_PORT)
        print(f"tunnel URL: {tunnel.uri()}")

        # The server needs a moment. Each attempt needs its own tunnel.
        response = b""
        for attempt in range(20):
            try:
                response = await http_get(tunnel)
                if response.startswith(b"HTTP/1."):
                    break
            except Exception as exc:
                print(f"attempt {attempt + 1}: {type(exc).__name__}: {exc}")
            await asyncio.sleep(1)
            tunnel = await box.network.tunnel(GUEST_PORT)  # the previous one is spent

        if response.startswith(b"HTTP/1."):
            print(response.split(b"\r\n", 1)[0].decode())
        else:
            log = await box.exec("cat", args=["/tmp/server.log"])
            text = ""
            async for line in log.stdout():
                text += line
            await log.wait()
            print(f"no HTTP response. server log:\n{text}")
    except Exception as exc:
        print(f"tunnel demo failed: {type(exc).__name__}: {exc}")
    finally:
        # Teardown on the runtime, in finally, so a failure above cannot leave a box running.
        if box_id:
            try:
                await rt.remove(box_id, force=True)
            except Exception as exc:
                print(f"remove failed: {exc}")


if __name__ == "__main__":
    asyncio.run(main())
```

Expected output:

```text theme={"theme":{"light":"github-light","dark":"github-dark"}}
tunnel URL: https://...
HTTP/1.0 200 OK
```

`uri()` returns the public URL of a tunnel served remotely, which is what a Cloud tunnel is. On a local runtime the same call returns `None`, because a local tunnel is already a live connection with no address to publish. Reading `uri()` leaves the tunnel usable — only `connect()` and `forward()` spend it.

The snippet starts the server with `python3`. If the server log says `python3: not found`, install an interpreter into the box first, or start a service the image already ships.

### A tunnel is one-shot

<Warning>
  **`connect()` and `forward()` each consume the tunnel.** Calling either one a second time on the same `BoxTunnel` raises a BoxLite error: `tunnel connection has already been consumed`.

  Every connection needs a fresh `await box.network.tunnel(port)`. Ask for one per request, per retry, and per concurrent client — as the loop above does — and never cache a `BoxTunnel` for reuse. Caching the *port number* is fine; caching the tunnel is the bug.
</Warning>

Preparing a tunnel is cheap and boxes accept several at once, so this is a shape to lean into rather than work around: many concurrent clients on the same guest port each get their own tunnel, and different guest ports on one box can be tunnelled at the same time.

### Forward a box port to a local port

`forward()` publishes the tunnel on an address on your machine, so any TCP client — `curl`, a browser, a database driver, an existing library that only knows how to dial a socket — can reach the service without knowing about BoxLite.

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
# cloud_tunnel_forward.py — publish a box port on a local port
# Run: python cloud_tunnel_forward.py
import asyncio
import os
import time
import urllib.request

from boxlite import (
    ApiKeyCredential,
    Boxlite,
    BoxliteRestOptions,
    BoxOptions,
    SocketAddress,
)

GUEST_PORT = 18080
IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"


async def main() -> None:
    # export BOXLITE_API_KEY=<YOUR_API_KEY> before running
    rt = Boxlite.rest(BoxliteRestOptions(
        url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"),
        credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]),
    ))

    box_id = None
    forwarder = None
    try:
        box = await rt.create(
            BoxOptions(image=IMAGE),
            name=f"tunnel-forward-{int(time.time())}",
        )
        box_id = box.id
        await box.start()

        launch = await box.exec("sh", args=[
            "-c",
            f"nohup python3 -m http.server {GUEST_PORT} --bind 0.0.0.0 "
            "> /tmp/server.log 2>&1 & echo launched",
        ])
        await launch.wait()
        await asyncio.sleep(2)  # give the server time to bind

        # host must be a numeric IP; port=0 asks the OS for a free port.
        listener = SocketAddress.tcp(host="127.0.0.1", port=0)

        tunnel = await box.network.tunnel(GUEST_PORT)
        forwarder = await tunnel.forward(listener)  # consumes the tunnel

        local = forwarder.local_addr()  # synchronous
        print(f"forwarding 127.0.0.1:{local.port} -> box port {GUEST_PORT}")

        # Any TCP client can now dial the local address. urllib is blocking,
        # so run it off the event loop.
        def fetch() -> int:
            url = f"http://127.0.0.1:{local.port}/"
            with urllib.request.urlopen(url, timeout=10) as resp:
                return resp.status

        print(f"local request status: {await asyncio.to_thread(fetch)}")
    except Exception as exc:
        print(f"forward demo failed: {type(exc).__name__}: {exc}")
    finally:
        if forwarder is not None:
            try:
                await forwarder.close()
            except Exception as exc:
                print(f"forwarder close failed: {exc}")
        if box_id:
            try:
                await rt.remove(box_id, force=True)
            except Exception as exc:
                print(f"remove failed: {exc}")


if __name__ == "__main__":
    asyncio.run(main())
```

Three constraints on the listening address:

* The host must be a **numeric IP**. `SocketAddress.tcp(host="localhost")` raises `ValueError: tunnel listener host must be a numeric IP` — pass `"127.0.0.1"`.
* `port=0` is the default and asks the operating system for a free port. Read the port you actually got from `forwarder.local_addr().port`.
* A Unix socket path must be **absolute**. `SocketAddress.unix("relative.sock")` raises `ValueError: tunnel Unix socket path must be absolute`.

`await forwarder.wait()` blocks until the forwarder finishes, which is what you await in a long-running process instead of exiting. `await forwarder.close()` shuts it down. The forwarder is built from a tunnel, and `forward()` spends that tunnel, so build each forwarder from its own fresh `await box.network.tunnel(port)`.

### Read and write raw bytes

`connect()` hands you the byte stream directly. This is the level you work at for a protocol that is not HTTP — a line protocol, a binary framing, a database wire protocol — or when you want full control over what goes on the wire.

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
# cloud_tunnel_bytes.py — talk to a non-HTTP TCP service inside a box
# Run: python cloud_tunnel_bytes.py
import asyncio
import os
import shlex
import time

from boxlite import ApiKeyCredential, Boxlite, BoxliteRestOptions, BoxOptions

GUEST_PORT = 18090
IMAGE = "ghcr.io/boxlite-ai/boxlite-agent-base:v0.1.0"

# A line protocol: read one newline-terminated line, reply with it upper-cased.
SERVER_CODE = f"""
import socketserver

class Handler(socketserver.StreamRequestHandler):
    def handle(self):
        line = self.rfile.readline()
        self.wfile.write(line.upper())

class Server(socketserver.ThreadingTCPServer):
    allow_reuse_address = True

Server(("0.0.0.0", {GUEST_PORT}), Handler).serve_forever()
"""


async def main() -> None:
    # export BOXLITE_API_KEY=<YOUR_API_KEY> before running
    rt = Boxlite.rest(BoxliteRestOptions(
        url=os.environ.get("BOXLITE_REST_URL", "https://app.boxlite.ai/api"),
        credential=ApiKeyCredential(os.environ["BOXLITE_API_KEY"]),
    ))

    box_id = None
    connection = None
    try:
        box = await rt.create(
            BoxOptions(image=IMAGE),
            name=f"tunnel-bytes-{int(time.time())}",
        )
        box_id = box.id
        await box.start()

        launch = await box.exec("sh", args=[
            "-c",
            f"nohup python3 -u -c {shlex.quote(SERVER_CODE)} "
            "> /tmp/line-server.log 2>&1 & echo launched",
        ])
        await launch.wait()
        await asyncio.sleep(2)  # give the server time to bind

        tunnel = await box.network.tunnel(GUEST_PORT)
        connection = await tunnel.connect()

        await connection.write(b"ping over a raw tunnel\n")

        # Signals that you have finished sending. This server replies to the
        # newline, so it does not depend on seeing the half-close -- see Limits.
        await connection.shutdown_write()

        reply = bytearray()
        while b"\n" not in reply:
            chunk = await connection.read(4096)  # max_bytes must be non-zero
            if not chunk:  # empty read: the far side closed the stream
                break
            reply.extend(chunk)

        print(f"reply: {bytes(reply)!r}")
    except Exception as exc:
        print(f"byte stream demo failed: {type(exc).__name__}: {exc}")
    finally:
        if connection is not None:
            try:
                await connection.close()
            except Exception as exc:
                print(f"connection close failed: {exc}")
        if box_id:
            try:
                await rt.remove(box_id, force=True)
            except Exception as exc:
                print(f"remove failed: {exc}")


if __name__ == "__main__":
    asyncio.run(main())
```

`read(max_bytes)` returns up to `max_bytes` bytes and an empty `bytes` object once the far side has closed. `max_bytes` of `0` raises `ValueError: max_bytes must be non-zero`, and reading after `close()` raises a BoxLite error carrying `connection is closed`.

A synchronous surface exists as well: `SyncBox.network` and `SyncBox.tunnel(port)` return the `SyncNetworkHandle` and `SyncTunnelForwarder` equivalents of the classes documented here.

### Parameters and returns

#### `box.network.tunnel(port)`

Async. Prepares one tunnel to one port inside one box.

| Parameter | Type  | Required | Description                                                                                                |
| --------- | ----- | -------- | ---------------------------------------------------------------------------------------------------------- |
| `port`    | `int` | Required | The port your service listens on **inside** the box. `0` raises `ValueError: tunnel port must be non-zero` |

Returns a `BoxTunnel`. `await box.tunnel(port)` is an equivalent shorthand on a `SimpleBox`.

#### `BoxTunnel`

| Member            | Async | Returns           | Description                                                                                                    |
| ----------------- | ----- | ----------------- | -------------------------------------------------------------------------------------------------------------- |
| `uri()`           | No    | `str \| None`     | Public URL of a remotely served tunnel, or `None` for a local one. Does not consume the tunnel                 |
| `connect()`       | Yes   | `BoxConnection`   | **Consumes the tunnel** and returns its bidirectional byte stream                                              |
| `forward(listen)` | Yes   | `TunnelForwarder` | **Consumes the tunnel**, listens on `listen`, and forwards traffic into the box. `listen` is a `SocketAddress` |

#### `SocketAddress`

Two class methods build the address `forward()` listens on.

| Constructor          | Signature                                     | Constraint                                                                                                                          |
| -------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `SocketAddress.tcp`  | `SocketAddress.tcp(host="127.0.0.1", port=0)` | `host` must be a numeric IP, otherwise `ValueError: tunnel listener host must be a numeric IP`. `port=0` lets the system assign one |
| `SocketAddress.unix` | `SocketAddress.unix(path)`                    | `path` must be absolute, otherwise `ValueError: tunnel Unix socket path must be absolute`                                           |

Read-only attributes:

| Attribute | Type          | TCP address | Unix address    |
| --------- | ------------- | ----------- | --------------- |
| `kind`    | `str`         | `"tcp"`     | `"unix"`        |
| `host`    | `str \| None` | The IP      | `None`          |
| `port`    | `int \| None` | The port    | `None`          |
| `path`    | `str \| None` | `None`      | The socket path |

#### `TunnelForwarder`

| Member         | Async | Returns         | Description                                                     |
| -------------- | ----- | --------------- | --------------------------------------------------------------- |
| `local_addr()` | No    | `SocketAddress` | The address actually bound — read this when you passed `port=0` |
| `wait()`       | Yes   | —               | Blocks until the forwarder finishes                             |
| `close()`      | Yes   | —               | Shuts the forwarder down                                        |

#### `BoxConnection`

| Member             | Async | Returns | Description                                                                                                                                                                                 |
| ------------------ | ----- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `read(max_bytes)`  | Yes   | `bytes` | Up to `max_bytes` bytes; empty once the far side closes. `max_bytes` of `0` raises `ValueError: max_bytes must be non-zero`; reading a closed connection raises with `connection is closed` |
| `write(data)`      | Yes   | `int`   | Writes all of `data` (`bytes`) and returns the number of bytes written                                                                                                                      |
| `shutdown_write()` | Yes   | —       | Half-closes the write direction. Read [Limits](#limits) before you depend on it                                                                                                             |
| `close()`          | Yes   | —       | Closes both directions                                                                                                                                                                      |

### What a tunnel carries

These behaviours are verified end to end against BoxLite Cloud, so you can build on them:

* **HTTP requests and responses**, `GET` and `POST`.
* **WebSocket** — the upgrade handshake and frames in both directions.
* **Several guest ports on one box**, tunnelled at the same time.
* **Concurrent clients** against the same guest port, each on its own tunnel.
* **Responses larger than 2 MiB** through a single connection, byte-for-byte intact.
* **Slow readers** — a client that drains the stream slowly does not lose data.
* **Client cancellation** — abandoning your side ends that stream without disturbing the box or its other streams.
* **Service restart** — restart the server inside the box, open a fresh tunnel, and traffic flows again.
* **Arbitrary TCP**, including a real SSH session to an `sshd` listening on port 2222. The path is not HTTP-specific.

One isolation property is worth stating on its own: **two boxes serving on the same guest port never receive each other's traffic.** A tunnel is bound to the box that produced it, and that holds while both boxes take traffic concurrently.

### Limits

Three properties of this path to design around.

**A `CONNECT` to a stopped box can be accepted before the stream fails.** A successful connect is not proof that the box is running — the accept can land and the stream fail immediately afterwards. Treat your first successful read or write as the readiness signal. Tunnel traffic also does not wake a stopped box: the console is explicit that preview traffic keeps a running box alive but does not wake a stopped one, so start the box yourself and check [Stop when idle](/cloud/boxes#stop-when-idle) if it stops under you.

**A response can be lost after `shutdown_write()`.** TCP half-close is not carried end to end, so a guest that waits for end-of-input before it replies may never reply, and a reply already in flight can be dropped. Use a protocol that frames its own messages — a newline, a length prefix, `Content-Length` — instead of one that signals "done" with EOF.

**Direct browser use of a tunnel URL is not covered.** The URL from `uri()` is the address the SDK dials when you call `connect()` or `forward()`. Navigating to it in a browser, and browser authentication for a private box, sit outside the verified path. Drive tunnels from the SDK, and when you want a browser on the service, forward the port to your machine as shown above.

## Outbound: what the box can reach

Inbound and outbound are separate controls. The outbound side of a box's network boundary is expressed with `NetworkSpec`, passed as the `network` field of `BoxOptions`: `mode` decides whether the box has a network interface at all, and `allow_net` narrows egress to a list of hosts.

One detail about that allowlist saves a debugging session. A blocked host is a **DNS sinkhole, not a connection error**: a host that is not on the list resolves to `0.0.0.0`. Your code sees a connection to `0.0.0.0` fail, and a `nslookup` of the blocked host can still exit `0`. So when you check whether a host was blocked, inspect the resolved address rather than the exit code.

The `NetworkSpec` parameter table, the three useful `mode` and `allow_net` combinations, and the verification recipe live on [Network access](/manage-sandbox/network-access). That page owns them, and this page does not restate them.

## Troubleshooting

| Symptom                                                                                                     | Cause                                                                                                                    | Fix                                                                                                                                                                                                                                                |
| ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `uri()` returns `None`                                                                                      | The box came from a local runtime, not from `Boxlite.rest(...)`. A local tunnel has no published address                 | Construct the runtime with `Boxlite.rest(BoxliteRestOptions(url=..., credential=...))` as in [Quickstart](/cloud/quickstart). The endpoint shape is the tell: a local runtime's endpoint is an integer file descriptor, a Cloud runtime's is a URL |
| A BoxLite error carrying `tunnel connection has already been consumed`                                      | `connect()` or `forward()` already spent that `BoxTunnel`                                                                | Call `await box.network.tunnel(port)` again for every connection. Never reuse or cache a tunnel object                                                                                                                                             |
| `RuntimeError: Box not started. Use 'async with SimpleBox(...) as box:' or call 'await box.start()' first.` | The tunnel was requested before the box was running                                                                      | `await box.start()` first, then request the tunnel                                                                                                                                                                                                 |
| `ValueError: tunnel port must be non-zero`                                                                  | `0` was passed as the guest port                                                                                         | Pass the port your service actually listens on inside the box                                                                                                                                                                                      |
| `ValueError: tunnel listener host must be a numeric IP`                                                     | `forward()` got a hostname such as `localhost`                                                                           | Use `SocketAddress.tcp(host="127.0.0.1", port=0)`                                                                                                                                                                                                  |
| `ValueError: tunnel Unix socket path must be absolute`                                                      | A relative path was passed to `SocketAddress.unix(...)`                                                                  | Pass an absolute path, for example `/tmp/boxlite-forward.sock`                                                                                                                                                                                     |
| `ValueError: max_bytes must be non-zero`                                                                    | `read(0)`                                                                                                                | Pass a real buffer size, for example `read(64 * 1024)`                                                                                                                                                                                             |
| The connect succeeds and the stream drops immediately                                                       | The box is stopped. A `CONNECT` can be accepted before the stream fails                                                  | Confirm the box is running and start it if not. If it keeps stopping under you, it is being stopped for being idle — see [Stop when idle](/cloud/boxes#stop-when-idle)                                                                             |
| The connection is established but nothing answers                                                           | The service inside the box is bound to `127.0.0.1`. A tunnel arrives on the box's network interface, not on its loopback | Bind `0.0.0.0`, for example `python3 -m http.server 18080 --bind 0.0.0.0`, Flask `app.run(host="0.0.0.0")`, uvicorn `--host 0.0.0.0`. Background: [Network access](/manage-sandbox/network-access)                                                 |
| No reply arrives after `shutdown_write()`                                                                   | The guest is waiting for end-of-input, and TCP half-close is not carried end to end                                      | Frame the protocol so the guest knows a message is complete without EOF — a newline, a length prefix, or `Content-Length`                                                                                                                          |
| An `exec` that starts the server returns exit code `0` but nothing listens                                  | The command was backgrounded, so its exit code says nothing about the server                                             | Read the server's log file back out of the box, as the first example does with `/tmp/server.log`                                                                                                                                                   |

## Next steps

<CardGroup cols={2}>
  <Card title="Boxes on Cloud" icon="box" href="/cloud/boxes">
    Images, sizes, and the lifecycle controls that decide whether your service is still listening.
  </Card>

  <Card title="Quickstart" icon="rocket" href="/cloud/quickstart">
    The five-call Cloud lifecycle, from API key to teardown.
  </Card>

  <Card title="Network access" icon="shield-halved" href="/manage-sandbox/network-access">
    The `NetworkSpec` and `ports` parameter tables, and the egress allowlist in full.
  </Card>
</CardGroup>
