diff --git a/EXAMPLES.md b/EXAMPLES.md
index daf4924e4..a036aa01d 100644
--- a/EXAMPLES.md
+++ b/EXAMPLES.md
@@ -10,6 +10,7 @@ Runnable examples live in [`examples/`](./examples).
- [Blueprint with Build Context](#blueprint-with-build-context)
- [Devbox From Blueprint (Run Command, Shutdown)](#devbox-from-blueprint-lifecycle)
- [Devbox Snapshot and Resume](#devbox-snapshot-resume)
+- [Devbox Tunnel (HTTP Server Access)](#devbox-tunnel)
- [MCP Hub + Claude Code + GitHub](#mcp-github-tools)
- [Secrets with Devbox (Create, Inject, Verify, Delete)](#secrets-with-devbox)
@@ -105,6 +106,35 @@ uv run pytest -m smoketest tests/smoketests/examples/
**Source:** [`examples/devbox_snapshot_resume.py`](./examples/devbox_snapshot_resume.py)
+
+## Devbox Tunnel (HTTP Server Access)
+
+**Use case:** Create a devbox with a tunnel, start an HTTP server, and access the server from the local machine through the tunnel. Uses the async SDK.
+
+**Tags:** `devbox`, `tunnel`, `networking`, `http`, `async`
+
+### Workflow
+- Create a devbox with tunnel configuration
+- Start an HTTP server inside the devbox
+- Make an HTTP request from the local machine through the tunnel
+- Validate the response
+- Shutdown the devbox
+
+### Prerequisites
+- `RUNLOOP_API_KEY`
+
+### Run
+```sh
+uv run python -m examples.devbox_tunnel
+```
+
+### Test
+```sh
+uv run pytest -m smoketest tests/smoketests/examples/
+```
+
+**Source:** [`examples/devbox_tunnel.py`](./examples/devbox_tunnel.py)
+
## MCP Hub + Claude Code + GitHub
diff --git a/examples/devbox_tunnel.py b/examples/devbox_tunnel.py
new file mode 100644
index 000000000..3bb5d9bb0
--- /dev/null
+++ b/examples/devbox_tunnel.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env -S uv run python
+"""
+---
+title: Devbox Tunnel (HTTP Server Access)
+slug: devbox-tunnel
+use_case: Create a devbox with a tunnel, start an HTTP server, and access the server from the local machine through the tunnel. Uses the async SDK.
+workflow:
+ - Create a devbox with tunnel configuration
+ - Start an HTTP server inside the devbox
+ - Make an HTTP request from the local machine through the tunnel
+ - Validate the response
+ - Shutdown the devbox
+tags:
+ - devbox
+ - tunnel
+ - networking
+ - http
+ - async
+prerequisites:
+ - RUNLOOP_API_KEY
+run: uv run python -m examples.devbox_tunnel
+test: uv run pytest -m smoketest tests/smoketests/examples/
+---
+"""
+
+from __future__ import annotations
+
+import asyncio
+
+import httpx
+
+from runloop_api_client import AsyncRunloopSDK
+
+from ._harness import run_as_cli, wrap_recipe
+from .example_types import ExampleCheck, RecipeOutput, RecipeContext
+
+HTTP_SERVER_PORT = 8080
+SERVER_STARTUP_DELAY_S = 2
+
+
+async def recipe(ctx: RecipeContext) -> RecipeOutput:
+ """Create a devbox with a tunnel, start an HTTP server, and access it from the local machine."""
+ cleanup = ctx.cleanup
+
+ sdk = AsyncRunloopSDK()
+
+ devbox = await sdk.devbox.create(
+ name="devbox-tunnel-example",
+ launch_parameters={
+ "resource_size_request": "X_SMALL",
+ },
+ tunnel={"auth_mode": "open"},
+ )
+ cleanup.add(f"devbox:{devbox.id}", devbox.shutdown)
+
+ # Start a simple HTTP server inside the devbox using Python's built-in http.server
+ # We use exec_async because the server runs indefinitely until stopped
+ server_execution = await devbox.cmd.exec_async(f"python3 -m http.server {HTTP_SERVER_PORT} --directory /tmp")
+
+ # Give the server a moment to start
+ await asyncio.sleep(SERVER_STARTUP_DELAY_S)
+
+ # The tunnel was created with the devbox. For authenticated tunnels, set
+ # tunnel={"auth_mode": "authenticated"} on create and include the auth_token
+ # in your requests via the Authorization header: `Authorization: Bearer {tunnel.auth_token}`
+ tunnel = await devbox.get_tunnel()
+ if tunnel is None:
+ raise RuntimeError("Failed to create tunnel at devbox launch time")
+
+ # Get the tunnel URL for the server port
+ tunnel_url = await devbox.get_tunnel_url(HTTP_SERVER_PORT)
+ if tunnel_url is None:
+ raise RuntimeError("Failed to get tunnel URL after creating tunnel")
+
+ # Make an HTTP request from the LOCAL MACHINE through the tunnel to the devbox
+ # This demonstrates that the tunnel allows external access to the devbox service
+ async with httpx.AsyncClient() as client:
+ response = await client.get(tunnel_url)
+ response_text = response.text
+
+ # Stop the HTTP server
+ await server_execution.kill()
+
+ return RecipeOutput(
+ resources_created=[f"devbox:{devbox.id}"],
+ checks=[
+ ExampleCheck(
+ name="tunnel was created successfully",
+ passed=bool(tunnel.tunnel_key),
+ details=f"tunnel_key={tunnel.tunnel_key}",
+ ),
+ ExampleCheck(
+ name="tunnel URL was constructed correctly",
+ passed=bool(
+ tunnel.tunnel_key and tunnel.tunnel_key in tunnel_url and str(HTTP_SERVER_PORT) in tunnel_url
+ ),
+ details=tunnel_url,
+ ),
+ ExampleCheck(
+ name="HTTP request through tunnel succeeded",
+ passed=response.is_success,
+ details=f"status={response.status_code}",
+ ),
+ ExampleCheck(
+ name="response contains directory listing",
+ passed="Directory listing" in response_text,
+ details=response_text[:200],
+ ),
+ ],
+ )
+
+
+run_devbox_tunnel_example = wrap_recipe(recipe)
+
+
+if __name__ == "__main__":
+ run_as_cli(run_devbox_tunnel_example)
diff --git a/examples/registry.py b/examples/registry.py
index 8bb04cba0..edc52f445 100644
--- a/examples/registry.py
+++ b/examples/registry.py
@@ -7,6 +7,7 @@
from typing import Any, Callable, cast
+from .devbox_tunnel import run_devbox_tunnel_example
from .example_types import ExampleResult
from .mcp_github_tools import run_mcp_github_tools_example
from .secrets_with_devbox import run_secrets_with_devbox_example
@@ -38,6 +39,13 @@
"required_env": ["RUNLOOP_API_KEY"],
"run": run_devbox_snapshot_resume_example,
},
+ {
+ "slug": "devbox-tunnel",
+ "title": "Devbox Tunnel (HTTP Server Access)",
+ "file_name": "devbox_tunnel.py",
+ "required_env": ["RUNLOOP_API_KEY"],
+ "run": run_devbox_tunnel_example,
+ },
{
"slug": "mcp-github-tools",
"title": "MCP Hub + Claude Code + GitHub",
diff --git a/scripts/generate_examples_md.py b/scripts/generate_examples_md.py
index bf4dc587a..9e47e9cae 100644
--- a/scripts/generate_examples_md.py
+++ b/scripts/generate_examples_md.py
@@ -150,12 +150,13 @@ def generate_markdown(examples: list[dict[str, Any]]) -> str:
def generate_registry(examples: list[dict[str, Any]]) -> str:
"""Generate the registry.py content."""
- imports: list[str] = []
+ imports: list[tuple[str, str]] = [("example_types", "ExampleResult")]
for example in examples:
module = example["file_name"].replace(".py", "")
runner = f"run_{module}_example"
- imports.append(f"from .{module} import {runner}")
- imports.sort(key=len)
+ imports.append((module, runner))
+ imports.sort(key=lambda x: (len(x[0]), x[0]))
+ import_lines = [f"from .{mod} import {name}" for mod, name in imports]
entries: list[str] = []
for example in examples:
@@ -181,8 +182,7 @@ def generate_registry(examples: list[dict[str, Any]]) -> str:
from typing import Any, Callable, cast
-from .example_types import ExampleResult
-{chr(10).join(imports)}
+{chr(10).join(import_lines)}
ExampleRegistryEntry = dict[str, Any]
diff --git a/uv.lock b/uv.lock
index 22893d164..79832c62a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -835,7 +835,7 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
@@ -2386,7 +2386,7 @@ wheels = [
[[package]]
name = "runloop-api-client"
-version = "1.11.0"
+version = "1.12.1"
source = { editable = "." }
dependencies = [
{ name = "anyio" },