Container-based sandbox management for isolated code execution.
Puck.Sandbox.Runtime provides a struct-based API for creating and managing
isolated container environments. Inspired by E2B's simplicity and Modal's
from_id pattern.
For in-process code evaluation (Lua, JavaScript), see Puck.Sandbox.Eval.
Usage
alias Puck.Sandbox.Runtime
alias Puck.Sandbox.Runtime.Adapters.Test
# Create a sandbox
{:ok, sandbox} = Runtime.create({Test, image: "node:22-slim"})
# Execute commands
{:ok, result} = Runtime.exec(sandbox, "node --version")
IO.puts(result.stdout)
# Cleanup
:ok = Runtime.terminate(sandbox)Adapters
Runtime uses an adapter pattern for different backends:
Puck.Sandbox.Runtime.Adapters.Test- In-memory testing adapter (shipped)- Docker adapter - Planned
- Fly.io Machines adapter - Planned
Custom adapters can be created by implementing the Puck.Sandbox.Runtime.Adapter behaviour.
Summary
Functions
Waits for sandbox to become ready.
Creates a new sandbox using the specified adapter and configuration.
Creates a new sandbox from a template with optional config overrides.
Executes a command in the sandbox.
Reconstructs a sandbox struct from an existing sandbox ID.
Gets the URL for an exposed port on the sandbox.
Sends a prompt to the sandbox server and returns a stream of events.
Reads a file from the sandbox.
Starts a stopped sandbox.
Gets the current status of the sandbox.
Stops a sandbox without destroying it.
Terminates and cleans up the sandbox.
Updates a sandbox's configuration without destroying it.
Writes a file to the sandbox.
Writes multiple files to the sandbox.
Types
@type prompt_content() :: String.t() | [text_block() | file_block()]
@type text_block() :: %{type: :text, text: String.t()}
Functions
Waits for sandbox to become ready.
Delegates to the adapter's await_ready/3 if implemented.
Falls back to polling the health endpoint if not.
Options
:port- health check port (default: 4001):timeout- max wait time in ms (default: 60_000):interval- poll interval in ms (default: 2_000)
Examples
{:ok, metadata} = Runtime.await_ready(sandbox)
{:ok, metadata} = Runtime.await_ready(sandbox, timeout: 30_000)
Creates a new sandbox using the specified adapter and configuration.
Examples
{:ok, sandbox} = Runtime.create({Docker, image: "node:22-slim"})
{:ok, sandbox} = Runtime.create({Docker, %{
image: "node:22-slim",
workdir: "/workspace",
memory_mb: 2048
}})
Creates a new sandbox from a template with optional config overrides.
Examples
template = Template.new({Docker, %{image: "python:3.12", memory_mb: 512}})
# Create from template
{:ok, sandbox} = Runtime.create(template)
# With overrides
{:ok, sandbox} = Runtime.create(template, memory_mb: 1024)
Executes a command in the sandbox.
Options
:timeout- Command timeout in milliseconds (default: 30_000):workdir- Working directory for the command
Examples
{:ok, result} = Runtime.exec(sandbox, "node --version")
IO.puts(result.stdout)
# With timeout
{:ok, result} = Runtime.exec(sandbox, "npm install", timeout: 120_000)
# File operations via exec
{:ok, _} = Runtime.exec(sandbox, "echo 'hello' > file.txt")
{:ok, result} = Runtime.exec(sandbox, "cat file.txt")
Reconstructs a sandbox struct from an existing sandbox ID.
Useful for resuming work with a container that was created elsewhere
or persisted across sessions. Inspired by Modal's Sandbox.from_id().
Examples
sandbox = Runtime.from_id(Docker, "puck-sandbox-abc123")
{:ok, result} = Runtime.exec(sandbox, "echo 'still here'")
Gets the URL for an exposed port on the sandbox.
Useful for sandboxes running servers. This is an optional adapter callback -
returns {:error, :not_implemented} if the adapter doesn't support it.
Examples
{:ok, url} = Runtime.get_url(sandbox, 4000)
# => "http://puck-sandbox-abc123:4000"
Sends a prompt to the sandbox server and returns a stream of events.
The sandbox must be running a puck-sandbox compatible server that
accepts POST requests to /prompt and returns NDJSON events.
Options
:port- The port the sandbox server is listening on (default: 4001):timeout- Request timeout in milliseconds (default: 60_000):options- Custom options passed to the sandbox server (default: %{})
Event Format
The stream yields maps with a "type" key indicating the event type:
%{"type" => "text", "text" => "Hello!"}
%{"type" => "error", "message" => "..."}Examples
{:ok, stream} = Runtime.prompt(sandbox, "Hello")
Enum.each(stream, fn event ->
case event do
%{"type" => "text", "text" => text} -> IO.write(text)
%{"type" => "error", "message" => msg} -> IO.puts("Error: #{msg}")
_ -> :ok
end
end)
# With content blocks for images/files
{:ok, stream} = Runtime.prompt(sandbox, [
%{type: "text", text: "What's in this image?"},
%{type: "file", media_type: "image/png", data: Base.encode64(bytes)}
])
# Accumulate full response
{:ok, stream} = Runtime.prompt(sandbox, "Write a poem")
text = stream
|> Enum.filter(&match?(%{"type" => "text"}, &1))
|> Enum.map_join(&Map.get(&1, "text"))
Reads a file from the sandbox.
Examples
{:ok, content} = Runtime.read_file(sandbox, "/app/code.py")
Starts a stopped sandbox.
Returns {:error, :not_implemented} if the adapter doesn't support it.
Examples
{:ok, _} = Runtime.start(sandbox)
Gets the current status of the sandbox.
Examples
:running = Runtime.status(sandbox)
Stops a sandbox without destroying it.
Useful for suspend/resume patterns. The sandbox can be restarted with start/1.
Returns {:error, :not_implemented} if the adapter doesn't support it.
Examples
{:ok, _} = Runtime.stop(sandbox)
Terminates and cleans up the sandbox.
Examples
:ok = Runtime.terminate(sandbox)
Updates a sandbox's configuration without destroying it.
This preserves state like volume attachments. Returns {:error, :not_implemented}
if the adapter doesn't support it.
Examples
{:ok, _} = Runtime.update(sandbox, %{image: "node:23-slim"})
Writes a file to the sandbox.
Examples
:ok = Runtime.write_file(sandbox, "/app/code.py", "print('hello')")
Writes multiple files to the sandbox.
Examples
:ok = Runtime.write_files(sandbox, [
{"/app/main.py", "import lib"},
{"/app/lib.py", "def foo(): pass"}
])