diff --git a/EXAMPLES.md b/EXAMPLES.md
index 0d3ff62cc..92927089b 100644
--- a/EXAMPLES.md
+++ b/EXAMPLES.md
@@ -9,6 +9,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 Tunnel (HTTP Server Access)](#devbox-tunnel)
- [MCP Hub + Claude Code + GitHub](#mcp-github-tools)
@@ -70,6 +71,36 @@ uv run pytest -m smoketest tests/smoketests/examples/
**Source:** [`examples/devbox_from_blueprint_lifecycle.py`](./examples/devbox_from_blueprint_lifecycle.py)
+
+## Devbox Tunnel (HTTP Server Access)
+
+**Use case:** Create a devbox, start an HTTP server, enable a tunnel, 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
+- Start an HTTP server inside the devbox
+- Enable a tunnel for external access
+- 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..479fa9957
--- /dev/null
+++ b/examples/devbox_tunnel.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env -S uv run python
+"""
+---
+title: Devbox Tunnel (HTTP Server Access)
+slug: devbox-tunnel
+use_case: Create a devbox, start an HTTP server, enable a tunnel, and access the server from the local machine through the tunnel. Uses the async SDK.
+workflow:
+ - Create a devbox
+ - Start an HTTP server inside the devbox
+ - Enable a tunnel for external access
+ - 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, start an HTTP server, enable a tunnel, 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",
+ },
+ )
+ 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)
+
+ # Enable a tunnel to expose the HTTP server
+ # For authenticated tunnels, use auth_mode="authenticated" and include the auth_token
+ # in your requests via the Authorization header: `Authorization: Bearer {tunnel.auth_token}`
+ tunnel = await devbox.net.enable_tunnel(auth_mode="open")
+
+ # 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 enabling 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 cb6b780a9..c2b7e7e48 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 .blueprint_with_build_context import run_blueprint_with_build_context_example
@@ -29,6 +30,13 @@
"required_env": ["RUNLOOP_API_KEY"],
"run": run_devbox_from_blueprint_lifecycle_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]