diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8c652335f..8c3962610 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -32,6 +32,9 @@ jobs:
- name: Run lints
run: ./scripts/lint
+ - name: Verify generated examples docs
+ run: uv run python scripts/generate_examples_md.py --check
+
build:
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
timeout-minutes: 10
@@ -55,14 +58,18 @@ jobs:
run: uv build
- name: Get GitHub OIDC Token
- if: github.repository == 'stainless-sdks/runloop-python'
+ if: |-
+ github.repository == 'stainless-sdks/runloop-python' &&
+ !startsWith(github.ref, 'refs/heads/stl/')
id: github-oidc
uses: actions/github-script@v8
with:
script: core.setOutput('github_token', await core.getIDToken());
- name: Upload tarball
- if: github.repository == 'stainless-sdks/runloop-python'
+ if: |-
+ github.repository == 'stainless-sdks/runloop-python' &&
+ !startsWith(github.ref, 'refs/heads/stl/')
env:
URL: https://pkg.stainless.com/s
AUTH: ${{ steps.github-oidc.outputs.github_token }}
diff --git a/.github/workflows/smoketests.yml b/.github/workflows/smoketests.yml
index ba29508b5..f6846f473 100644
--- a/.github/workflows/smoketests.yml
+++ b/.github/workflows/smoketests.yml
@@ -45,6 +45,10 @@ jobs:
fi
echo "DEBUG=false" >> $GITHUB_ENV
echo "PYTHONPATH=${{ github.workspace }}/src" >> $GITHUB_ENV
+ echo "RUN_EXAMPLE_LIVE_TESTS=1" >> $GITHUB_ENV
+
+ - name: Verify generated example artifacts
+ run: uv run python scripts/generate_examples_md.py --check
- name: Run smoke tests (pytest via uv)
env:
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 083df00ef..caf148712 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "1.10.3"
+ ".": "1.11.0"
}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bb2c9f22c..1a11183b9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,27 @@
# Changelog
+## 1.11.0 (2026-03-10)
+
+Full Changelog: [v1.10.3...v1.11.0](https://github.com/runloopai/api-client-python/compare/v1.10.3...v1.11.0)
+
+### Features
+
+* add snapshots example ([#753](https://github.com/runloopai/api-client-python/issues/753)) ([f13916a](https://github.com/runloopai/api-client-python/commit/f13916a570fe708aef56c6fbfd5c06b9e0dc158e))
+* **documentation:** added llms.txt, examples and referenced these artifacts via README ([#748](https://github.com/runloopai/api-client-python/issues/748)) ([bceb953](https://github.com/runloopai/api-client-python/commit/bceb953c8486bec6bbe5bca1276ed54d625eda5d))
+
+
+### Bug Fixes
+
+* add logs to devboxes, smoke tests & examples ([#755](https://github.com/runloopai/api-client-python/issues/755)) ([da5faa4](https://github.com/runloopai/api-client-python/commit/da5faa4e69348dd6f034a538046ab9ff2ff4831b))
+
+
+### Chores
+
+* **ci:** skip uploading artifacts on stainless-internal branches ([6874252](https://github.com/runloopai/api-client-python/commit/6874252fe355ac8a44be6bc57cd6bb15a3f035be))
+* **documentation:** correct exec advice ([#752](https://github.com/runloopai/api-client-python/issues/752)) ([fa16c1d](https://github.com/runloopai/api-client-python/commit/fa16c1d602ec301200e82a48fe23c2218ae82a08))
+* **test:** do not count install time for mock server timeout ([f6fde05](https://github.com/runloopai/api-client-python/commit/f6fde050a5f4b985c01003cd7593c1619ede028f))
+* update placeholder string ([5dfcd93](https://github.com/runloopai/api-client-python/commit/5dfcd93bb05cda783901377ee7fc03a6acffe0ae))
+
## 1.10.3 (2026-02-27)
Full Changelog: [v1.10.2...v1.10.3](https://github.com/runloopai/api-client-python/compare/v1.10.2...v1.10.3)
diff --git a/EXAMPLES.md b/EXAMPLES.md
new file mode 100644
index 000000000..eb1222c9c
--- /dev/null
+++ b/EXAMPLES.md
@@ -0,0 +1,137 @@
+# Examples
+
+> This file is auto-generated from metadata in `examples/*.py`.
+> Do not edit this file manually. Run `uv run python scripts/generate_examples_md.py` instead.
+
+Runnable examples live in [`examples/`](./examples).
+
+## Table of Contents
+
+- [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)
+- [MCP Hub + Claude Code + GitHub](#mcp-github-tools)
+
+
+## Blueprint with Build Context
+
+**Use case:** Create a blueprint using the object store to provide docker build context files, then verify files are copied into the image. Uses the async SDK.
+
+**Tags:** `blueprint`, `object-store`, `build-context`, `devbox`, `cleanup`, `async`
+
+### Workflow
+- Create a temporary directory with sample application files
+- Upload the directory to object storage as build context
+- Create a blueprint with a Dockerfile that copies the context files
+- Create a devbox from the blueprint
+- Verify the files were copied into the image
+- Shutdown devbox and delete blueprint and storage object
+
+### Prerequisites
+- `RUNLOOP_API_KEY`
+
+### Run
+```sh
+uv run python -m examples.blueprint_with_build_context
+```
+
+### Test
+```sh
+uv run pytest -m smoketest tests/smoketests/examples/
+```
+
+**Source:** [`examples/blueprint_with_build_context.py`](./examples/blueprint_with_build_context.py)
+
+
+## Devbox From Blueprint (Run Command, Shutdown)
+
+**Use case:** Create a devbox from a blueprint, run a command, fetch logs, validate output, and cleanly tear everything down.
+
+**Tags:** `devbox`, `blueprint`, `commands`, `logs`, `cleanup`
+
+### Workflow
+- Create a blueprint
+- Fetch blueprint build logs
+- Create a devbox from the blueprint
+- Execute a command in the devbox
+- Fetch devbox logs
+- Validate exit code, stdout, and logs
+- Shutdown devbox and delete blueprint
+
+### Prerequisites
+- `RUNLOOP_API_KEY`
+
+### Run
+```sh
+uv run python -m examples.devbox_from_blueprint_lifecycle
+```
+
+### Test
+```sh
+uv run pytest -m smoketest tests/smoketests/examples/
+```
+
+**Source:** [`examples/devbox_from_blueprint_lifecycle.py`](./examples/devbox_from_blueprint_lifecycle.py)
+
+
+## Devbox Snapshot and Resume
+
+**Use case:** Create a devbox, snapshot its disk, resume from the snapshot, and demonstrate that changes in the original devbox do not affect the clone. Uses the async SDK.
+
+**Tags:** `devbox`, `snapshot`, `resume`, `cleanup`, `async`
+
+### Workflow
+- Create a devbox
+- Write a file to the devbox
+- Create a disk snapshot
+- Create a new devbox from the snapshot
+- Modify the file on the original devbox
+- Verify the clone has the original content
+- Shutdown both devboxes and delete the snapshot
+
+### Prerequisites
+- `RUNLOOP_API_KEY`
+
+### Run
+```sh
+uv run python -m examples.devbox_snapshot_resume
+```
+
+### Test
+```sh
+uv run pytest -m smoketest tests/smoketests/examples/
+```
+
+**Source:** [`examples/devbox_snapshot_resume.py`](./examples/devbox_snapshot_resume.py)
+
+
+## MCP Hub + Claude Code + GitHub
+
+**Use case:** Connect Claude Code running in a devbox to GitHub tools through MCP Hub without exposing raw GitHub credentials to the devbox.
+
+**Tags:** `mcp`, `devbox`, `github`, `commands`, `cleanup`
+
+### Workflow
+- Create an MCP config for GitHub
+- Store GitHub token as a Runloop secret
+- Launch a devbox with MCP Hub wiring
+- Install Claude Code and register MCP endpoint
+- Run a Claude prompt through MCP tools
+- Shutdown devbox and clean up cloud resources
+
+### Prerequisites
+- `RUNLOOP_API_KEY`
+- `GITHUB_TOKEN (GitHub PAT with repo scope)`
+- `ANTHROPIC_API_KEY`
+
+### Run
+```sh
+GITHUB_TOKEN=ghp_xxx ANTHROPIC_API_KEY=sk-ant-xxx uv run python -m examples.mcp_github_tools
+```
+
+### Test
+```sh
+uv run pytest -m smoketest tests/smoketests/examples/
+```
+
+**Source:** [`examples/mcp_github_tools.py`](./examples/mcp_github_tools.py)
diff --git a/README-SDK.md b/README-SDK.md
index e4885ff3b..115eacf22 100644
--- a/README-SDK.md
+++ b/README-SDK.md
@@ -160,13 +160,13 @@ print(f"Devbox {info.name} is {info.status}")
Execute commands synchronously or asynchronously:
```python
-# Synchronous command execution (waits for completion)
+# exec blocks until completion - use for commands that return immediately
result = devbox.cmd.exec("ls -la")
print("Output:", result.stdout())
print("Exit code:", result.exit_code)
print("Success:", result.success)
-# Asynchronous command execution (returns immediately)
+# exec_async returns immediately - use for long-running processes
execution = devbox.cmd.exec_async("npm run dev")
# Check execution status
@@ -393,11 +393,30 @@ async with await runloop.devbox.create(name="temp-devbox") as devbox:
# devbox is automatically shutdown when exiting the context
```
+#### Devbox Logs
+
+Retrieve logs from a devbox, optionally filtered by execution ID or shell name:
+
+```python
+# Get all devbox logs
+logs = devbox.logs()
+for log in logs.logs:
+ print(f"[{log.level}] {log.message}")
+
+# Filter logs by execution ID
+result = devbox.cmd.exec('echo "hello"')
+exec_logs = devbox.logs(execution_id=result.execution_id)
+
+# Filter logs by shell name
+shell_logs = devbox.logs(shell_name="my-shell")
+```
+
**Key methods:**
- `devbox.get_info()` - Get devbox details and status
- `devbox.cmd.exec()` - Execute commands synchronously
- `devbox.cmd.exec_async()` - Execute commands asynchronously
+- `devbox.logs()` - Retrieve devbox logs (optionally filter by execution_id or shell_name)
- `devbox.file.read()` - Read file contents
- `devbox.file.write()` - Write file contents
- `devbox.file.upload()` - Upload files
diff --git a/README.md b/README.md
index 14e808a10..09aeb9016 100644
--- a/README.md
+++ b/README.md
@@ -88,6 +88,23 @@ asyncio.run(main())
Functionality between the synchronous and asynchronous clients is otherwise identical.
+## Examples
+
+Workflow-oriented runnable examples are documented in [`EXAMPLES.md`](./EXAMPLES.md).
+
+`EXAMPLES.md` is generated from metadata in `examples/*.py` and should not be edited manually.
+Regenerate it with:
+
+```sh
+uv run python scripts/generate_examples_md.py
+```
+
+## Agent Guidance
+
+Detailed agent-specific instructions for developing using this package live in [`llms.txt`](./llms.txt). Consolidated recipes for frequent tasks are in [`EXAMPLES.md`](./EXAMPLES.md).
+
+After completing any modifications to this project, ensure `llms.txt` and `README.md` are kept in sync.
+
### With aiohttp
By default, the async client uses `httpx` for HTTP requests. However, for improved concurrency performance you may also use `aiohttp` as the HTTP backend.
@@ -480,6 +497,17 @@ print(runloop_api_client.__version__)
Python 3.9 or higher.
+## Development
+
+After cloning the repository, run the bootstrap script and install git hooks:
+
+```sh
+./scripts/bootstrap
+./scripts/install-hooks
+```
+
+This installs pre-push hooks that run linting and verify generated files are up to date.
+
## Contributing
See [the contributing documentation](./CONTRIBUTING.md).
diff --git a/examples/.keep b/examples/.keep
deleted file mode 100644
index d8c73e937..000000000
--- a/examples/.keep
+++ /dev/null
@@ -1,4 +0,0 @@
-File generated from our OpenAPI spec by Stainless.
-
-This directory can be used to store example files demonstrating usage of this SDK.
-It is ignored by Stainless code generation and its content (other than this keep file) won't be touched.
\ No newline at end of file
diff --git a/examples/__init__.py b/examples/__init__.py
new file mode 100644
index 000000000..9f6967a61
--- /dev/null
+++ b/examples/__init__.py
@@ -0,0 +1 @@
+# Examples package for Runloop Python SDK
diff --git a/examples/_harness.py b/examples/_harness.py
new file mode 100644
index 000000000..dec6dfdf4
--- /dev/null
+++ b/examples/_harness.py
@@ -0,0 +1,172 @@
+from __future__ import annotations
+
+import sys
+import json
+import time
+import asyncio
+from typing import Any, TypeVar, Callable, Awaitable
+from dataclasses import asdict
+
+from .example_types import (
+ ExampleCheck,
+ RecipeOutput,
+ ExampleResult,
+ RecipeContext,
+ ExampleCleanupStatus,
+ ExampleCleanupFailure,
+)
+
+T = TypeVar("T")
+
+
+def unique_name(prefix: str) -> str:
+ """Generate a unique name with timestamp and random suffix."""
+ return f"{prefix}-{int(time.time())}-{hex(int(time.time() * 1000) % 0xFFFFFF)[2:]}"
+
+
+class _CleanupTracker:
+ """Tracks cleanup actions and executes them in LIFO order."""
+
+ def __init__(self, status: ExampleCleanupStatus) -> None:
+ self._status = status
+ self._actions: list[tuple[str, Callable[[], Any]]] = []
+
+ def add(self, resource: str, action: Callable[[], Any]) -> None:
+ """Register a cleanup action for a resource."""
+ self._actions.append((resource, action))
+
+ async def run(self) -> None:
+ """Execute all cleanup actions in reverse order."""
+ while self._actions:
+ resource, action = self._actions.pop()
+ self._status.attempted.append(resource)
+ try:
+ result = action()
+ if asyncio.iscoroutine(result):
+ await result
+ self._status.succeeded.append(resource)
+ except Exception as e:
+ self._status.failed.append(ExampleCleanupFailure(resource, str(e)))
+
+ if self._status.attempted:
+ if not self._status.failed:
+ print("Cleanup completed.") # noqa: T201
+ else:
+ print("Cleanup finished with errors.") # noqa: T201
+
+
+def _should_fail_process(result: ExampleResult) -> bool:
+ """Determine if the process should exit with failure."""
+ has_failed_checks = any(not check.passed for check in result.checks)
+ return result.skipped or has_failed_checks or len(result.cleanup_status.failed) > 0
+
+
+def _run_recipe_impl(
+ recipe_call: Callable[[], RecipeOutput | Awaitable[RecipeOutput]],
+ cleanup: _CleanupTracker,
+ cleanup_status: ExampleCleanupStatus,
+) -> ExampleResult:
+ """Shared implementation for running recipes with cleanup."""
+
+ async def _run_async() -> RecipeOutput:
+ try:
+ result = recipe_call()
+ if asyncio.iscoroutine(result):
+ output: RecipeOutput = await result
+ return output
+ return result # type: ignore[return-value]
+ finally:
+ await cleanup.run()
+
+ loop = asyncio.new_event_loop()
+ try:
+ output = loop.run_until_complete(_run_async())
+ return ExampleResult(
+ resources_created=output.resources_created,
+ checks=output.checks,
+ cleanup_status=cleanup_status,
+ )
+ finally:
+ loop.close()
+
+
+def wrap_recipe(
+ recipe: Callable[[RecipeContext], RecipeOutput] | Callable[[RecipeContext], Awaitable[RecipeOutput]],
+ validate_env: Callable[[], tuple[bool, list[ExampleCheck]]] | None = None,
+) -> Callable[[], ExampleResult]:
+ """Wrap a recipe function with cleanup tracking and result handling.
+
+ Args:
+ recipe: The recipe function to wrap. Can be sync or async.
+ validate_env: Optional function to validate environment before running.
+ Returns (skip, checks) tuple.
+
+ Returns:
+ A callable that runs the recipe and returns ExampleResult.
+ """
+
+ def run() -> ExampleResult:
+ cleanup_status = ExampleCleanupStatus()
+ cleanup = _CleanupTracker(cleanup_status)
+
+ if validate_env is not None:
+ skip, checks = validate_env()
+ if skip:
+ return ExampleResult(
+ resources_created=[],
+ checks=checks,
+ cleanup_status=cleanup_status,
+ skipped=True,
+ )
+
+ ctx = RecipeContext(cleanup=cleanup)
+ return _run_recipe_impl(lambda: recipe(ctx), cleanup, cleanup_status)
+
+ return run
+
+
+def wrap_recipe_with_options(
+ recipe: Callable[[RecipeContext, T], RecipeOutput] | Callable[[RecipeContext, T], Awaitable[RecipeOutput]],
+ validate_env: Callable[[T], tuple[bool, list[ExampleCheck]]] | None = None,
+) -> Callable[[T], ExampleResult]:
+ """Wrap a recipe function that takes options with cleanup tracking.
+
+ Args:
+ recipe: The recipe function to wrap. Can be sync or async. Takes options parameter.
+ validate_env: Optional function to validate environment before running.
+ Takes options and returns (skip, checks) tuple.
+
+ Returns:
+ A callable that runs the recipe with options and returns ExampleResult.
+ """
+
+ def run(options: T) -> ExampleResult:
+ cleanup_status = ExampleCleanupStatus()
+ cleanup = _CleanupTracker(cleanup_status)
+
+ if validate_env is not None:
+ skip, checks = validate_env(options)
+ if skip:
+ return ExampleResult(
+ resources_created=[],
+ checks=checks,
+ cleanup_status=cleanup_status,
+ skipped=True,
+ )
+
+ ctx = RecipeContext(cleanup=cleanup)
+ return _run_recipe_impl(lambda: recipe(ctx, options), cleanup, cleanup_status)
+
+ return run
+
+
+def run_as_cli(run: Callable[[], ExampleResult]) -> None:
+ """Run an example and exit with appropriate status code."""
+ try:
+ result = run()
+ print(json.dumps(asdict(result), indent=2)) # noqa: T201
+ if _should_fail_process(result):
+ sys.exit(1)
+ except Exception as e:
+ print(f"Error: {e}") # noqa: T201
+ sys.exit(1)
diff --git a/examples/blueprint_with_build_context.py b/examples/blueprint_with_build_context.py
new file mode 100644
index 000000000..3bd9395d3
--- /dev/null
+++ b/examples/blueprint_with_build_context.py
@@ -0,0 +1,130 @@
+#!/usr/bin/env -S uv run python
+"""
+---
+title: Blueprint with Build Context
+slug: blueprint-with-build-context
+use_case: Create a blueprint using the object store to provide docker build context files, then verify files are copied into the image. Uses the async SDK.
+workflow:
+ - Create a temporary directory with sample application files
+ - Upload the directory to object storage as build context
+ - Create a blueprint with a Dockerfile that copies the context files
+ - Create a devbox from the blueprint
+ - Verify the files were copied into the image
+ - Shutdown devbox and delete blueprint and storage object
+tags:
+ - blueprint
+ - object-store
+ - build-context
+ - devbox
+ - cleanup
+ - async
+prerequisites:
+ - RUNLOOP_API_KEY
+run: uv run python -m examples.blueprint_with_build_context
+test: uv run pytest -m smoketest tests/smoketests/examples/
+---
+"""
+
+from __future__ import annotations
+
+import tempfile
+from pathlib import Path
+from datetime import timedelta
+
+from runloop_api_client import AsyncRunloopSDK
+from runloop_api_client.lib.polling import PollingConfig
+
+from ._harness import run_as_cli, unique_name, wrap_recipe
+from .example_types import ExampleCheck, RecipeOutput, RecipeContext
+
+# building can take time: make sure to set a long blueprint build timeout
+BLUEPRINT_POLL_TIMEOUT_S = 10 * 60
+BLUEPRINT_POLL_MAX_ATTEMPTS = 600
+
+# configure object storage ttl for the build context
+BUILD_CONTEXT_TTL = timedelta(hours=1)
+
+
+async def recipe(ctx: RecipeContext) -> RecipeOutput:
+ """Create a blueprint with build context from object storage, then verify files in a devbox."""
+ cleanup = ctx.cleanup
+
+ sdk = AsyncRunloopSDK()
+
+ # setup: create a temporary directory with sample application files to use as build context
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ tmp_path = Path(tmp_dir)
+ (tmp_path / "app.py").write_text('print("Hello from app")')
+ (tmp_path / "config.txt").write_text("key=value")
+
+ # upload the build context to object storage
+ storage_obj = await sdk.storage_object.upload_from_dir(
+ tmp_path,
+ name=unique_name("example-build-context"),
+ ttl=BUILD_CONTEXT_TTL,
+ )
+ cleanup.add(f"storage_object:{storage_obj.id}", storage_obj.delete)
+
+ # create a blueprint with the build context
+ blueprint = await sdk.blueprint.create(
+ name=unique_name("example-blueprint-context"),
+ dockerfile="FROM ubuntu:22.04\nWORKDIR /app\nCOPY . .",
+ build_context=storage_obj.as_build_context(),
+ polling_config=PollingConfig(
+ timeout_seconds=BLUEPRINT_POLL_TIMEOUT_S,
+ max_attempts=BLUEPRINT_POLL_MAX_ATTEMPTS,
+ ),
+ )
+ cleanup.add(f"blueprint:{blueprint.id}", blueprint.delete)
+
+ devbox = await blueprint.create_devbox(
+ name=unique_name("example-devbox"),
+ launch_parameters={
+ "resource_size_request": "X_SMALL",
+ "keep_alive_time_seconds": 60 * 5,
+ },
+ )
+ cleanup.add(f"devbox:{devbox.id}", devbox.shutdown)
+
+ app_result = await devbox.cmd.exec("cat /app/app.py")
+ app_stdout = await app_result.stdout()
+
+ config_result = await devbox.cmd.exec("cat /app/config.txt")
+ config_stdout = await config_result.stdout()
+
+ return RecipeOutput(
+ resources_created=[
+ f"storage_object:{storage_obj.id}",
+ f"blueprint:{blueprint.id}",
+ f"devbox:{devbox.id}",
+ ],
+ checks=[
+ ExampleCheck(
+ name="app.py exists and readable",
+ passed=app_result.exit_code == 0,
+ details=f"exitCode={app_result.exit_code}",
+ ),
+ ExampleCheck(
+ name="app.py contains expected content",
+ passed='print("Hello from app")' in app_stdout,
+ details=app_stdout.strip(),
+ ),
+ ExampleCheck(
+ name="config.txt exists and readable",
+ passed=config_result.exit_code == 0,
+ details=f"exitCode={config_result.exit_code}",
+ ),
+ ExampleCheck(
+ name="config.txt contains expected content",
+ passed="key=value" in config_stdout,
+ details=config_stdout.strip(),
+ ),
+ ],
+ )
+
+
+run_blueprint_with_build_context_example = wrap_recipe(recipe)
+
+
+if __name__ == "__main__":
+ run_as_cli(run_blueprint_with_build_context_example)
diff --git a/examples/devbox_from_blueprint_lifecycle.py b/examples/devbox_from_blueprint_lifecycle.py
new file mode 100644
index 000000000..f916ee5f4
--- /dev/null
+++ b/examples/devbox_from_blueprint_lifecycle.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env -S uv run python
+"""
+---
+title: Devbox From Blueprint (Run Command, Shutdown)
+slug: devbox-from-blueprint-lifecycle
+use_case: Create a devbox from a blueprint, run a command, fetch logs, validate output, and cleanly tear everything down.
+workflow:
+ - Create a blueprint
+ - Fetch blueprint build logs
+ - Create a devbox from the blueprint
+ - Execute a command in the devbox
+ - Fetch devbox logs
+ - Validate exit code, stdout, and logs
+ - Shutdown devbox and delete blueprint
+tags:
+ - devbox
+ - blueprint
+ - commands
+ - logs
+ - cleanup
+prerequisites:
+ - RUNLOOP_API_KEY
+run: uv run python -m examples.devbox_from_blueprint_lifecycle
+test: uv run pytest -m smoketest tests/smoketests/examples/
+---
+"""
+
+from __future__ import annotations
+
+from runloop_api_client import RunloopSDK
+from runloop_api_client.lib.polling import PollingConfig
+
+from ._harness import run_as_cli, unique_name, wrap_recipe
+from .example_types import ExampleCheck, RecipeOutput, RecipeContext
+
+BLUEPRINT_POLL_TIMEOUT_S = 10 * 60
+
+
+def recipe(ctx: RecipeContext) -> RecipeOutput:
+ """Create a devbox from a blueprint, run a command, fetch logs, and clean up."""
+ cleanup = ctx.cleanup
+
+ sdk = RunloopSDK()
+
+ blueprint = sdk.blueprint.create(
+ name=unique_name("example-blueprint"),
+ dockerfile='FROM ubuntu:22.04\nRUN echo "Hello from your blueprint"',
+ polling_config=PollingConfig(timeout_seconds=BLUEPRINT_POLL_TIMEOUT_S),
+ )
+ cleanup.add(f"blueprint:{blueprint.id}", blueprint.delete)
+
+ # Fetch blueprint build logs
+ blueprint_logs = blueprint.logs()
+
+ devbox = blueprint.create_devbox(
+ name=unique_name("example-devbox"),
+ launch_parameters={
+ "resource_size_request": "X_SMALL",
+ "keep_alive_time_seconds": 60 * 5,
+ },
+ )
+ cleanup.add(f"devbox:{devbox.id}", devbox.shutdown)
+
+ result = devbox.cmd.exec('echo "Hello from your devbox"')
+ stdout = result.stdout()
+
+ # Fetch devbox logs
+ devbox_logs = devbox.logs()
+
+ return RecipeOutput(
+ resources_created=[f"blueprint:{blueprint.id}", f"devbox:{devbox.id}"],
+ checks=[
+ ExampleCheck(
+ name="command exits successfully",
+ passed=result.exit_code == 0,
+ details=f"exitCode={result.exit_code}",
+ ),
+ ExampleCheck(
+ name="command output contains expected text",
+ passed="Hello from your devbox" in stdout,
+ details=stdout.strip(),
+ ),
+ ExampleCheck(
+ name="blueprint build logs are retrievable",
+ passed=hasattr(blueprint_logs, "logs"),
+ details=f"blueprint_log_count={len(blueprint_logs.logs)}",
+ ),
+ ExampleCheck(
+ name="devbox logs are retrievable",
+ passed=hasattr(devbox_logs, "logs"),
+ details=f"devbox_log_count={len(devbox_logs.logs)}",
+ ),
+ ],
+ )
+
+
+run_devbox_from_blueprint_lifecycle_example = wrap_recipe(recipe)
+
+
+if __name__ == "__main__":
+ run_as_cli(run_devbox_from_blueprint_lifecycle_example)
diff --git a/examples/devbox_snapshot_resume.py b/examples/devbox_snapshot_resume.py
new file mode 100644
index 000000000..6913731b2
--- /dev/null
+++ b/examples/devbox_snapshot_resume.py
@@ -0,0 +1,134 @@
+#!/usr/bin/env -S uv run python
+"""
+---
+title: Devbox Snapshot and Resume
+slug: devbox-snapshot-resume
+use_case: Create a devbox, snapshot its disk, resume from the snapshot, and demonstrate that changes in the original devbox do not affect the clone. Uses the async SDK.
+workflow:
+ - Create a devbox
+ - Write a file to the devbox
+ - Create a disk snapshot
+ - Create a new devbox from the snapshot
+ - Modify the file on the original devbox
+ - Verify the clone has the original content
+ - Shutdown both devboxes and delete the snapshot
+tags:
+ - devbox
+ - snapshot
+ - resume
+ - cleanup
+ - async
+prerequisites:
+ - RUNLOOP_API_KEY
+run: uv run python -m examples.devbox_snapshot_resume
+test: uv run pytest -m smoketest tests/smoketests/examples/
+---
+"""
+
+from __future__ import annotations
+
+from runloop_api_client import AsyncRunloopSDK
+
+from ._harness import run_as_cli, wrap_recipe
+from .example_types import ExampleCheck, RecipeOutput, RecipeContext
+
+FILE_PATH = "/home/user/welcome.txt"
+ORIGINAL_CONTENT = "hello world!"
+MODIFIED_CONTENT = "original devbox has changed the welcome message"
+
+
+async def recipe(ctx: RecipeContext) -> RecipeOutput:
+ """Create a devbox, snapshot it, resume from snapshot, and verify state isolation."""
+ cleanup = ctx.cleanup
+
+ sdk = AsyncRunloopSDK()
+
+ # Create a devbox
+ dbx_original = await sdk.devbox.create(
+ name="dbx_original",
+ launch_parameters={
+ "resource_size_request": "X_SMALL",
+ "keep_alive_time_seconds": 60 * 5,
+ },
+ )
+ cleanup.add(f"devbox:{dbx_original.id}", dbx_original.shutdown)
+
+ # Write a file to the original devbox
+ await dbx_original.file.write(file_path=FILE_PATH, contents=ORIGINAL_CONTENT)
+
+ # Read and display the file contents
+ cat_original_before = await dbx_original.cmd.exec(f"cat {FILE_PATH}")
+ original_content_before = await cat_original_before.stdout()
+
+ # Create a disk snapshot of the original devbox
+ snapshot = await dbx_original.snapshot_disk(name="my-snapshot")
+ cleanup.add(f"snapshot:{snapshot.id}", snapshot.delete)
+
+ # Create a new devbox from the snapshot
+ dbx_clone = await sdk.devbox.create_from_snapshot(
+ snapshot.id,
+ name="dbx_clone",
+ launch_parameters={
+ "resource_size_request": "X_SMALL",
+ "keep_alive_time_seconds": 60 * 5,
+ },
+ )
+ cleanup.add(f"devbox:{dbx_clone.id}", dbx_clone.shutdown)
+
+ # Modify the file on the original devbox
+ await dbx_original.file.write(file_path=FILE_PATH, contents=MODIFIED_CONTENT)
+
+ # Read the file contents from both devboxes
+ cat_clone = await dbx_clone.cmd.exec(f"cat {FILE_PATH}")
+ clone_content = await cat_clone.stdout()
+
+ # now the original devbox has been modified but the clone has the original message
+ cat_original_after = await dbx_original.cmd.exec(f"cat {FILE_PATH}")
+ original_content_after = await cat_original_after.stdout()
+
+ return RecipeOutput(
+ resources_created=[
+ f"devbox:{dbx_original.id}",
+ f"snapshot:{snapshot.id}",
+ f"devbox:{dbx_clone.id}",
+ ],
+ checks=[
+ ExampleCheck(
+ name="original devbox file created successfully",
+ passed=cat_original_before.exit_code == 0 and original_content_before.strip() == ORIGINAL_CONTENT,
+ details=f'content="{original_content_before.strip()}"',
+ ),
+ ExampleCheck(
+ name="snapshot created successfully",
+ passed=bool(snapshot.id),
+ details=f"snapshotId={snapshot.id}",
+ ),
+ ExampleCheck(
+ name="clone devbox created from snapshot",
+ passed=bool(dbx_clone.id),
+ details=f"cloneId={dbx_clone.id}",
+ ),
+ ExampleCheck(
+ name="clone has original file content (before modification)",
+ passed=cat_clone.exit_code == 0 and clone_content.strip() == ORIGINAL_CONTENT,
+ details=f'cloneContent="{clone_content.strip()}"',
+ ),
+ ExampleCheck(
+ name="original devbox has modified content",
+ passed=cat_original_after.exit_code == 0 and original_content_after.strip() == MODIFIED_CONTENT,
+ details=f'originalContent="{original_content_after.strip()}"',
+ ),
+ ExampleCheck(
+ name="clone and original have divergent state",
+ passed=clone_content.strip() != original_content_after.strip(),
+ details=f'clone="{clone_content.strip()}" vs original="{original_content_after.strip()}"',
+ ),
+ ],
+ )
+
+
+run_devbox_snapshot_resume_example = wrap_recipe(recipe)
+
+
+if __name__ == "__main__":
+ run_as_cli(run_devbox_snapshot_resume_example)
diff --git a/examples/example_types.py b/examples/example_types.py
new file mode 100644
index 000000000..46e8931c8
--- /dev/null
+++ b/examples/example_types.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from typing import Any, Union, Callable, Protocol, Awaitable
+from dataclasses import field, dataclass
+
+
+@dataclass
+class ExampleCheck:
+ """Result of a single validation check in an example."""
+
+ name: str
+ passed: bool
+ details: str | None = None
+
+
+@dataclass
+class ExampleCleanupFailure:
+ """Record of a cleanup action that failed."""
+
+ resource: str
+ reason: str
+
+
+@dataclass
+class ExampleCleanupStatus:
+ """Tracks cleanup operations during example execution."""
+
+ attempted: list[str] = field(default_factory=lambda: [])
+ succeeded: list[str] = field(default_factory=lambda: [])
+ failed: list[ExampleCleanupFailure] = field(default_factory=lambda: [])
+
+
+@dataclass
+class ExampleResult:
+ """Full result of running an example, including checks and cleanup status."""
+
+ resources_created: list[str]
+ checks: list[ExampleCheck]
+ cleanup_status: ExampleCleanupStatus
+ skipped: bool = False
+
+
+@dataclass
+class RecipeOutput:
+ """Output from a recipe function before cleanup runs."""
+
+ resources_created: list[str]
+ checks: list[ExampleCheck]
+
+
+CleanupAction = Callable[[], Union[None, Awaitable[None]]]
+
+
+class CleanupTracker(Protocol):
+ """Protocol for tracking cleanup actions."""
+
+ def add(self, resource: str, action: CleanupAction) -> None: ...
+
+
+@dataclass
+class RecipeContext:
+ """Context passed to recipe functions."""
+
+ cleanup: Any # CleanupTracker, but using Any to avoid circular typing issues
diff --git a/examples/mcp_github_tools.py b/examples/mcp_github_tools.py
new file mode 100644
index 000000000..b2850c7af
--- /dev/null
+++ b/examples/mcp_github_tools.py
@@ -0,0 +1,184 @@
+#!/usr/bin/env -S uv run python
+"""
+---
+title: MCP Hub + Claude Code + GitHub
+slug: mcp-github-tools
+use_case: Connect Claude Code running in a devbox to GitHub tools through MCP Hub without exposing raw GitHub credentials to the devbox.
+workflow:
+ - Create an MCP config for GitHub
+ - Store GitHub token as a Runloop secret
+ - Launch a devbox with MCP Hub wiring
+ - Install Claude Code and register MCP endpoint
+ - Run a Claude prompt through MCP tools
+ - Shutdown devbox and clean up cloud resources
+tags:
+ - mcp
+ - devbox
+ - github
+ - commands
+ - cleanup
+prerequisites:
+ - RUNLOOP_API_KEY
+ - GITHUB_TOKEN (GitHub PAT with repo scope)
+ - ANTHROPIC_API_KEY
+run: GITHUB_TOKEN=ghp_xxx ANTHROPIC_API_KEY=sk-ant-xxx uv run python -m examples.mcp_github_tools
+test: uv run pytest -m smoketest tests/smoketests/examples/
+---
+"""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+
+from runloop_api_client import RunloopSDK
+
+from ._harness import run_as_cli, unique_name, wrap_recipe_with_options
+from .example_types import ExampleCheck, RecipeOutput, RecipeContext
+
+GITHUB_MCP_ENDPOINT = "https://api.githubcopilot.com/mcp/"
+
+
+@dataclass
+class McpExampleOptions:
+ """Options for the MCP GitHub tools example."""
+
+ skip_if_missing_credentials: bool = False
+
+
+def recipe(ctx: RecipeContext, options: McpExampleOptions) -> RecipeOutput: # noqa: ARG001
+ """Connect Claude Code to GitHub tools via MCP Hub."""
+ cleanup = ctx.cleanup
+
+ sdk = RunloopSDK()
+ resources_created: list[str] = []
+ checks: list[ExampleCheck] = []
+
+ github_token = os.environ.get("GITHUB_TOKEN")
+ anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
+
+ if not github_token:
+ raise RuntimeError("Set GITHUB_TOKEN to a GitHub PAT with repo scope.")
+ if not anthropic_key:
+ raise RuntimeError("Set ANTHROPIC_API_KEY for Claude Code.")
+
+ # Register GitHub's MCP server with Runloop
+ mcp_config = sdk.api.mcp_configs.create(
+ name=unique_name("github-example"),
+ endpoint=GITHUB_MCP_ENDPOINT,
+ allowed_tools=[
+ "get_me",
+ "search_pull_requests",
+ "get_pull_request",
+ "get_repository",
+ "get_file_contents",
+ ],
+ description="GitHub MCP server - example",
+ )
+ resources_created.append(f"mcp_config:{mcp_config.id}")
+ cleanup.add(f"mcp_config:{mcp_config.id}", lambda: sdk.api.mcp_configs.delete(mcp_config.id))
+
+ # Store the GitHub PAT as a Runloop secret
+ secret_name = unique_name("example-github-mcp")
+ sdk.api.secrets.create(name=secret_name, value=github_token)
+ resources_created.append(f"secret:{secret_name}")
+ cleanup.add(f"secret:{secret_name}", lambda: sdk.api.secrets.delete(secret_name))
+
+ # Launch a devbox with MCP Hub wiring
+ devbox = sdk.devbox.create(
+ name=unique_name("mcp-claude-code"),
+ launch_parameters={
+ "resource_size_request": "SMALL",
+ "keep_alive_time_seconds": 300,
+ },
+ mcp={
+ "MCP_SECRET": {
+ "mcp_config": mcp_config.id,
+ "secret": secret_name,
+ },
+ },
+ )
+ resources_created.append(f"devbox:{devbox.id}")
+ cleanup.add(f"devbox:{devbox.id}", devbox.shutdown)
+
+ # Install Claude Code
+ install_result = devbox.cmd.exec("npm install -g @anthropic-ai/claude-code")
+ checks.append(
+ ExampleCheck(
+ name="install Claude Code",
+ passed=install_result.exit_code == 0,
+ details="installed" if install_result.exit_code == 0 else install_result.stderr(),
+ )
+ )
+ if install_result.exit_code != 0:
+ return RecipeOutput(resources_created=resources_created, checks=checks)
+
+ # Register MCP Hub endpoint with Claude Code
+ add_mcp_result = devbox.cmd.exec(
+ 'claude mcp add runloop-mcp --transport http "$RL_MCP_URL" --header "Authorization: Bearer $RL_MCP_TOKEN"',
+ )
+ checks.append(
+ ExampleCheck(
+ name="register MCP Hub in Claude",
+ passed=add_mcp_result.exit_code == 0,
+ details="registered" if add_mcp_result.exit_code == 0 else add_mcp_result.stderr(),
+ )
+ )
+ if add_mcp_result.exit_code != 0:
+ return RecipeOutput(resources_created=resources_created, checks=checks)
+
+ # Ask Claude to summarize latest PR via MCP tools
+ prompt = (
+ "Use the MCP tools to get my last pr and describe what it does in 2-3 sentences. "
+ "Also detail how you collected this information"
+ )
+ claude_result = devbox.cmd.exec(
+ f'ANTHROPIC_API_KEY={anthropic_key} claude -p "{prompt}" --dangerously-skip-permissions',
+ )
+ claude_stdout = claude_result.stdout().strip()
+ checks.append(
+ ExampleCheck(
+ name="Claude prompt through MCP succeeds",
+ passed=claude_result.exit_code == 0 and len(claude_stdout) > 0,
+ details="non-empty response received" if claude_result.exit_code == 0 else claude_result.stderr(),
+ )
+ )
+
+ return RecipeOutput(resources_created=resources_created, checks=checks)
+
+
+def validate_env(options: McpExampleOptions) -> tuple[bool, list[ExampleCheck]]:
+ """Validate required environment variables."""
+ checks: list[ExampleCheck] = []
+ skip_if_missing = options.skip_if_missing_credentials
+
+ github_token = os.environ.get("GITHUB_TOKEN")
+ if not github_token and skip_if_missing:
+ checks.append(
+ ExampleCheck(
+ name="GITHUB_TOKEN provided",
+ passed=False,
+ details="Skipped: missing GITHUB_TOKEN",
+ )
+ )
+ return True, checks
+
+ anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
+ if not anthropic_key and skip_if_missing:
+ checks.append(
+ ExampleCheck(
+ name="ANTHROPIC_API_KEY provided",
+ passed=False,
+ details="Skipped: missing ANTHROPIC_API_KEY",
+ )
+ )
+ return True, checks
+
+ return False, checks
+
+
+run_mcp_github_tools_example = wrap_recipe_with_options(recipe, validate_env)
+
+
+if __name__ == "__main__":
+ run_as_cli(lambda: run_mcp_github_tools_example(McpExampleOptions()))
diff --git a/examples/registry.py b/examples/registry.py
new file mode 100644
index 000000000..cde21979d
--- /dev/null
+++ b/examples/registry.py
@@ -0,0 +1,55 @@
+"""
+This file is auto-generated by scripts/generate_examples_md.py.
+Do not edit manually.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Callable, cast
+
+from .example_types import ExampleResult
+from .mcp_github_tools import run_mcp_github_tools_example
+from .devbox_snapshot_resume import run_devbox_snapshot_resume_example
+from .blueprint_with_build_context import run_blueprint_with_build_context_example
+from .devbox_from_blueprint_lifecycle import run_devbox_from_blueprint_lifecycle_example
+
+ExampleRegistryEntry = dict[str, Any]
+
+example_registry: list[ExampleRegistryEntry] = [
+ {
+ "slug": "blueprint-with-build-context",
+ "title": "Blueprint with Build Context",
+ "file_name": "blueprint_with_build_context.py",
+ "required_env": ["RUNLOOP_API_KEY"],
+ "run": run_blueprint_with_build_context_example,
+ },
+ {
+ "slug": "devbox-from-blueprint-lifecycle",
+ "title": "Devbox From Blueprint (Run Command, Shutdown)",
+ "file_name": "devbox_from_blueprint_lifecycle.py",
+ "required_env": ["RUNLOOP_API_KEY"],
+ "run": run_devbox_from_blueprint_lifecycle_example,
+ },
+ {
+ "slug": "devbox-snapshot-resume",
+ "title": "Devbox Snapshot and Resume",
+ "file_name": "devbox_snapshot_resume.py",
+ "required_env": ["RUNLOOP_API_KEY"],
+ "run": run_devbox_snapshot_resume_example,
+ },
+ {
+ "slug": "mcp-github-tools",
+ "title": "MCP Hub + Claude Code + GitHub",
+ "file_name": "mcp_github_tools.py",
+ "required_env": ["RUNLOOP_API_KEY", "GITHUB_TOKEN", "ANTHROPIC_API_KEY"],
+ "run": run_mcp_github_tools_example,
+ },
+]
+
+
+def get_example_runner(slug: str) -> Callable[[], ExampleResult] | None:
+ """Get the runner function for an example by slug."""
+ for entry in example_registry:
+ if entry["slug"] == slug:
+ return cast(Callable[[], ExampleResult], entry["run"])
+ return None
diff --git a/llms.txt b/llms.txt
new file mode 100644
index 000000000..ed9c684a8
--- /dev/null
+++ b/llms.txt
@@ -0,0 +1,60 @@
+# Runloop Python SDK
+
+> Python client for Runloop.ai - create cloud devboxes, execute commands, manage blueprints and snapshots. Additional platform documentation is available at [docs.runloop.ai](https://docs.runloop.ai) and [docs.runloop.ai/llms.txt](https://docs.runloop.ai/llms.txt).
+
+## Quick Start
+
+- [README.md](README.md): Installation, authentication, and quickstart example
+- [README-SDK.md](README-SDK.md): Object-oriented SDK documentation
+- [EXAMPLES.md](EXAMPLES.md): Consolidated workflow recipes
+
+## Core Patterns
+
+- [Devbox lifecycle example](examples/devbox_from_blueprint_lifecycle.py): Create blueprint, launch devbox, run commands, cleanup
+- [Devbox snapshot and resume example](examples/devbox_snapshot_resume.py): Snapshot disk, resume from snapshot, verify state isolation
+- [MCP GitHub example](examples/mcp_github_tools.py): MCP Hub integration with Claude Code
+
+## API Reference
+
+- [SDK entry point](src/runloop_api_client/__init__.py): `AsyncRunloopSDK` and `RunloopSDK` classes
+- [Type definitions](src/runloop_api_client/types/): Pydantic types for all resources
+
+## Key Guidance
+
+- **Prefer `AsyncRunloopSDK` over `RunloopSDK`** for better concurrency and performance; all SDK methods have async equivalents
+- Use `async with await runloop.devbox.create()` for automatic cleanup via context manager
+- For resources without SDK coverage (e.g., secrets, benchmarks), use `runloop.api.*` as a fallback
+- Use `await devbox.cmd.exec('command')` for commands expected to return immediately (e.g., `echo`, `pwd`, `cat`)—blocks until completion, returns `ExecutionResult` with stdout/stderr
+- Use `await devbox.cmd.exec_async('command')` for long-running or background processes (servers, watchers, builds)—returns immediately with `Execution` handle to check status, get result, or kill
+- Both `exec` and `exec_async` support streaming callbacks (`stdout`, `stderr`, `output`) for real-time output
+- Call `await devbox.shutdown()` to clean up resources that are no longer in use.
+- Streaming callbacks (`stdout`, `stderr`, `output`) must be synchronous functions even with async SDK
+- In example files, focus on the `recipe` function body for SDK usage patterns; ignore test harness files (`_harness.py`, `registry.py`, `example_types.py`)
+
+## Async vs Sync
+
+The SDK provides both sync and async variants. **The async SDK (`AsyncRunloopSDK`) is recommended** because:
+
+- Better resource utilization when running multiple devbox operations
+- Non-blocking I/O for long-running commands
+- Native async/await support integrates cleanly with modern Python frameworks
+
+Sync SDK example:
+```python
+from runloop_api_client import RunloopSDK
+runloop = RunloopSDK()
+with runloop.devbox.create(name="my-devbox") as devbox:
+ result = devbox.cmd.exec("echo hello")
+```
+
+Async SDK example (preferred):
+```python
+from runloop_api_client import AsyncRunloopSDK
+runloop = AsyncRunloopSDK()
+async with await runloop.devbox.create(name="my-devbox") as devbox:
+ result = await devbox.cmd.exec("echo hello")
+```
+
+## Optional
+
+- [External docs](https://docs.runloop.ai/llms.txt): Additional agent guidance from Runloop platform documentation
diff --git a/pyproject.toml b/pyproject.toml
index dfd204508..6b7225899 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "runloop_api_client"
-version = "1.10.3"
+version = "1.11.0"
description = "The official Python library for the runloop API"
dynamic = ["readme"]
license = "MIT"
@@ -66,6 +66,7 @@ dev = [
"pytest-xdist>=3.6.1",
"uuid-utils>=0.11.0",
"pytest-cov>=7.0.0",
+ "python-frontmatter>=1.0.0",
]
docs = [
"furo>=2025.9.25",
diff --git a/scripts/generate_examples_md.py b/scripts/generate_examples_md.py
new file mode 100644
index 000000000..bf4dc587a
--- /dev/null
+++ b/scripts/generate_examples_md.py
@@ -0,0 +1,263 @@
+#!/usr/bin/env python
+"""Generate EXAMPLES.md and examples/registry.py from example file frontmatter.
+
+Usage:
+ uv run python scripts/generate_examples_md.py # Generate files
+ uv run python scripts/generate_examples_md.py --check # Check if files are up to date
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+import argparse
+from typing import Any
+from pathlib import Path
+
+import frontmatter # type: ignore[import-untyped]
+
+ROOT = Path(__file__).parent.parent
+EXAMPLES_DIR = ROOT / "examples"
+OUTPUT_FILE = ROOT / "EXAMPLES.md"
+OUTPUT_REGISTRY_FILE = EXAMPLES_DIR / "registry.py"
+LLMS_FILE = ROOT / "llms.txt"
+
+REQUIRED_FIELDS = ["title", "slug", "use_case", "workflow", "tags", "prerequisites", "run", "test"]
+EXCLUDED_FILES = {"_harness.py", "example_types.py", "registry.py", "__init__.py"}
+
+
+def parse_example(path: Path) -> dict[str, Any]:
+ """Parse frontmatter from a Python file's docstring."""
+ content = path.read_text()
+ match = re.search(r'^(?:#!.*\n)?(?:\s*\n)*"""([\s\S]*?)"""', content)
+ if not match:
+ raise ValueError(f"{path}: missing docstring")
+
+ docstring = match.group(1).strip()
+ if not docstring.startswith("---"):
+ raise ValueError(f"{path}: docstring must start with frontmatter (---)")
+
+ try:
+ post = frontmatter.loads(docstring)
+ return dict(post.metadata)
+ except Exception as e:
+ raise ValueError(f"{path}: invalid frontmatter: {e}") from e
+
+
+def validate_example(metadata: dict[str, Any], file_name: str, seen_slugs: set[str]) -> None:
+ """Validate all example metadata in one pass."""
+ path = f"examples/{file_name}"
+
+ missing = [f for f in REQUIRED_FIELDS if f not in metadata]
+ if missing:
+ raise ValueError(f"{path}: missing fields: {', '.join(missing)}")
+
+ for field in ("workflow", "tags", "prerequisites"):
+ if not isinstance(metadata[field], list) or not metadata[field]:
+ raise ValueError(f"{path}: '{field}' must be a non-empty list")
+
+ slug = metadata["slug"]
+ expected_slug = file_name.replace(".py", "").replace("_", "-")
+ if slug != expected_slug:
+ raise ValueError(f"{path}: slug '{slug}' must match '{expected_slug}'")
+ if slug in seen_slugs:
+ raise ValueError(f"{path}: duplicate slug")
+ seen_slugs.add(slug)
+
+ module_name = file_name.replace(".py", "")
+ if f"examples.{module_name}" not in metadata["run"]:
+ raise ValueError(f"{path}: run command must reference 'examples.{module_name}'")
+
+
+def ensure_llms_references(examples: list[dict[str, Any]]) -> None:
+ """Ensure llms.txt references at least one example file."""
+ if not LLMS_FILE.exists():
+ raise ValueError(f"Missing llms file: {LLMS_FILE.relative_to(ROOT)}")
+
+ llms_text = LLMS_FILE.read_text()
+ referenced = set(re.findall(r"examples/([a-z0-9_]+\.py)", llms_text))
+
+ if not referenced:
+ raise ValueError(f"{LLMS_FILE.relative_to(ROOT)}: expected at least one reference to examples/*.py")
+
+ generated = {e["file_name"] for e in examples}
+ for file_name in referenced:
+ if file_name not in generated:
+ raise ValueError(f"{LLMS_FILE.relative_to(ROOT)}: references unknown example file 'examples/{file_name}'")
+
+
+def normalize_env_var(prerequisite: str) -> str | None:
+ """Extract environment variable name from prerequisite string."""
+ match = re.match(r"^[A-Z0-9_]+", prerequisite)
+ return match.group(0) if match else None
+
+
+def markdown_for_example(example: dict[str, Any]) -> str:
+ """Generate markdown section for a single example."""
+ lines = [
+ f'',
+ f"## {example['title']}",
+ "",
+ f"**Use case:** {example['use_case']}",
+ "",
+ f"**Tags:** {', '.join(f'`{tag}`' for tag in example['tags'])}",
+ "",
+ "### Workflow",
+ *[f"- {step}" for step in example["workflow"]],
+ "",
+ "### Prerequisites",
+ *[f"- `{item}`" for item in example["prerequisites"]],
+ "",
+ "### Run",
+ "```sh",
+ example["run"],
+ "```",
+ "",
+ "### Test",
+ "```sh",
+ example["test"],
+ "```",
+ "",
+ f"**Source:** [`examples/{example['file_name']}`](./examples/{example['file_name']})",
+ "",
+ ]
+ return "\n".join(lines)
+
+
+def generate_markdown(examples: list[dict[str, Any]]) -> str:
+ """Generate the full EXAMPLES.md content."""
+ toc = "\n".join(f"- [{e['title']}](#{e['slug']})" for e in examples)
+ sections = "\n".join(markdown_for_example(e) for e in examples)
+
+ return "\n".join(
+ [
+ "# Examples",
+ "",
+ "> This file is auto-generated from metadata in `examples/*.py`.",
+ "> Do not edit this file manually. Run `uv run python scripts/generate_examples_md.py` instead.",
+ "",
+ "Runnable examples live in [`examples/`](./examples).",
+ "",
+ "## Table of Contents",
+ "",
+ toc,
+ "",
+ sections.rstrip(),
+ "",
+ ]
+ )
+
+
+def generate_registry(examples: list[dict[str, Any]]) -> str:
+ """Generate the registry.py content."""
+ imports: list[str] = []
+ 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)
+
+ entries: list[str] = []
+ for example in examples:
+ module = example["file_name"].replace(".py", "")
+ runner = f"run_{module}_example"
+ env_vars = [normalize_env_var(p) for p in example["prerequisites"]]
+ env_list = ", ".join(f'"{e}"' for e in env_vars if e)
+ title = example["title"].replace('"', '\\"')
+ entries.append(f''' {{
+ "slug": "{example["slug"]}",
+ "title": "{title}",
+ "file_name": "{example["file_name"]}",
+ "required_env": [{env_list}],
+ "run": {runner},
+ }},''')
+
+ return f'''"""
+This file is auto-generated by scripts/generate_examples_md.py.
+Do not edit manually.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Callable, cast
+
+from .example_types import ExampleResult
+{chr(10).join(imports)}
+
+ExampleRegistryEntry = dict[str, Any]
+
+example_registry: list[ExampleRegistryEntry] = [
+{chr(10).join(entries)}
+]
+
+
+def get_example_runner(slug: str) -> Callable[[], ExampleResult] | None:
+ """Get the runner function for an example by slug."""
+ for entry in example_registry:
+ if entry["slug"] == slug:
+ return cast(Callable[[], ExampleResult], entry["run"])
+ return None
+'''
+
+
+def main() -> int:
+ """Main entry point."""
+ parser = argparse.ArgumentParser(description="Generate EXAMPLES.md and registry.py")
+ parser.add_argument("--check", action="store_true", help="Check if files are up to date")
+ args = parser.parse_args()
+
+ seen_slugs: set[str] = set()
+ examples: list[dict[str, Any]] = []
+
+ for path in sorted(EXAMPLES_DIR.glob("*.py")):
+ if path.name in EXCLUDED_FILES or path.name.startswith("_"):
+ continue
+ try:
+ metadata = parse_example(path)
+ validate_example(metadata, path.name, seen_slugs)
+ examples.append({**metadata, "file_name": path.name})
+ except ValueError as e:
+ print(f"Error: {e}", file=sys.stderr)
+ return 1
+
+ examples.sort(key=lambda e: e["title"])
+
+ try:
+ ensure_llms_references(examples)
+ except ValueError as e:
+ print(f"Error: {e}", file=sys.stderr)
+ return 1
+
+ markdown = generate_markdown(examples)
+ registry_source = generate_registry(examples)
+
+ if args.check:
+ errors: list[str] = []
+ if not OUTPUT_FILE.exists():
+ errors.append(f"{OUTPUT_FILE.relative_to(ROOT)} does not exist")
+ elif OUTPUT_FILE.read_text() != markdown:
+ errors.append(f"{OUTPUT_FILE.relative_to(ROOT)} is out of date")
+
+ if not OUTPUT_REGISTRY_FILE.exists():
+ errors.append(f"{OUTPUT_REGISTRY_FILE.relative_to(ROOT)} does not exist")
+ elif OUTPUT_REGISTRY_FILE.read_text() != registry_source:
+ errors.append(f"{OUTPUT_REGISTRY_FILE.relative_to(ROOT)} is out of date")
+
+ if errors:
+ for err in errors:
+ print(f"Error: {err}", file=sys.stderr)
+ print("\nRun `uv run python scripts/generate_examples_md.py` to regenerate.", file=sys.stderr)
+ return 1
+
+ print("All generated files are up to date.")
+ return 0
+
+ OUTPUT_FILE.write_text(markdown)
+ OUTPUT_REGISTRY_FILE.write_text(registry_source)
+ print(f"Wrote {OUTPUT_FILE.relative_to(ROOT)} from {len(examples)} example(s)")
+ print(f"Wrote {OUTPUT_REGISTRY_FILE.relative_to(ROOT)} from {len(examples)} example(s)")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/install-hooks b/scripts/install-hooks
new file mode 100755
index 000000000..43261db61
--- /dev/null
+++ b/scripts/install-hooks
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+
+set -e
+
+cd "$(dirname "$0")/.."
+
+echo "==> Installing git hooks..."
+
+mkdir -p .git/hooks
+
+cat > .git/hooks/pre-push << 'EOF'
+#!/usr/bin/env bash
+set -e
+cd "$(git rev-parse --show-toplevel)"
+
+echo "==> Running lint checks..."
+./scripts/lint
+
+echo "==> Checking EXAMPLES.md is up to date..."
+uv run python scripts/generate_examples_md.py --check
+
+echo "==> All checks passed!"
+EOF
+
+chmod +x .git/hooks/pre-push
+
+echo "==> Git hooks installed successfully!"
diff --git a/scripts/mock b/scripts/mock
index 0b28f6ea2..bcf3b392b 100755
--- a/scripts/mock
+++ b/scripts/mock
@@ -21,11 +21,22 @@ echo "==> Starting mock server with URL ${URL}"
# Run prism mock on the given spec
if [ "$1" == "--daemon" ]; then
+ # Pre-install the package so the download doesn't eat into the startup timeout
+ npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism --version
+
npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log &
- # Wait for server to come online
+ # Wait for server to come online (max 30s)
echo -n "Waiting for server"
+ attempts=0
while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do
+ attempts=$((attempts + 1))
+ if [ "$attempts" -ge 300 ]; then
+ echo
+ echo "Timed out waiting for Prism server to start"
+ cat .prism.log
+ exit 1
+ fi
echo -n "."
sleep 0.1
done
diff --git a/src/runloop_api_client/_version.py b/src/runloop_api_client/_version.py
index 8592b7e83..93214b609 100644
--- a/src/runloop_api_client/_version.py
+++ b/src/runloop_api_client/_version.py
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
__title__ = "runloop_api_client"
-__version__ = "1.10.3" # x-release-please-version
+__version__ = "1.11.0" # x-release-please-version
diff --git a/src/runloop_api_client/resources/devboxes/devboxes.py b/src/runloop_api_client/resources/devboxes/devboxes.py
index de809b855..dbd02be20 100644
--- a/src/runloop_api_client/resources/devboxes/devboxes.py
+++ b/src/runloop_api_client/resources/devboxes/devboxes.py
@@ -867,7 +867,7 @@ def execute(
id: str,
*,
command: str,
- command_id: str = str(uuid7()),
+ command_id: str | None = None,
last_n: str | Omit = omit,
optimistic_timeout: Optional[int] | Omit = omit,
shell_name: Optional[str] | Omit = omit,
@@ -892,7 +892,8 @@ def execute(
specified the command is run from the directory based on the recent state of the
persistent shell.
- command_id: The command ID in UUIDv7 string format for idempotency and tracking
+ command_id: The command ID in UUIDv7 string format for idempotency and tracking.
+ A fresh UUID is generated per call if not provided.
last_n: Last n lines of standard error / standard out to return (default: 100)
@@ -915,6 +916,8 @@ def execute(
"""
if not id:
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
+ if command_id is None:
+ command_id = str(uuid7())
if not is_given(timeout) and self._client.timeout == DEFAULT_TIMEOUT:
timeout = 600
return self._post(
@@ -944,7 +947,7 @@ def execute_and_await_completion(
devbox_id: str,
*,
command: str,
- command_id: str = str(uuid7()),
+ command_id: str | None = None,
last_n: str | Omit = omit,
optimistic_timeout: Optional[int] | Omit = omit,
shell_name: Optional[str] | Omit = omit,
@@ -963,9 +966,11 @@ def execute_and_await_completion(
return the result within the initial request's timeout. If the execution is not yet
complete, it switches to using wait_for_command to minimize latency while waiting.
- A command_id (UUIDv7) is automatically generated for idempotency and tracking.
+ A command_id (UUIDv7) is automatically generated per call for idempotency and tracking.
You can provide your own command_id to enable custom retry logic or external tracking.
"""
+ if command_id is None:
+ command_id = str(uuid7())
execution = self.execute(
devbox_id,
command=command,
@@ -2543,7 +2548,7 @@ async def execute(
id: str,
*,
command: str,
- command_id: str = str(uuid7()),
+ command_id: str | None = None,
last_n: str | Omit = omit,
optimistic_timeout: Optional[int] | Omit = omit,
shell_name: Optional[str] | Omit = omit,
@@ -2568,7 +2573,8 @@ async def execute(
specified the command is run from the directory based on the recent state of the
persistent shell.
- command_id: The command ID in UUIDv7 string format for idempotency and tracking
+ command_id: The command ID in UUIDv7 string format for idempotency and tracking.
+ A fresh UUID is generated per call if not provided.
last_n: Last n lines of standard error / standard out to return (default: 100)
@@ -2591,6 +2597,8 @@ async def execute(
"""
if not id:
raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
+ if command_id is None:
+ command_id = str(uuid7())
if not is_given(timeout) and self._client.timeout == DEFAULT_TIMEOUT:
timeout = 600
return await self._post(
@@ -2620,7 +2628,7 @@ async def execute_and_await_completion(
devbox_id: str,
*,
command: str,
- command_id: str = str(uuid7()),
+ command_id: str | None = None,
last_n: str | Omit = omit,
optimistic_timeout: Optional[int] | Omit = omit,
shell_name: Optional[str] | Omit = omit,
@@ -2639,7 +2647,7 @@ async def execute_and_await_completion(
return the result within the initial request's timeout. If the execution is not yet
complete, it switches to using wait_for_command to minimize latency while waiting.
- A command_id (UUIDv7) is automatically generated for idempotency and tracking.
+ A command_id (UUIDv7) is automatically generated per call for idempotency and tracking.
You can provide your own command_id to enable custom retry logic or external tracking.
"""
diff --git a/src/runloop_api_client/sdk/async_devbox.py b/src/runloop_api_client/sdk/async_devbox.py
index 81d602eb3..e6c0ac51e 100644
--- a/src/runloop_api_client/sdk/async_devbox.py
+++ b/src/runloop_api_client/sdk/async_devbox.py
@@ -33,6 +33,7 @@
SDKDevboxSnapshotDiskAsyncParams,
SDKDevboxWriteFileContentsParams,
)
+from .._types import omit
from .._client import AsyncRunloop
from ._helpers import filter_params
from .._streaming import AsyncStream
@@ -41,6 +42,7 @@
from .async_execution import AsyncExecution, _AsyncStreamingGroup
from .async_execution_result import AsyncExecutionResult
from ..types.devbox_execute_async_params import DevboxNiceExecuteAsyncParams
+from ..types.devboxes.devbox_logs_list_view import DevboxLogsListView
from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView
StreamFactory = Callable[[], Awaitable[AsyncStream[ExecutionUpdateChunk]]]
@@ -163,6 +165,38 @@ async def get_tunnel_url(
return None
return f"https://{port}-{tunnel_view.tunnel_key}.tunnel.runloop.ai"
+ async def logs(
+ self,
+ *,
+ execution_id: str | None = None,
+ shell_name: str | None = None,
+ **options: Unpack[BaseRequestOptions],
+ ) -> DevboxLogsListView:
+ """Retrieve logs for the devbox.
+
+ Returns all logs from a running or completed devbox. Optionally filter
+ by execution ID or shell name.
+
+ :param execution_id: Filter logs by execution ID, defaults to None
+ :type execution_id: str | None, optional
+ :param shell_name: Filter logs by shell name, defaults to None
+ :type shell_name: str | None, optional
+ :param options: Optional request configuration
+ :return: Log entries for the devbox
+ :rtype: :class:`~runloop_api_client.types.devboxes.devbox_logs_list_view.DevboxLogsListView`
+
+ Example:
+ >>> logs = await devbox.logs()
+ >>> for log in logs.logs:
+ ... print(f"[{log.level}] {log.message}")
+ """
+ return await self._client.devboxes.logs.list(
+ self._id,
+ execution_id=execution_id if execution_id is not None else omit,
+ shell_name=shell_name if shell_name is not None else omit,
+ **options,
+ )
+
async def await_running(self, *, polling_config: PollingConfig | None = None) -> DevboxView:
"""Wait for the devbox to reach running state.
diff --git a/src/runloop_api_client/sdk/devbox.py b/src/runloop_api_client/sdk/devbox.py
index ff541ce92..5718e7392 100644
--- a/src/runloop_api_client/sdk/devbox.py
+++ b/src/runloop_api_client/sdk/devbox.py
@@ -34,6 +34,7 @@
SDKDevboxSnapshotDiskAsyncParams,
SDKDevboxWriteFileContentsParams,
)
+from .._types import omit
from .._client import Runloop
from ._helpers import filter_params
from .execution import Execution, _StreamingGroup
@@ -42,6 +43,7 @@
from ..types.devboxes import ExecutionUpdateChunk
from .execution_result import ExecutionResult
from ..types.devbox_execute_async_params import DevboxNiceExecuteAsyncParams
+from ..types.devboxes.devbox_logs_list_view import DevboxLogsListView
from ..types.devbox_async_execution_detail_view import DevboxAsyncExecutionDetailView
if TYPE_CHECKING:
@@ -162,6 +164,38 @@ def get_tunnel_url(
return None
return f"https://{port}-{tunnel_view.tunnel_key}.tunnel.runloop.ai"
+ def logs(
+ self,
+ *,
+ execution_id: str | None = None,
+ shell_name: str | None = None,
+ **options: Unpack[BaseRequestOptions],
+ ) -> DevboxLogsListView:
+ """Retrieve logs for the devbox.
+
+ Returns all logs from a running or completed devbox. Optionally filter
+ by execution ID or shell name.
+
+ :param execution_id: Filter logs by execution ID, defaults to None
+ :type execution_id: str | None, optional
+ :param shell_name: Filter logs by shell name, defaults to None
+ :type shell_name: str | None, optional
+ :param options: Optional request configuration
+ :return: Log entries for the devbox
+ :rtype: :class:`~runloop_api_client.types.devboxes.devbox_logs_list_view.DevboxLogsListView`
+
+ Example:
+ >>> logs = devbox.logs()
+ >>> for log in logs.logs:
+ ... print(f"[{log.level}] {log.message}")
+ """
+ return self._client.devboxes.logs.list(
+ self._id,
+ execution_id=execution_id if execution_id is not None else omit,
+ shell_name=shell_name if shell_name is not None else omit,
+ **options,
+ )
+
def await_running(self, *, polling_config: PollingConfig | None = None) -> DevboxView:
"""Wait for the devbox to reach running state.
diff --git a/tests/api_resources/test_devboxes.py b/tests/api_resources/test_devboxes.py
index 7f83732a0..1e25ced15 100644
--- a/tests/api_resources/test_devboxes.py
+++ b/tests/api_resources/test_devboxes.py
@@ -1087,7 +1087,7 @@ def test_method_upload_file_with_all_params(self, client: Runloop) -> None:
devbox = client.devboxes.upload_file(
id="id",
path="path",
- file=b"raw file contents",
+ file=b"Example data",
)
assert_matches_type(object, devbox, path=["response"])
@@ -2755,7 +2755,7 @@ async def test_method_upload_file_with_all_params(self, async_client: AsyncRunlo
devbox = await async_client.devboxes.upload_file(
id="id",
path="path",
- file=b"raw file contents",
+ file=b"Example data",
)
assert_matches_type(object, devbox, path=["response"])
diff --git a/tests/smoketests/examples/__init__.py b/tests/smoketests/examples/__init__.py
new file mode 100644
index 000000000..c0b52f6e9
--- /dev/null
+++ b/tests/smoketests/examples/__init__.py
@@ -0,0 +1 @@
+# Smoketests for examples
diff --git a/tests/smoketests/examples/test_examples.py b/tests/smoketests/examples/test_examples.py
new file mode 100644
index 000000000..7dbe82581
--- /dev/null
+++ b/tests/smoketests/examples/test_examples.py
@@ -0,0 +1,107 @@
+"""Smoketests for SDK examples.
+
+These tests run the example scripts against the live API.
+Set RUN_EXAMPLE_LIVE_TESTS=1 to enable live tests.
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from typing import Any
+from pathlib import Path
+
+import pytest
+
+# Add the root directory to the path so we can import examples
+sys.path.insert(0, str(Path(__file__).parents[3]))
+
+from examples.registry import example_registry # noqa: E402
+from examples.example_types import ExampleResult # noqa: E402
+from examples.mcp_github_tools import McpExampleOptions, run_mcp_github_tools_example # noqa: E402
+
+LONG_TIMEOUT = 600 # 10 minutes for live tests
+SHORT_TIMEOUT = 30 # 30 seconds for skip mode tests
+
+
+class TestExamples:
+ """Tests for SDK examples."""
+
+ @pytest.mark.smoketest
+ @pytest.mark.timeout(LONG_TIMEOUT)
+ @pytest.mark.parametrize("entry", example_registry, ids=lambda e: e["slug"])
+ def test_example_runs_with_successful_checks(self, entry: dict[str, Any]) -> None:
+ """Test that examples run successfully with all checks passing."""
+ if not os.environ.get("RUN_EXAMPLE_LIVE_TESTS"):
+ pytest.skip("RUN_EXAMPLE_LIVE_TESTS not set")
+
+ required_env: list[str] = entry["required_env"]
+ missing_env = [e for e in required_env if not os.environ.get(e)]
+ if missing_env:
+ pytest.skip(f"Missing env vars: {missing_env}")
+
+ # Handle examples that need options
+ result: ExampleResult
+ if entry["slug"] == "mcp-github-tools":
+ result = run_mcp_github_tools_example(McpExampleOptions())
+ else:
+ result = entry["run"]()
+
+ assert not result.skipped, "Example was unexpectedly skipped"
+ assert len(result.resources_created) > 0, "No resources were created"
+ assert len(result.checks) > 0, "No checks were performed"
+
+ failed_checks = [c for c in result.checks if not c.passed]
+ assert not failed_checks, f"Failed checks: {[c.name for c in failed_checks]}"
+
+ assert len(result.cleanup_status.failed) == 0, (
+ f"Cleanup failures: {[f.resource for f in result.cleanup_status.failed]}"
+ )
+
+ @pytest.mark.timeout(SHORT_TIMEOUT)
+ def test_mcp_skip_mode_for_missing_credentials(self) -> None:
+ """Test that mcp-github-tools example skips deterministically when credentials are missing."""
+ # Save original env vars
+ original_github_token = os.environ.get("GITHUB_TOKEN")
+ original_anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
+
+ # Remove credentials
+ if "GITHUB_TOKEN" in os.environ:
+ del os.environ["GITHUB_TOKEN"]
+ if "ANTHROPIC_API_KEY" in os.environ:
+ del os.environ["ANTHROPIC_API_KEY"]
+
+ try:
+ result = run_mcp_github_tools_example(McpExampleOptions(skip_if_missing_credentials=True))
+
+ assert result.skipped, "Example should be skipped when credentials are missing"
+ assert len(result.resources_created) == 0, "No resources should be created when skipped"
+ assert len(result.cleanup_status.attempted) == 0, "No cleanup should be attempted when skipped"
+ finally:
+ # Restore original env vars
+ if original_github_token is not None:
+ os.environ["GITHUB_TOKEN"] = original_github_token
+ elif "GITHUB_TOKEN" in os.environ:
+ del os.environ["GITHUB_TOKEN"]
+
+ if original_anthropic_key is not None:
+ os.environ["ANTHROPIC_API_KEY"] = original_anthropic_key
+ elif "ANTHROPIC_API_KEY" in os.environ:
+ del os.environ["ANTHROPIC_API_KEY"]
+
+ @pytest.mark.timeout(SHORT_TIMEOUT)
+ def test_example_registry_is_populated(self) -> None:
+ """Test that the example registry contains expected entries."""
+ assert len(example_registry) >= 2, "Expected at least 2 examples in registry"
+
+ slugs = {e["slug"] for e in example_registry}
+ assert "devbox-from-blueprint-lifecycle" in slugs
+ assert "mcp-github-tools" in slugs
+
+ for entry in example_registry:
+ assert "slug" in entry
+ assert "title" in entry
+ assert "file_name" in entry
+ assert "required_env" in entry
+ assert "run" in entry
+ assert callable(entry["run"])
diff --git a/tests/smoketests/sdk/test_async_devbox.py b/tests/smoketests/sdk/test_async_devbox.py
index 860502cd7..e21c18705 100644
--- a/tests/smoketests/sdk/test_async_devbox.py
+++ b/tests/smoketests/sdk/test_async_devbox.py
@@ -438,15 +438,20 @@ async def test_create_ssh_key(self, async_sdk_client: AsyncRunloopSDK) -> None:
await devbox.shutdown()
@pytest.mark.timeout(TWO_MINUTE_TIMEOUT)
- async def test_create_and_remove_tunnel(self, async_sdk_client: AsyncRunloopSDK) -> None:
- """Test creating and removing a tunnel."""
+ async def test_create_tunnel_deprecated(self, async_sdk_client: AsyncRunloopSDK) -> None:
+ """Test creating a tunnel (deprecated - now creates v2 tunnel).
+
+ Note: The deprecated create_tunnel endpoint now creates v2 Portal tunnels
+ which cannot be removed. They remain active until the devbox is stopped.
+ Use enable_tunnel for creating v2 tunnels instead.
+ """
devbox = await async_sdk_client.devbox.create(
name=unique_name("sdk-async-devbox-tunnel"),
launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5},
)
try:
- # Create tunnel
+ # Create tunnel (now creates v2 Portal tunnel)
with pytest.warns(DeprecationWarning, match="create_tunnel is deprecated"):
tunnel = await devbox.net.create_tunnel(port=8080)
assert tunnel is not None
@@ -454,9 +459,10 @@ async def test_create_and_remove_tunnel(self, async_sdk_client: AsyncRunloopSDK)
assert tunnel.port == 8080
assert tunnel.devbox_id == devbox.id
- # Remove tunnel
- with pytest.warns(DeprecationWarning, match="remove_tunnel is deprecated"):
- await devbox.net.remove_tunnel(port=8080)
+ # Verify tunnel persists in devbox info (v2 tunnels cannot be removed)
+ info = await devbox.get_info()
+ assert info.tunnel is not None
+ assert info.tunnel.tunnel_key is not None
finally:
await devbox.shutdown()
@@ -1058,3 +1064,55 @@ async def test_shell_exec_async_with_both_streams(self, devbox: AsyncDevbox) ->
# Verify streaming captured same data as result
assert stdout_combined == await result.stdout()
assert stderr_combined == await result.stderr()
+
+
+class TestAsyncDevboxLogs:
+ """Test async devbox logs retrieval functionality."""
+
+ @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
+ async def test_logs_basic(self, shared_devbox: AsyncDevbox) -> None:
+ """Test retrieving devbox logs returns valid response structure."""
+ test_message = "async basic log test message"
+ result = await shared_devbox.cmd.exec(f'echo "{test_message}"')
+ assert result.exit_code == 0
+
+ logs = await shared_devbox.logs()
+
+ assert logs is not None
+ assert hasattr(logs, "logs")
+ assert isinstance(logs.logs, list)
+ log_content = " ".join(str(log) for log in logs.logs)
+ assert test_message in log_content
+
+ @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
+ async def test_logs_with_execution_filter(self, shared_devbox: AsyncDevbox) -> None:
+ """Test retrieving devbox logs filtered by execution ID."""
+ test_message = "async filtered log test"
+ result = await shared_devbox.cmd.exec(f'echo "{test_message}"')
+ assert result.exit_code == 0
+
+ logs = await shared_devbox.logs(execution_id=result.execution_id)
+
+ assert logs is not None
+ assert hasattr(logs, "logs")
+ assert isinstance(logs.logs, list)
+ log_content = " ".join(str(log) for log in logs.logs)
+ assert test_message in log_content
+
+ @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
+ async def test_logs_with_shell_name_filter(self, shared_devbox: AsyncDevbox) -> None:
+ """Test retrieving devbox logs filtered by shell name."""
+ shell_name = "async-test-logs-shell"
+ shell = shared_devbox.shell(shell_name)
+
+ test_message = "async shell log test"
+ result = await shell.exec(f'echo "{test_message}"')
+ assert result.exit_code == 0
+
+ logs = await shared_devbox.logs(shell_name=shell_name)
+
+ assert logs is not None
+ assert hasattr(logs, "logs")
+ assert isinstance(logs.logs, list)
+ log_content = " ".join(str(log) for log in logs.logs)
+ assert test_message in log_content
diff --git a/tests/smoketests/sdk/test_devbox.py b/tests/smoketests/sdk/test_devbox.py
index 196fb312e..e145b9b91 100644
--- a/tests/smoketests/sdk/test_devbox.py
+++ b/tests/smoketests/sdk/test_devbox.py
@@ -435,15 +435,20 @@ def test_create_ssh_key(self, sdk_client: RunloopSDK) -> None:
devbox.shutdown()
@pytest.mark.timeout(TWO_MINUTE_TIMEOUT)
- def test_create_and_remove_tunnel(self, sdk_client: RunloopSDK) -> None:
- """Test creating and removing a tunnel."""
+ def test_create_tunnel_deprecated(self, sdk_client: RunloopSDK) -> None:
+ """Test creating a tunnel (deprecated - now creates v2 tunnel).
+
+ Note: The deprecated create_tunnel endpoint now creates v2 Portal tunnels
+ which cannot be removed. They remain active until the devbox is stopped.
+ Use enable_tunnel for creating v2 tunnels instead.
+ """
devbox = sdk_client.devbox.create(
name=unique_name("sdk-devbox-tunnel"),
launch_parameters={"resource_size_request": "SMALL", "keep_alive_time_seconds": 60 * 5},
)
try:
- # Create tunnel
+ # Create tunnel (now creates v2 Portal tunnel)
with pytest.warns(DeprecationWarning, match="create_tunnel is deprecated"):
tunnel = devbox.net.create_tunnel(port=8080)
assert tunnel is not None
@@ -451,9 +456,10 @@ def test_create_and_remove_tunnel(self, sdk_client: RunloopSDK) -> None:
assert tunnel.port == 8080
assert tunnel.devbox_id == devbox.id
- # Remove tunnel
- with pytest.warns(DeprecationWarning, match="remove_tunnel is deprecated"):
- devbox.net.remove_tunnel(port=8080)
+ # Verify tunnel persists in devbox info (v2 tunnels cannot be removed)
+ info = devbox.get_info()
+ assert info.tunnel is not None
+ assert info.tunnel.tunnel_key is not None
finally:
devbox.shutdown()
@@ -1044,3 +1050,55 @@ def test_shell_exec_async_with_both_streams(self, devbox: Devbox) -> None:
# Verify streaming captured same data as result
assert stdout_combined == result.stdout()
assert stderr_combined == result.stderr()
+
+
+class TestDevboxLogs:
+ """Test devbox logs retrieval functionality."""
+
+ @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
+ def test_logs_basic(self, shared_devbox: Devbox) -> None:
+ """Test retrieving devbox logs returns valid response structure."""
+ test_message = "basic log test message"
+ result = shared_devbox.cmd.exec(f'echo "{test_message}"')
+ assert result.exit_code == 0
+
+ logs = shared_devbox.logs()
+
+ assert logs is not None
+ assert hasattr(logs, "logs")
+ assert isinstance(logs.logs, list)
+ log_content = " ".join(str(log) for log in logs.logs)
+ assert test_message in log_content
+
+ @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
+ def test_logs_with_execution_filter(self, shared_devbox: Devbox) -> None:
+ """Test retrieving devbox logs filtered by execution ID."""
+ test_message = "filtered log test"
+ result = shared_devbox.cmd.exec(f'echo "{test_message}"')
+ assert result.exit_code == 0
+
+ logs = shared_devbox.logs(execution_id=result.execution_id)
+
+ assert logs is not None
+ assert hasattr(logs, "logs")
+ assert isinstance(logs.logs, list)
+ log_content = " ".join(str(log) for log in logs.logs)
+ assert test_message in log_content
+
+ @pytest.mark.timeout(THIRTY_SECOND_TIMEOUT)
+ def test_logs_with_shell_name_filter(self, shared_devbox: Devbox) -> None:
+ """Test retrieving devbox logs filtered by shell name."""
+ shell_name = "test-logs-shell"
+ shell = shared_devbox.shell(shell_name)
+
+ test_message = "shell log test"
+ result = shell.exec(f'echo "{test_message}"')
+ assert result.exit_code == 0
+
+ logs = shared_devbox.logs(shell_name=shell_name)
+
+ assert logs is not None
+ assert hasattr(logs, "logs")
+ assert isinstance(logs.logs, list)
+ log_content = " ".join(str(log) for log in logs.logs)
+ assert test_message in log_content
diff --git a/tests/test_command_id.py b/tests/test_command_id.py
new file mode 100644
index 000000000..92089d28a
--- /dev/null
+++ b/tests/test_command_id.py
@@ -0,0 +1,94 @@
+"""Tests for command_id default generation in execute / execute_and_await_completion.
+
+Verifies that each call generates a fresh UUIDv7 rather than reusing a frozen
+default (the bug fixed in this change).
+"""
+
+from __future__ import annotations
+
+import json
+from typing import cast
+
+import httpx
+import pytest
+from respx import Route, MockRouter
+
+from runloop_api_client import Runloop, AsyncRunloop
+
+base_url = "http://127.0.0.1:4010"
+EXECUTE_PATH = "/v1/devboxes/dbx_test/execute"
+
+STUB_RESPONSE = {
+ "execution_id": "exec_1",
+ "command_id": "ignored",
+ "devbox_id": "dbx_test",
+ "status": "completed",
+ "exit_status": 0,
+ "stdout": "",
+ "stderr": "",
+}
+
+
+def _get_command_ids(route: Route) -> list[str]:
+ return [
+ json.loads(cast(bytes, call.request.content))["command_id"] # type: ignore[union-attr]
+ for call in route.calls # type: ignore[union-attr]
+ ]
+
+
+def _get_request_body(route: Route, index: int = 0) -> dict[str, object]:
+ return json.loads(cast(bytes, route.calls[index].request.content)) # type: ignore[union-attr]
+
+
+class TestCommandIdGeneration:
+ """command_id must be a fresh UUIDv7 per call when not explicitly provided."""
+
+ @pytest.mark.respx(base_url=base_url)
+ def test_execute_generates_unique_command_ids(self, respx_mock: MockRouter) -> None:
+ route = respx_mock.post(EXECUTE_PATH).mock(return_value=httpx.Response(200, json=STUB_RESPONSE))
+ client = Runloop(base_url=base_url, bearer_token="test")
+
+ for _ in range(5):
+ client.devboxes.execute(id="dbx_test", command="echo hi")
+
+ assert route.call_count == 5
+ ids = _get_command_ids(route)
+ assert len(set(ids)) == 5, f"command_ids should all be unique, got: {ids}"
+
+ @pytest.mark.respx(base_url=base_url)
+ def test_execute_preserves_explicit_command_id(self, respx_mock: MockRouter) -> None:
+ route = respx_mock.post(EXECUTE_PATH).mock(return_value=httpx.Response(200, json=STUB_RESPONSE))
+ client = Runloop(base_url=base_url, bearer_token="test")
+
+ client.devboxes.execute(id="dbx_test", command="echo hi", command_id="my-custom-id")
+
+ body = _get_request_body(route)
+ assert body["command_id"] == "my-custom-id"
+
+
+class TestAsyncCommandIdGeneration:
+ """Async variant: command_id must be a fresh UUIDv7 per call when not explicitly provided."""
+
+ @pytest.mark.respx(base_url=base_url)
+ @pytest.mark.asyncio
+ async def test_execute_generates_unique_command_ids(self, respx_mock: MockRouter) -> None:
+ route = respx_mock.post(EXECUTE_PATH).mock(return_value=httpx.Response(200, json=STUB_RESPONSE))
+ client = AsyncRunloop(base_url=base_url, bearer_token="test")
+
+ for _ in range(5):
+ await client.devboxes.execute(id="dbx_test", command="echo hi")
+
+ assert route.call_count == 5
+ ids = _get_command_ids(route)
+ assert len(set(ids)) == 5, f"command_ids should all be unique, got: {ids}"
+
+ @pytest.mark.respx(base_url=base_url)
+ @pytest.mark.asyncio
+ async def test_execute_preserves_explicit_command_id(self, respx_mock: MockRouter) -> None:
+ route = respx_mock.post(EXECUTE_PATH).mock(return_value=httpx.Response(200, json=STUB_RESPONSE))
+ client = AsyncRunloop(base_url=base_url, bearer_token="test")
+
+ await client.devboxes.execute(id="dbx_test", command="echo hi", command_id="my-custom-id")
+
+ body = _get_request_body(route)
+ assert body["command_id"] == "my-custom-id"
diff --git a/uv.lock b/uv.lock
index 26c8d993a..4f0209e1f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2082,6 +2082,91 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
+[[package]]
+name = "python-frontmatter"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/96/de/910fa208120314a12f9a88ea63e03707261692af782c99283f1a2c8a5e6f/python-frontmatter-1.1.0.tar.gz", hash = "sha256:7118d2bd56af9149625745c58c9b51fb67e8d1294a0c76796dafdc72c36e5f6d", size = 16256, upload-time = "2024-01-16T18:50:04.052Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/87/3c8da047b3ec5f99511d1b4d7a5bc72d4b98751c7e78492d14dc736319c5/python_frontmatter-1.1.0-py3-none-any.whl", hash = "sha256:335465556358d9d0e6c98bbeb69b1c969f2a4a21360587b9873bfc3b213407c1", size = 9834, upload-time = "2024-01-16T18:50:00.911Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
+ { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
+ { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+ { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+ { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+ { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+ { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" },
+ { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" },
+ { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" },
+]
+
[[package]]
name = "requests"
version = "2.32.5"
@@ -2267,7 +2352,7 @@ wheels = [
[[package]]
name = "runloop-api-client"
-version = "1.9.0"
+version = "1.10.3"
source = { editable = "." }
dependencies = [
{ name = "anyio" },
@@ -2298,6 +2383,7 @@ dev = [
{ name = "pytest-cov" },
{ name = "pytest-timeout" },
{ name = "pytest-xdist" },
+ { name = "python-frontmatter" },
{ name = "respx" },
{ name = "rich" },
{ name = "ruff" },
@@ -2343,6 +2429,7 @@ dev = [
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "pytest-timeout" },
{ name = "pytest-xdist", specifier = ">=3.6.1" },
+ { name = "python-frontmatter", specifier = ">=1.0.0" },
{ name = "respx" },
{ name = "rich", specifier = ">=13.7.1" },
{ name = "ruff" },