Metadata-Version: 2.4
Name: lumina-sandbox
Version: 0.1.1
Summary: Python SDK for Lumina sandboxes — create, manage, and interact with isolated dev environments
License-Expression: MIT
Requires-Python: >=3.9
Requires-Dist: httpx>=0.24
Provides-Extra: agent
Requires-Dist: bu-agent-sdk>=0.0.1; extra == 'agent'
Description-Content-Type: text/markdown

# Lumina Python SDK

The `lumina_sandbox` Python package provides an async client for the Lumina tool endpoints, plus integration with the `bu-agent-sdk` for building AI coding agents.

## Table of Contents

- [Installation](#installation)
- [Quick Start](#quick-start)
- [LuminaToolClient](#luminatoolclient)
  - [Initialization](#initialization)
  - [Sandbox Lifecycle](#sandbox-lifecycle)
  - [File Tools](#file-tools)
  - [Search Tools](#search-tools)
  - [Bash Tools](#bash-tools)
  - [Error Handling](#error-handling)
- [Result Types](#result-types)
- [bu-agent-sdk Integration](#bu-agent-sdk-integration)
  - [Using make_tools()](#using-make_tools)
  - [Demo Agent](#demo-agent)
- [Interactive Chat CLI](#interactive-chat-cli)
- [Advanced Usage](#advanced-usage)
  - [Tool Sessions](#tool-sessions)
  - [Read-Before-Write Safety](#read-before-write-safety)
  - [Background Commands](#background-commands)
  - [Environment Persistence](#environment-persistence)
- [API Reference](#api-reference)

---

## Installation

The SDK requires Python 3.9+.

```bash
# Core SDK
pip install lumina-sandbox

# With agent integration (requires Python >= 3.11)
pip install lumina-sandbox[agent]
```

---

## Quick Start

```python
import asyncio
from lumina_sandbox import LuminaToolClient

async def main():
    async with LuminaToolClient(
        base_url="http://localhost:8080",
        token="your-api-key-here",
        lumina_name="my-sandbox",
    ) as client:
        # Run a command
        result = await client.bash("echo Hello from Lumina!")
        print(result.output)  # "Hello from Lumina!\n"

        # Read a file
        content = await client.read("/home/user/main.py")
        print(f"File has {content.total_lines} lines")

        # Search for Python files
        files = await client.glob("**/*.py")
        print(f"Found {files.count} Python files")

asyncio.run(main())
```

---

## LuminaToolClient

### Initialization

```python
from lumina_sandbox import LuminaToolClient

client = LuminaToolClient(
    base_url="http://localhost:8080",     # Lumina server URL
    token="a1b2c3d4...",                  # API key (64-char hex) or JWT
    lumina_name="my-sandbox",             # Sandbox name
    tool_session_id=None,                 # Auto-generated UUID if not provided
    timeout=660.0,                        # HTTP request timeout in seconds
)
```

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `base_url` | `str` | yes | | Lumina server URL |
| `token` | `str` | yes | | API key or JWT token |
| `lumina_name` | `str` | yes | | Target sandbox name |
| `tool_session_id` | `str` | no | auto UUID | Session ID for state tracking |
| `timeout` | `float` | no | `660.0` | HTTP timeout in seconds |

The client is an **async context manager**:

```python
# Recommended: auto-closes on exit
async with LuminaToolClient(...) as client:
    await client.bash("echo hello")

# Manual lifecycle
client = LuminaToolClient(...)
try:
    await client.bash("echo hello")
finally:
    await client.close()
```

### Sandbox Lifecycle

The client provides methods for the full sandbox lifecycle:

```python
# Create a sandbox and use tools
async with LuminaToolClient(
    base_url="http://localhost:8080",
    token="your-api-key",
    lumina_name="my-sandbox",
) as client:
    await client.create_sandbox()          # Create and bootstrap
    result = await client.bash("echo hi")  # Use tools
    await client.destroy_sandbox()         # Cleanup
```

Or use the convenience class method to create and connect in one call:

```python
async with await LuminaToolClient.create(
    base_url="http://localhost:8080",
    token="your-api-key",
    name="my-sandbox",
) as client:
    result = await client.bash("echo hi")
    await client.destroy_sandbox()
```

#### create_sandbox()

```python
async def create_sandbox(eager: bool = True) -> dict
```

Creates the sandbox on the server. If `eager=True` (default), immediately acquires a container and bootstraps the user environment so the first tool call has zero cold-start latency. Returns the server response dict with sandbox metadata and lease info.

#### list_sandboxes()

```python
async def list_sandboxes() -> list[dict]
```

Returns all sandboxes for the authenticated user.

#### restart_sandbox()

```python
async def restart_sandbox() -> dict
```

Restarts the sandbox container with the latest available image. Running processes are terminated but persistent storage is retained.

#### destroy_sandbox()

```python
async def destroy_sandbox() -> None
```

Destroys the sandbox and releases its container lease.

### File Tools

#### read()

Read a file from the sandbox. Automatically detects text files, images, and PDFs.

```python
async def read(
    file_path: str,
    offset: Optional[int] = None,    # Starting line (1-based)
    limit: Optional[int] = None,     # Max lines to return
) -> TextFileResult | ImageFileResult | PDFFileResult
```

**Examples:**

```python
# Read a text file
result = await client.read("/home/user/main.py")
print(result.content)        # "   1\timport os\n   2\t..."
print(result.total_lines)    # 150
print(result.lines_returned) # 150

# Read with pagination (lines 50-100)
result = await client.read("/home/user/big_file.py", offset=50, limit=50)
print(result.lines_returned)  # 50

# Read an image (returns base64)
result = await client.read("/home/user/screenshot.png")
print(result.mime_type)   # "image/png"
print(result.file_size)   # 24567
# result.image contains base64-encoded data

# Read a PDF
result = await client.read("/home/user/document.pdf")
for page in result.pages:
    print(f"Page {page.page_number}: {page.text[:100]}...")
```

#### write()

Write content to a file. Creates parent directories automatically.

```python
async def write(
    file_path: str,
    content: str,
) -> WriteResult
```

**Examples:**

```python
# Write a new file
result = await client.write("/home/user/hello.txt", "Hello, world!\n")
print(result.bytes_written)  # 14

# Write a Python script
result = await client.write("/home/user/script.py", """#!/usr/bin/env python3
import sys

def main():
    print("Hello from script!")
    return 0

if __name__ == "__main__":
    sys.exit(main())
""")

# Overwrite requires reading first (safety check)
await client.read("/home/user/hello.txt")  # Must read first!
result = await client.write("/home/user/hello.txt", "Updated content\n")
```

#### edit()

Edit a file using exact string replacement.

```python
async def edit(
    file_path: str,
    old_string: str,
    new_string: str,
    replace_all: bool = False,
) -> EditResult
```

**Examples:**

```python
# Read the file first (required)
await client.read("/home/user/main.py")

# Replace a single occurrence
result = await client.edit(
    "/home/user/main.py",
    old_string="def hello():",
    new_string="def hello(name: str = 'World'):",
)
print(result.replacements)  # 1

# Replace all occurrences
result = await client.edit(
    "/home/user/main.py",
    old_string="print",
    new_string="logging.info",
    replace_all=True,
)
print(result.replacements)  # 5

# If old_string appears multiple times and replace_all=False,
# the server returns a 400 error with the count. Use more context
# to make the match unique, or set replace_all=True.
```

### Search Tools

#### glob()

Search for files matching a glob pattern.

```python
async def glob(
    pattern: str,
    path: Optional[str] = None,    # Search directory (default: user home)
) -> GlobResult
```

**Examples:**

```python
# Find all Python files
result = await client.glob("**/*.py")
print(result.count)     # 12
print(result.matches)   # ["/home/user/main.py", "/home/user/utils.py", ...]

# Find in a specific directory
result = await client.glob("*.ts", path="/home/user/src")
for f in result.matches:
    print(f)

# Find test files
result = await client.glob("**/test_*.py", path="/home/user/project")
```

Results are sorted by modification time (newest first).

#### grep()

Search file contents using regular expressions (powered by ripgrep).

```python
async def grep(
    pattern: str,
    path: Optional[str] = None,
    glob: Optional[str] = None,           # File glob filter
    output_mode: str = "files_with_matches",
    line_numbers: bool = False,
    after: int = 0,                       # Context lines after match
    before: int = 0,                      # Context lines before match
    context: int = 0,                     # Context lines both sides
    file_type: Optional[str] = None,      # File type filter (e.g., "py")
    case_insensitive: bool = False,
    multiline: bool = False,
) -> GrepContentResult | GrepFilesResult | GrepCountResult
```

**Examples:**

```python
# Find files containing "TODO"
result = await client.grep("TODO")
print(result.count)  # 3
print(result.files)  # ["/home/user/main.py", ...]

# Search with context lines (content mode)
result = await client.grep(
    "def main",
    path="/home/user/project",
    output_mode="content",
    line_numbers=True,
    after=5,
)
for match in result.matches:
    print(f"{match.file}:{match.line_number}")
    print(f"  {match.line}")
    for ctx in match.after_context:
        print(f"  {ctx}")

# Count occurrences per file
result = await client.grep("import", output_mode="count", file_type="py")
for entry in result.counts:
    print(f"{entry.file}: {entry.count} imports")
print(f"Total: {result.total}")

# Case-insensitive search in specific file types
result = await client.grep(
    "error|warning",
    output_mode="content",
    case_insensitive=True,
    glob="*.log",
)

# Multiline regex
result = await client.grep(
    r"class \w+:.*\n\s+def __init__",
    output_mode="content",
    multiline=True,
    file_type="py",
)
```

### Bash Tools

#### bash()

Execute a bash command in a persistent tmux session.

```python
async def bash(
    command: str,
    timeout: Optional[int] = None,       # Timeout in ms (default: 120000)
    description: Optional[str] = None,    # Human-readable description
    run_in_background: bool = False,
) -> BashResult
```

**Examples:**

```python
# Simple command
result = await client.bash("ls -la /home/user")
print(result.output)     # directory listing
print(result.exit_code)  # 0

# Install packages
result = await client.bash("pip install requests flask", timeout=60000)
if result.exit_code != 0:
    print(f"Install failed: {result.output}")

# Run tests
result = await client.bash("cd /home/user/project && python -m pytest -v", timeout=300000)
print(f"Tests {'passed' if result.exit_code == 0 else 'failed'}")

# Environment persists across calls
await client.bash("export DATABASE_URL=postgres://localhost/mydb")
result = await client.bash("echo $DATABASE_URL")
print(result.output)  # "postgres://localhost/mydb\n"

# Working directory persists
await client.bash("cd /home/user/project/src")
result = await client.bash("pwd")
print(result.output)  # "/home/user/project/src\n"

# Background command
result = await client.bash("npm run dev", run_in_background=True)
print(result.shell_id)  # "bash_a1b2c3d4" -- use for polling

# Timeout enforcement
result = await client.bash("sleep 60", timeout=3000)
print(result.killed)     # True
print(result.exit_code)  # 124
```

#### bash_output()

Get incremental output from a background bash command.

```python
async def bash_output(
    bash_id: str,
    filter: Optional[str] = None,    # Only lines containing this substring
) -> BashOutputResult
```

**Examples:**

```python
# Start a long-running command
result = await client.bash("npm run build 2>&1", run_in_background=True)
shell_id = result.shell_id

# Poll for output
import asyncio
while True:
    status = await client.bash_output(shell_id)
    if status.output:
        print(status.output, end="")
    if status.status in ("completed", "killed", "failed"):
        print(f"\nDone with exit code: {status.exit_code}")
        break
    await asyncio.sleep(2)

# Filter output to only show errors
status = await client.bash_output(shell_id, filter="ERROR")
```

**Status values:** `"running"`, `"completed"`, `"killed"`, `"failed"`

#### kill_bash()

Terminate a running background command.

```python
async def kill_bash(shell_id: str) -> dict
```

**Example:**

```python
# Start a long-running server
result = await client.bash("python -m http.server 8000", run_in_background=True)

# ... do some work ...

# Kill it when done
await client.kill_bash(result.shell_id)
```

### Error Handling

All tool methods raise `ToolError` on HTTP 4xx/5xx responses:

```python
from lumina_sandbox import ToolError

try:
    result = await client.read("/home/user/nonexistent.txt")
except ToolError as e:
    print(f"Error {e.status_code}: {e}")
    # Error 404: file not found: /home/user/nonexistent.txt
```

Common error scenarios:

| Status | Cause | Example |
|--------|-------|---------|
| `400` | Invalid request | Path not absolute, ambiguous edit match |
| `404` | Not found | File doesn't exist, sandbox not found |
| `409` | Conflict | Write without prior Read, session binding mismatch |

```python
# Handle write-before-read
try:
    await client.write("/home/user/existing.txt", "new content")
except ToolError as e:
    if e.status_code == 409:
        # Need to read first
        await client.read("/home/user/existing.txt")
        await client.write("/home/user/existing.txt", "new content")

# Handle ambiguous edit
try:
    await client.edit("/home/user/main.py", "x", "y")
except ToolError as e:
    if e.status_code == 400 and "occurrences" in str(e):
        # Use more context or replace_all=True
        await client.edit("/home/user/main.py", "x", "y", replace_all=True)
```

---

## Result Types

All result types are Python `dataclass` instances defined in `lumina_sandbox.types`:

```python
from lumina_sandbox import (
    BashResult,           # output, exit_code, killed, shell_id
    BashOutputResult,     # output, status, exit_code (Optional[int])
    TextFileResult,       # content, total_lines, lines_returned
    ImageFileResult,      # image (base64), mime_type, file_size
    PDFFileResult,        # pages: list[PDFPage], total_pages
    WriteResult,          # message, bytes_written, file_path
    EditResult,           # message, replacements, file_path
    GlobResult,           # matches: list[str], count, search_path
    GrepContentResult,    # matches: list[GrepContentMatch], total_matches
    GrepFilesResult,      # files: list[str], count
    GrepCountResult,      # counts: list[GrepCountEntry], total
    ToolError,            # Exception with status_code: int
)
```

### BashResult

```python
@dataclass
class BashResult:
    output: str        # Command stdout+stderr (combined)
    exit_code: int     # Process exit code (124 if timed out)
    killed: bool       # True if command was killed by timeout
    shell_id: str      # Non-empty only for background commands
```

### BashOutputResult

```python
@dataclass
class BashOutputResult:
    output: str              # Incremental output since last read
    status: str              # "running", "completed", "killed", "failed"
    exit_code: Optional[int] # Set when status != "running"
```

### TextFileResult

```python
@dataclass
class TextFileResult:
    content: str         # File content with line numbers (tab-separated)
    total_lines: int     # Total lines in the file
    lines_returned: int  # Lines returned in this response
```

### ImageFileResult

```python
@dataclass
class ImageFileResult:
    image: str       # Base64-encoded image data
    mime_type: str   # e.g., "image/png", "image/jpeg"
    file_size: int   # File size in bytes
```

### PDFFileResult / PDFPage

```python
@dataclass
class PDFPage:
    page_number: int
    text: str

@dataclass
class PDFFileResult:
    pages: list[PDFPage]
    total_pages: int
```

### WriteResult

```python
@dataclass
class WriteResult:
    message: str       # "ok"
    bytes_written: int
    file_path: str
```

### EditResult

```python
@dataclass
class EditResult:
    message: str       # "ok"
    replacements: int  # Number of replacements made
    file_path: str
```

### GlobResult

```python
@dataclass
class GlobResult:
    matches: list[str]   # Matching file paths (newest first)
    count: int
    search_path: str     # Directory that was searched
```

### GrepContentMatch / GrepContentResult

```python
@dataclass
class GrepContentMatch:
    file: str
    line_number: Optional[int]
    line: str
    before_context: list[str]
    after_context: list[str]

@dataclass
class GrepContentResult:
    matches: list[GrepContentMatch]
    total_matches: int
```

### GrepFilesResult

```python
@dataclass
class GrepFilesResult:
    files: list[str]
    count: int
```

### GrepCountEntry / GrepCountResult

```python
@dataclass
class GrepCountEntry:
    file: str
    count: int

@dataclass
class GrepCountResult:
    counts: list[GrepCountEntry]
    total: int
```

---

## bu-agent-sdk Integration

The SDK provides two ways to use Lumina tools with `bu-agent-sdk`:

### Using make_tools()

The `make_tools()` function creates tool-compatible async functions from a `LuminaToolClient`:

```python
import asyncio
from bu_agent_sdk import Agent
from bu_agent_sdk.llm import ChatAnthropic
from lumina_sandbox import LuminaToolClient
from lumina_sandbox.tools import make_tools

async def main():
    client = LuminaToolClient(
        base_url="http://localhost:8080",
        token="your-api-key",
        lumina_name="my-sandbox",
    )

    tools = make_tools(client)
    # tools = {"Read": fn, "Write": fn, "Edit": fn, "Glob": fn,
    #          "Grep": fn, "Bash": fn, "BashOutput": fn, "KillBash": fn}

    agent = Agent(
        llm=ChatAnthropic(model="claude-sonnet-4-20250514"),
        tools=list(tools.values()),
    )

    result = await agent.query("Read /home/user/main.py and add type hints")
    print(result)

    await client.close()

asyncio.run(main())
```

The returned tool functions accept the same parameters as the `LuminaToolClient` methods and return plain dicts (for agent SDK compatibility).

### Demo Agent

The `demo_agent` module provides a complete, ready-to-run agent with 9 tools (8 Lumina tools + `done`):

```python
from lumina_sandbox.demo_agent import create_agent, run

# Create agent (configures tools, LLM, etc.)
agent = create_agent()

# Run a task
import asyncio
result = asyncio.run(run("Read /home/user/main.py and refactor the main function"))
```

**From the command line:**

```bash
export LUMINA_URL=http://localhost:8080
export LUMINA_TOKEN=your-api-key
export LUMINA_NAME=my-sandbox
export ANTHROPIC_API_KEY=sk-ant-...

python -m lumina_sandbox.demo_agent "List all Python files and count total lines of code"
```

**Environment variables:**

| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `LUMINA_URL` | no | `http://localhost:8080` | Lumina server URL |
| `LUMINA_TOKEN` | yes | | API key for authentication |
| `LUMINA_NAME` | yes | | Sandbox name |
| `ANTHROPIC_API_KEY` | yes | | Anthropic API key |
| `AGENT_MODEL` | no | `claude-sonnet-4-20250514` | Model to use |

### Building a Custom Agent

```python
import asyncio
import os
from bu_agent_sdk import Agent
from bu_agent_sdk.agent import TaskComplete
from bu_agent_sdk.tools import tool
from bu_agent_sdk.llm import ChatAnthropic
from lumina_sandbox import LuminaToolClient
from lumina_sandbox.tools import make_tools

async def main():
    client = LuminaToolClient(
        base_url=os.environ.get("LUMINA_URL", "http://localhost:8080"),
        token=os.environ["LUMINA_TOKEN"],
        lumina_name=os.environ["LUMINA_NAME"],
    )

    # Get the standard Lumina tools
    lumina_tools = make_tools(client)

    # Add custom tools
    @tool("Signal task completion with a summary message.")
    async def done(message: str) -> str:
        raise TaskComplete(message)

    @tool("Search the web for documentation or answers.")
    async def web_search(query: str) -> str:
        # Your custom implementation
        return f"Search results for: {query}"

    # Combine all tools
    all_tools = list(lumina_tools.values()) + [done, web_search]

    agent = Agent(
        llm=ChatAnthropic(model="claude-sonnet-4-20250514"),
        tools=all_tools,
        system_prompt=(
            "You are a coding assistant with access to a Linux sandbox. "
            "You can read, write, and edit files, run bash commands, "
            "and search the web. When done, call the done tool."
        ),
        require_done_tool=True,
    )

    result = await agent.query("Set up a Flask web server with tests")
    print(result)

    await client.close()

asyncio.run(main())
```

---

## Interactive Chat CLI

The SDK includes a full interactive chat interface that manages the sandbox lifecycle automatically:

```bash
# Required environment variables
export LUMINA_HOST=http://localhost:8080   # or LUMINA_URL
export LUMINA_TOKEN=your-api-key
export ANTHROPIC_API_KEY=sk-ant-...

# Launch the chat
python -m lumina_sandbox
```

The chat CLI will:
1. Create a temporary sandbox (named `chat-<random>`)
2. Present an interactive prompt where you type tasks
3. Stream the agent's tool calls and responses with colored output
4. Destroy the sandbox on exit

**Reuse an existing sandbox** (sandbox will NOT be destroyed on exit):

```bash
export LUMINA_SANDBOX_NAME=my-sandbox
python -m lumina_sandbox
```

**Sample session:**

```
╔══════════════════════════════════════════════════╗
║       Lumina Sandbox — Interactive Chat           ║
╚══════════════════════════════════════════════════╝

  Server:  http://localhost:8080
  Sandbox: chat-a1b2c3d4

you > Create a Python project with a calculator module and tests

  ● bash_cmd(command="mkdir -p /home/user/calc && cd /home/user/calc")
    → ...
  ● write_file(file_path="/home/user/calc/calculator.py", content="...")
    → {'message': 'ok', 'bytes_written': 340, ...}
  ● write_file(file_path="/home/user/calc/test_calculator.py", content="...")
    → {'message': 'ok', 'bytes_written': 520, ...}
  ● bash_cmd(command="cd /home/user/calc && python -m pytest -v")
    → test_calculator.py::test_add PASSED\ntest_calculator.py::test_sub...

agent > Created a calculator project at /home/user/calc with add, subtract,
multiply, and divide functions. All 8 tests pass.

you > exit
```

**Environment variables:**

| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `LUMINA_HOST` | no | `http://localhost:8080` | Lumina server URL |
| `LUMINA_TOKEN` | yes | | API key |
| `ANTHROPIC_API_KEY` | yes | | Anthropic API key |
| `ANTHROPIC_BASE_URL` | no | | Custom Anthropic API base URL (for proxies) |
| `AGENT_MODEL` | no | `claude-sonnet-4-20250514` | Model to use |
| `LUMINA_SANDBOX_NAME` | no | | Reuse existing sandbox (skip create/destroy) |

---

## Advanced Usage

### Tool Sessions

Every `LuminaToolClient` instance has a `tool_session_id` that tracks server-side state:

```python
# Auto-generated session ID
client = LuminaToolClient(base_url="...", token="...", lumina_name="sandbox")
print(client.tool_session_id)  # "550e8400-e29b-41d4-..."

# Explicit session ID (e.g., to resume a previous session)
client = LuminaToolClient(
    base_url="...", token="...", lumina_name="sandbox",
    tool_session_id="my-session-id",
)
```

Session state includes:
- **Read file tracking:** Which files have been read (for write/edit safety checks)
- **Tmux session:** Persistent shell with environment variables and working directory
- **Background commands:** Running processes and their output buffers

Sessions are bound to a `(api_key, sandbox)` pair. Using the same session ID with a different API key or sandbox returns an error. Sessions expire after 8 hours.

### Read-Before-Write Safety

The server enforces a read-before-write pattern: you cannot overwrite an existing file unless you've read it in the current session. This prevents agents from accidentally destroying file contents they haven't seen.

```python
# New file: Write succeeds without prior Read
await client.write("/home/user/new_file.txt", "content")  # OK

# Existing file: Must Read first
try:
    await client.write("/home/user/new_file.txt", "updated")
except ToolError as e:
    print(e.status_code)  # 409

await client.read("/home/user/new_file.txt")  # Track the read
await client.write("/home/user/new_file.txt", "updated")  # Now OK

# Different session = different tracking
client2 = LuminaToolClient(base_url="...", token="...", lumina_name="sandbox")
try:
    await client2.write("/home/user/new_file.txt", "from other session")
except ToolError as e:
    print(e.status_code)  # 409 - this session hasn't read the file
```

### Background Commands

Use background execution for long-running processes (servers, builds, watchers):

```python
# Start a dev server in the background
result = await client.bash("cd /home/user/app && npm run dev", run_in_background=True)
server_id = result.shell_id

# Wait for it to start
import asyncio
await asyncio.sleep(3)

# Check if it's running and see output
status = await client.bash_output(server_id)
print(f"Status: {status.status}")    # "running"
print(f"Output: {status.output}")    # "Server started on port 3000\n"

# Do some testing
result = await client.bash("curl -s http://localhost:3000/health")
print(result.output)  # "ok"

# Subsequent bash_output calls return only NEW output (incremental)
status = await client.bash_output(server_id)
print(f"New output: {status.output}")  # Only output since last check

# Filter output to specific lines
status = await client.bash_output(server_id, filter="ERROR")

# Kill when done
await client.kill_bash(server_id)
```

### Environment Persistence

The Bash tool uses tmux sessions under the hood, so shell state persists:

```python
# Set environment variables
await client.bash("export NODE_ENV=production")
await client.bash("export PATH=$HOME/.local/bin:$PATH")

# They persist in subsequent calls
result = await client.bash("echo $NODE_ENV")
print(result.output)  # "production\n"

# Working directory also persists
await client.bash("cd /home/user/project/src")
result = await client.bash("pwd")
print(result.output)  # "/home/user/project/src\n"

# Shell aliases persist too
await client.bash("alias ll='ls -la'")
result = await client.bash("ll")
print(result.output)  # detailed directory listing
```

---

## API Reference

### LuminaToolClient

| Method | Parameters | Returns | Description |
|--------|-----------|---------|-------------|
| `create` (classmethod) | `base_url, token, name, eager?, ...` | `LuminaToolClient` | Create sandbox + client |
| `create_sandbox` | `eager?` | `dict` | Create the sandbox |
| `list_sandboxes` | | `list[dict]` | List all sandboxes |
| `restart_sandbox` | | `dict` | Restart sandbox container |
| `destroy_sandbox` | | `None` | Destroy sandbox |
| `read` | `file_path, offset?, limit?` | `TextFileResult \| ImageFileResult \| PDFFileResult` | Read a file |
| `write` | `file_path, content` | `WriteResult` | Write a file |
| `edit` | `file_path, old_string, new_string, replace_all?` | `EditResult` | Edit via string replacement |
| `glob` | `pattern, path?` | `GlobResult` | Search for files by glob |
| `grep` | `pattern, path?, glob?, output_mode?, ...` | `GrepContentResult \| GrepFilesResult \| GrepCountResult` | Search file contents |
| `bash` | `command, timeout?, description?, run_in_background?` | `BashResult` | Execute shell command |
| `bash_output` | `bash_id, filter?` | `BashOutputResult` | Poll background command |
| `kill_bash` | `shell_id` | `dict` | Kill background command |
| `close` | | `None` | Close HTTP client |

### make_tools()

```python
from lumina_sandbox.tools import make_tools

tools = make_tools(client)  # Returns dict[str, Callable]
# Keys: "Read", "Write", "Edit", "Glob", "Grep", "Bash", "BashOutput", "KillBash"
```

Each tool function returns a plain `dict` (converted from the dataclass via `.__dict__`), suitable for direct use with `bu-agent-sdk`.
