migrate-from-modal

SKILL.md

Migrate from Modal to Blaxel

This guide covers migrating cloud sandbox code from Modal (modal) to Blaxel (@blaxel/core / blaxel).

Package replacement

Language Modal Blaxel
TypeScript modal @blaxel/core
Python modal blaxel
# TypeScript
npm uninstall modal
npm install @blaxel/core

# Python
pip uninstall modal
pip install blaxel

Authentication

Modal Blaxel
MODAL_TOKEN_ID + MODAL_TOKEN_SECRET env vars BL_WORKSPACE + BL_API_KEY env vars
Or modal setup CLI command Or bl login YOUR-WORKSPACE CLI command
Requires two secrets (token pair) CLI login, .env, env vars, or config file (auto-detected)

Blaxel authenticates automatically when logged in via CLI (bl login YOUR-WORKSPACE). No need to manage token pairs.

API mapping

Imports

TypeScript:

// BEFORE (Modal)
import { ModalClient, Sandbox } from 'modal'

// AFTER (Blaxel)
import { SandboxInstance } from '@blaxel/core'

Python:

# BEFORE (Modal)
import modal

# AFTER (Blaxel)
from blaxel.core import SandboxInstance

Sandbox creation

Modal requires instantiating a client, creating an app, building an image, then creating a sandbox. Blaxel uses a single static method.

TypeScript:

// BEFORE (Modal)
const client = new ModalClient()
const app = await client.apps.fromName('my-app', { createIfMissing: true })
const image = client.images.fromRegistry('python:3.11-slim')
const sandbox = await client.sandboxes.create(app, image, {
  regions: ['us-west-2'],
  memoryMiB: 2048,
  cpu: 1,
  timeoutMs: 1000 * 60 * 15,
})

// AFTER (Blaxel)
const sandbox = await SandboxInstance.createIfNotExists({
  name: 'my-sandbox',
  image: 'blaxel/base-image:latest',
  region: 'us-pdx-1',
  memory: 2048,
  ports: [{ target: 3000, protocol: 'HTTP' }],
  envs: [
    { name: 'NODE_ENV', value: 'production' },
  ],
})

Python:

# BEFORE (Modal)
app = modal.App("my-app")
image = modal.Image.debian_slim().pip_install("numpy")
sandbox = app.spawn_sandbox(
    image=image,
    timeout=900,     # seconds (max 24h)
    cpu=1,
    memory=2048,     # MiB
)

# AFTER (Blaxel)
sandbox = await SandboxInstance.create_if_not_exists({
    "name": "my-sandbox",
    "image": "blaxel/base-image:latest",
    "region": "us-pdx-1",
    "memory": 2048,
    "ports": [{"target": 3000, "protocol": "HTTP"}],
    "envs": [
        {"name": "NODE_ENV", "value": "production"},
    ],
})

Parameter mapping

Parameter Modal Blaxel
Image client.images.fromRegistry(...) (programmatic) image: 'blaxel/base-image:latest' (pre-built or custom Dockerfile)
Memory memoryMiB: 2048 memory: 2048 (MB)
CPU cpu: 1 Not needed (auto-allocated: CPU cores = memory / 2048)
Region regions: ['us-west-2'] region: 'us-pdx-1'
Timeout timeoutMs: 900000 (max 24h) ttl: '24h'
Idle timeout idle_timeout: 300 (seconds, terminates) Auto scale-to-zero (~5s, suspends with <25ms resume). For idle-based deletion, use lifecycle: { expirationPolicies: [{ type: 'ttl-idle', value: '7d', action: 'delete' }] }
Name name: 'my-sandbox' (within app) name: 'my-sandbox' (global)
Tags tags: { k: 'v' } labels: { k: 'v' }
Ports encrypted_ports: [3000] / unencrypted_ports: [3000] ports: [{ target: 3000, protocol: 'HTTP' }]
Environment vars Inline secrets at creation envs: [{ name: 'KEY', value: 'val' }]
Secrets Modal secrets (vault-based) envs: [{ name: 'KEY', value: 'val', secret: true }]
Network control block_network: true, cidr_allowlist: [...] N/A (sandboxes have full network access)
GPU gpu: 'A10G' (T4, L4, A10G, A100, H100, etc.) N/A (Blaxel sandboxes do not support GPU)
Entrypoint command command: ['python3', 'server.py'] at creation Not supported at creation — use process.exec() immediately after
Proxy / network routing proxy: modal.proxies.fromName(...) network: { vpcName: 'my-vpc', egressGatewayName: 'my-gw' } (VPC + egress gateway)
Cloud bucket mounts cloudBucketMounts: { '/mnt': ... } (S3/GCS) N/A (use process.exec() with AWS CLI/gsutil to sync data)
HTTP/2 ports h2Ports: [8080] N/A (use ports: [{ target: 8080, protocol: 'HTTP' }] — HTTPS handled via preview URLs)
App Required App object Not needed

Entrypoint command at creation

Modal allows running a command directly at sandbox creation. Blaxel requires creating the sandbox first, then executing:

TypeScript:

// BEFORE (Modal) - command at creation
const sandbox = await client.sandboxes.create(app, image, {
  command: ['python3', '-m', 'http.server', '8080'],
})

// AFTER (Blaxel) - create then exec
const sandbox = await SandboxInstance.createIfNotExists({
  name: 'my-sandbox',
  image: 'blaxel/base-image:latest',
  ports: [{ target: 8080, protocol: 'HTTP' }],
})
await sandbox.process.exec({
  command: 'python3 -m http.server 8080',
  waitForPorts: [8080],
})

Python:

# BEFORE (Modal)
sandbox = app.spawn_sandbox(image=image, command=["python3", "-m", "http.server", "8080"])

# AFTER (Blaxel)
sandbox = await SandboxInstance.create_if_not_exists({
    "name": "my-sandbox",
    "image": "blaxel/base-image:latest",
    "ports": [{"target": 8080, "protocol": "HTTP"}],
})
await sandbox.process.exec({
    "command": "python3 -m http.server 8080",
    "wait_for_ports": [8080],
})

Custom images / templates

Modal builds images programmatically with chained SDK methods. Blaxel uses standard Dockerfiles deployed via bl deploy.

TypeScript:

// BEFORE (Modal) - programmatic image building
const image = client.images.fromRegistry('ubuntu:22.04')
  .dockerfileCommands([
    'RUN apt-get update && apt-get install -y curl git',
    'RUN curl -fsSL https://bun.sh/install | bash',
    'RUN mkdir -p /workspace',
  ])
const sandbox = await client.sandboxes.create(app, image, { regions: ['us-west-2'] })

// AFTER (Blaxel) - Dockerfile-based templates deployed with `bl deploy`
// 1. Create template: bl new sandbox my-template
// 2. Edit the Dockerfile
// 3. Deploy: bl deploy
// 4. Use the deployed image:
const sandbox = await SandboxInstance.createIfNotExists({
  name: 'my-sandbox',
  image: 'my-deployed-template-image',
  region: 'us-pdx-1',
  memory: 2048,
})

Python:

# BEFORE (Modal) - programmatic image building
image = (
    modal.Image.debian_slim()
    .apt_install("curl", "git")
    .run_commands("curl -fsSL https://bun.sh/install | bash")
    .pip_install("numpy", "pandas")
)
sandbox = app.spawn_sandbox(image=image)

# AFTER (Blaxel) - deploy a Dockerfile via `bl deploy`, then reference it:
sandbox = await SandboxInstance.create_if_not_exists({
    "name": "my-sandbox",
    "image": "my-deployed-template-image",
    "region": "us-pdx-1",
    "memory": 2048,
})

Blaxel Dockerfile equivalent:

FROM ubuntu:22.04
WORKDIR /workspace
COPY --from=ghcr.io/blaxel-ai/sandbox:latest /sandbox-api /usr/local/bin/sandbox-api
RUN apt-get update && apt-get install -y curl git
RUN curl -fsSL https://bun.sh/install | bash
RUN pip install numpy pandas
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

Image building methods mapping

All Modal image building methods map to standard Dockerfile instructions in a Blaxel template:

Modal Image Method Blaxel Dockerfile Equivalent
.debian_slim() FROM python:3.11-slim
.from_registry("img") FROM img
.from_dockerfile("./Dockerfile") Use the Dockerfile directly as the Blaxel template
.pip_install("numpy") RUN pip install numpy
.pip_install_from_requirements("r.txt") COPY requirements.txt . && RUN pip install -r requirements.txt
.pip_install_from_pyproject("p.toml") COPY pyproject.toml . && RUN pip install .
.poetry_install_from_file(...) COPY pyproject.toml poetry.lock . && RUN poetry install
.uv_pip_install("numpy") RUN uv pip install numpy
.uv_sync() COPY pyproject.toml uv.lock . && RUN uv sync
.apt_install("curl") RUN apt-get update && apt-get install -y curl
.micromamba_install("numpy") RUN micromamba install numpy
.add_local_file("f", "/app/f") COPY f /app/f
.add_local_dir("./src", "/app/src") COPY ./src /app/src
.run_commands("echo hi") RUN echo hi
.run_function(fn) N/A (use RUN with a script instead)
.env({"KEY": "val"}) ENV KEY=val
.workdir("/app") WORKDIR /app
.entrypoint(["python"]) ENTRYPOINT ["python"]
.shell("/bin/bash") SHELL ["/bin/bash", "-c"]
.cmd(["serve"]) CMD ["serve"]

Image pre-warming: Modal's image.build(app) eagerly caches images before sandbox creation. The Blaxel equivalent is bl deploy, which builds and pushes the template image to Blaxel's private registry. Subsequent sandbox creation with that image is instant.

Command execution

Modal uses sandbox.exec(['bash', '-c', command]) with stdout/stderr streams. Blaxel uses a single process.exec() call.

TypeScript:

// BEFORE (Modal)
const result = await sandbox.exec(['bash', '-c', 'echo hello'])
const stdout = await result.stdout.readText()
const stderr = await result.stderr.readText()
const exitCode = await result.wait()

// With env vars and working directory (manual shell construction):
const envStr = Object.entries(envVars).map(([k, v]) => `export ${k}="${v}"`).join(' && ')
const fullCommand = `${envStr} && cd /app && npm install`
const result = await sandbox.exec(['bash', '-c', fullCommand])

// AFTER (Blaxel)
const result = await sandbox.process.exec({
  command: 'echo hello',
  waitForCompletion: true,
})

// With env vars and working directory (native support):
const result = await sandbox.process.exec({
  command: 'npm install',
  workingDir: '/app',
  env: { NODE_ENV: 'production' },
  waitForCompletion: true,
})

Python:

# BEFORE (Modal)
process = sandbox.exec("bash", "-c", "echo hello")
stdout = process.stdout.read()
stderr = process.stderr.read()
process.wait()

# With env vars (manual shell construction):
process = sandbox.exec("bash", "-c", "export NODE_ENV=production && cd /app && npm install")

# AFTER (Blaxel)
result = await sandbox.process.exec({
    "command": "echo hello",
    "wait_for_completion": True,
})

# With env vars and working directory (native support):
result = await sandbox.process.exec({
    "command": "npm install",
    "working_dir": "/app",
    "env": {"NODE_ENV": "production"},
    "wait_for_completion": True,
})

Long-running processes (dev servers)

TypeScript:

// BEFORE (Modal) - must redirect to temp file and poll
const bgCommand = `export PATH=... && cd /app && npm run dev > /tmp/output.log 2>&1 & echo $! > /tmp/pid.txt`
await sandbox.exec(['bash', '-c', bgCommand])
// ... poll /tmp/output.log for readiness ...
// ... kill process via: sandbox.exec(['bash', '-c', 'kill $(cat /tmp/pid.txt)'])

// AFTER (Blaxel) - native background mode with port waiting
const devServer = await sandbox.process.exec({
  name: 'dev-server',
  command: 'npm run dev -- --host 0.0.0.0',
  workingDir: '/app',
  waitForPorts: [3000],  // returns once port 3000 is open
})
// Later: await sandbox.process.kill('dev-server')

Python:

# BEFORE (Modal) - must redirect to temp file and poll
bg_command = "export PATH=... && cd /app && npm run dev > /tmp/output.log 2>&1 & echo $! > /tmp/pid.txt"
sandbox.exec("bash", "-c", bg_command)
# ... poll /tmp/output.log for readiness ...

# AFTER (Blaxel) - native background mode with port waiting
dev_server = await sandbox.process.exec({
    "name": "dev-server",
    "command": "npm run dev -- --host 0.0.0.0",
    "working_dir": "/app",
    "wait_for_ports": [3000],
})
# Later: await sandbox.process.kill("dev-server")

Log streaming / Process output

Modal uses pipe-based stdout/stderr streams on the sandbox entrypoint or on exec() results. Blaxel captures output via callbacks, log retrieval, or stream subscriptions.

TypeScript:

// BEFORE (Modal) - pipe-based streams
const result = await sandbox.exec(['bash', '-c', 'echo hello && echo err >&2'])
const stdout = await result.stdout.readText()
const stderr = await result.stderr.readText()
const exitCode = await result.wait()

// Entrypoint streams:
await sandbox.stdin.writeText('input\n')
const output = await sandbox.stdout.readText()

// AFTER (Blaxel) - callbacks on exec
const result = await sandbox.process.exec({
  command: 'echo hello && echo err >&2',
  onLog: (log) => console.log('[LOG]', log),
  onStdout: (out) => process.stdout.write(out),
  onStderr: (err) => process.stderr.write(err),
  waitForCompletion: true,
})
// result.stdout, result.stderr, result.exitCode available after completion

// Stream logs from a running process:
const handle = sandbox.process.streamLogs('my-process', {
  onLog: (log) => console.log(log),
  onStdout: (out) => process.stdout.write(out),
  onStderr: (err) => process.stderr.write(err),
  onError: (err) => console.error(err),
})
// Wait for the process to finish before closing the stream:
await handle.wait()

// Retrieve logs after the fact:
const logs = await sandbox.process.logs('my-process')          // all
const stdout = await sandbox.process.logs('my-process', 'stdout')
const stderr = await sandbox.process.logs('my-process', 'stderr')

Python:

# BEFORE (Modal) - pipe-based streams
process = sandbox.exec("bash", "-c", "echo hello && echo err >&2")
stdout = process.stdout.read()
stderr = process.stderr.read()
process.wait()

# AFTER (Blaxel) - callbacks on exec
result = await sandbox.process.exec({
    "command": "echo hello && echo err >&2",
    "on_log": lambda log: print("[LOG]", log),
    "on_stdout": lambda out: print(out, end=""),
    "on_stderr": lambda err: print(err, end="", file=sys.stderr),
    "wait_for_completion": True,
})
# result.stdout, result.stderr, result.exit_code available

# Stream logs from a running process:
handle = await sandbox.process.stream_logs("my-process", {
    "on_log": lambda log: print(log),
})
# Wait for the process to finish before closing the stream:
await handle.wait()

# Retrieve logs after the fact:
logs = await sandbox.process.logs("my-process")           # all
stdout = await sandbox.process.logs("my-process", "stdout")
stderr = await sandbox.process.logs("my-process", "stderr")

Note: Blaxel does not support stdin writes or pseudo-terminal (pty) mode. There is no equivalent to Modal's pty: true parameter.

Secrets

Modal has vault-based secrets (Secret.from_name(), Secret.from_dict(), Secret.from_dotenv()). Blaxel uses environment variables with an optional secret: true flag.

TypeScript:

// BEFORE (Modal) - vault-based secrets
const secret = await modal.Secret.fromName('my-secret')
const ephemeralSecret = modal.Secret.fromDict({ API_KEY: 'sk-...' })
const dotenvSecret = modal.Secret.fromDotenv()
const sandbox = await client.sandboxes.create(app, image, {
  secrets: [secret, ephemeralSecret],
})
// Per-exec secrets:
const result = await sandbox.exec(['bash', '-c', 'echo $API_KEY'], {
  secrets: [ephemeralSecret],
})

// AFTER (Blaxel) - environment variables with secret flag
const sandbox = await SandboxInstance.createIfNotExists({
  name: 'my-sandbox',
  image: 'blaxel/base-image:latest',
  envs: [
    { name: 'API_KEY', value: 'sk-...', secret: true },  // marked as secret
    { name: 'PUBLIC_VAR', value: 'visible' },
  ],
})
// Per-exec env vars:
const result = await sandbox.process.exec({
  command: 'echo $API_KEY',
  env: { API_KEY: 'sk-...' },
  waitForCompletion: true,
})

Python:

# BEFORE (Modal) - vault-based secrets
secret = modal.Secret.from_name("my-secret")
ephemeral_secret = modal.Secret.from_dict({"API_KEY": "sk-..."})
dotenv_secret = modal.Secret.from_dotenv()
sandbox = app.spawn_sandbox(image=image, secrets=[secret, ephemeral_secret])

# AFTER (Blaxel) - environment variables with secret flag
sandbox = await SandboxInstance.create_if_not_exists({
    "name": "my-sandbox",
    "image": "blaxel/base-image:latest",
    "envs": [
        {"name": "API_KEY", "value": "sk-...", "secret": True},
        {"name": "PUBLIC_VAR", "value": "visible"},
    ],
})
# Per-exec env vars:
result = await sandbox.process.exec({
    "command": "echo $API_KEY",
    "env": {"API_KEY": "sk-..."},
    "wait_for_completion": True,
})

Note: Blaxel does not have a vault-style secret management API. For production, use the Variables-and-secrets page in the Blaxel Console. The SDK auto-detects .env files for authentication (similar to Secret.from_dotenv()).

Connect tokens / Sessions

Modal's createConnectToken() provides authenticated access to a sandbox. Blaxel has two equivalents: preview tokens (for HTTP access to sandbox services) and sessions (for SDK-based sandbox access from frontends).

TypeScript:

// BEFORE (Modal) - connect token for sandbox access
const { url, token } = await sandbox.createConnectToken({ userMetadata: 'user-123' })

// AFTER (Blaxel) - Option 1: Preview token for HTTP access
const preview = await sandbox.previews.createIfNotExists({
  metadata: { name: 'app-preview' },
  spec: { port: 3000, public: false },
})
const token = await preview.tokens.create(new Date(Date.now() + 3600000)) // 1 hour
const accessUrl = `${preview.spec?.url}?bl_preview_token=${token.value}`

// AFTER (Blaxel) - Option 2: Session for SDK-based frontend access
const session = await sandbox.sessions.create({
  expiresAt: new Date(Date.now() + 3600000),
})
// Pass session token to frontend, then reconstruct the SDK client:
const frontendSandbox = await SandboxInstance.fromSession(session.token)

Python:

# BEFORE (Modal)
result = sandbox.create_connect_token(user_metadata="user-123")

# AFTER (Blaxel) - Preview token for HTTP access
preview = await sandbox.previews.create_if_not_exists({
    "metadata": {"name": "app-preview"},
    "spec": {"port": 3000, "public": False},
})
token = await preview.tokens.create(datetime.now() + timedelta(hours=1))
access_url = f"{preview.spec.url}?bl_preview_token={token.value}"

# AFTER (Blaxel) - Session for SDK-based frontend access
session = await sandbox.sessions.create({
    "expires_at": datetime.now() + timedelta(hours=1),
})
# Pass session token to frontend

Filesystem operations

Modal uses low-level file handles (open, read, write, close) or newer ls/mkdir/rm methods. Blaxel has high-level methods throughout.

Operation Modal Blaxel
Write file file = sandbox.open(path, 'w')file.write(encoded)file.close() sandbox.fs.write(path, content)
Read file file = sandbox.open(path, 'r')file.read()file.close() sandbox.fs.read(path)
List files sandbox.ls(path) or sandbox.exec(['ls', path]) sandbox.fs.ls(path)
Create dir sandbox.mkdir(path) or sandbox.exec(['mkdir', '-p', path]) sandbox.fs.mkdir(path)
Delete file sandbox.rm(path) sandbox.fs.rm(path) / sandbox.fs.rm(path, { recursive: true })
Copy file sandbox.exec(['cp', ...]) sandbox.fs.cp(src, dest)
Watch files sandbox.watch(path) (Alpha) sandbox.fs.watch(path, callback, { withContent: true, ignore: ['node_modules'] })
Fuzzy search N/A sandbox.fs.search(query, path)
Write tree N/A sandbox.fs.writeTree(files, basePath) / write_tree(files, base)
Write binary file.write(bytes) sandbox.fs.writeBinary(path, buffer) / write_binary(path, bytes)
Read binary file.read() sandbox.fs.readBinary(path) / read_binary(path)
Search content N/A sandbox.fs.grep(pattern, path)
Find files N/A sandbox.fs.find(path, { patterns: ['*.ts'] })

TypeScript:

// BEFORE (Modal) - low-level file handles
const file = await sandbox.open('/app/file.txt', 'w')
await file.write(new TextEncoder().encode('hello'))
await file.close()

const rFile = await sandbox.open('/app/file.txt', 'r')
const data = await rFile.read()
await rFile.close()
const content = new TextDecoder().decode(data)

const p = await sandbox.exec(['ls', '/app'])
const listing = await p.stdout.readText()

// AFTER (Blaxel) - high-level one-liners
await sandbox.fs.write('/app/file.txt', 'hello')
const content = await sandbox.fs.read('/app/file.txt')
const { subdirectories, files } = await sandbox.fs.ls('/app')

// Write multiple files at once:
await sandbox.fs.writeTree([
  { path: 'src/index.ts', content: 'console.log("hello")' },
  { path: 'package.json', content: '{"name":"app"}' },
], '/app')

// Delete files:
await sandbox.fs.rm('/app/temp.txt')
await sandbox.fs.rm('/app/old-dir', { recursive: true })

// Copy files:
await sandbox.fs.cp('/app/file.txt', '/backup/file.txt')

// Watch for file changes:
const handle = sandbox.fs.watch('/app/src', (event) => {
  console.log(event.op, event.path)
}, { withContent: true, ignore: ['node_modules', '.git'] })
// Later: handle.close()

Python:

# BEFORE (Modal) - low-level file handles
with sandbox.open("/app/file.txt", "w") as f:
    f.write(b"hello")

with sandbox.open("/app/file.txt", "r") as f:
    content = f.read().decode()

process = sandbox.exec("ls", "/app")
listing = process.stdout.read()

# AFTER (Blaxel) - high-level one-liners
await sandbox.fs.write("/app/file.txt", "hello")
content = await sandbox.fs.read("/app/file.txt")
listing = await sandbox.fs.ls("/app")

# Write multiple files at once:
await sandbox.fs.write_tree([
    {"path": "src/index.ts", "content": 'console.log("hello")'},
    {"path": "package.json", "content": '{"name":"app"}'},
], "/app")

# Delete files:
await sandbox.fs.rm("/app/temp.txt")
await sandbox.fs.rm("/app/old-dir", recursive=True)

# Copy files:
await sandbox.fs.cp("/app/file.txt", "/backup/file.txt")

# Watch for file changes:
def on_change(event):
    print(event.op, event.path)

handle = sandbox.fs.watch("/app/src", on_change, {
    "with_content": True,
    "ignore": ["node_modules", ".git"]
})
# Later: handle["close"]()

Preview URLs / Tunnels

Modal uses tunnels for exposing sandbox ports. Blaxel has dedicated preview URL management.

TypeScript:

// BEFORE (Modal) - tunnels for port access
const sandbox = await client.sandboxes.create(app, image, {
  encrypted_ports: [3000],
})
const tunnels = await sandbox.tunnels()
const url = tunnels.get(3000)?.url

// AFTER (Blaxel) - dedicated preview URL with access control
const sandbox = await SandboxInstance.createIfNotExists({
  name: 'my-sandbox',
  image: 'blaxel/node:latest',
  region: 'us-pdx-1',
  memory: 2048,
  ports: [{ target: 3000, protocol: 'HTTP' }],
})

const preview = await sandbox.previews.createIfNotExists({
  metadata: { name: 'app-preview' },
  spec: { port: 3000, public: true },
})
const url = preview.spec?.url  // https://xxxx.preview.bl.run

Python:

# BEFORE (Modal) - tunnels for port access
sandbox = app.spawn_sandbox(image=image, encrypted_ports=[3000])
tunnels = sandbox.tunnels()
url = tunnels[3000].url

# AFTER (Blaxel) - dedicated preview URL with access control
sandbox = await SandboxInstance.create_if_not_exists({
    "name": "my-sandbox",
    "image": "blaxel/node:latest",
    "region": "us-pdx-1",
    "memory": 2048,
    "ports": [{"target": 3000, "protocol": "HTTP"}],
})

preview = await sandbox.previews.create_if_not_exists({
    "metadata": {"name": "app-preview"},
    "spec": {"port": 3000, "public": True},
})
url = preview.spec.url  # https://xxxx.preview.bl.run

Snapshot / Pause / Resume

Modal uses filesystem snapshots for pause/resume. Blaxel handles this automatically with scale-to-zero.

TypeScript:

// BEFORE (Modal) - manual snapshot and restore
const snapshotImage = await sandbox.snapshotFilesystem()
await sandbox.terminate()
// ... later ...
const newSandbox = await client.sandboxes.create(app, snapshotImage, {
  regions: ['us-west-2'],
})

// AFTER (Blaxel) - automatic: sandboxes auto-pause after ~5s inactivity
// and resume in <25ms on the next API call. No explicit snapshot needed.
// State (memory + filesystem) is preserved automatically.
const sandbox = await SandboxInstance.get('my-sandbox')

Python:

# BEFORE (Modal) - manual snapshot and restore
snapshot_image = sandbox.snapshot_filesystem()
sandbox.terminate()
# ... later ...
new_sandbox = app.spawn_sandbox(image=snapshot_image)

# AFTER (Blaxel) - automatic, just reconnect by name
sandbox = await SandboxInstance.get("my-sandbox")

Directory snapshots: Modal's snapshotDirectory() / mountImage() for sharing directory state between sandboxes has no direct Blaxel equivalent. Blaxel auto-preserves sandbox state across scale-to-zero. To share pre-built directory state across multiple sandboxes, use volume templates (bl new volume-template) to create volumes pre-populated with files.

Volumes

TypeScript:

// BEFORE (Modal) - mount volumes and cloud buckets
const sandbox = await client.sandboxes.create(app, image, {
  mounts: [modal.Mount.from_local_dir('./data', remote_path='/data')],
  volumes: { '/persistent': myVolume },
})
await sandbox.reloadVolumes()

// AFTER (Blaxel) - attach volumes at creation
import { VolumeInstance, SandboxInstance } from '@blaxel/core'

const volume = await VolumeInstance.createIfNotExists({
  name: 'my-volume',
  size: 1024,
  region: 'us-pdx-1',
})
const sandbox = await SandboxInstance.createIfNotExists({
  name: 'my-sandbox',
  image: 'blaxel/base-image:latest',
  region: 'us-pdx-1',
  memory: 2048,
  volumes: [{ name: 'my-volume', mountPath: '/data', readOnly: false }],
})

Python:

# BEFORE (Modal) - mount volumes
volume = modal.Volume.from_name("my-volume", create_if_missing=True)
sandbox = app.spawn_sandbox(image=image, volumes={"/data": volume})

# AFTER (Blaxel) - attach volumes at creation
from blaxel.core import VolumeInstance, SandboxInstance

volume = await VolumeInstance.create_if_not_exists({
    "name": "my-volume",
    "size": 1024,
    "region": "us-pdx-1",
})
sandbox = await SandboxInstance.create_if_not_exists({
    "name": "my-sandbox",
    "image": "blaxel/base-image:latest",
    "region": "us-pdx-1",
    "memory": 2048,
    "volumes": [{"name": "my-volume", "mount_path": "/data", "read_only": False}],
})

reloadVolumes() not needed: Modal requires sandbox.reload_volumes() / sb.reloadVolumes() to sync volume changes. Blaxel volumes are persistent block storage mounted directly — changes are immediately visible without reload.

Ephemeral volumes: Modal's modal.volumes.ephemeral() for temporary shared storage between sandboxes has no Blaxel equivalent. Blaxel only supports persistent named volumes. Workaround: create a named volume and delete it when done, or use the sandbox filesystem (data persists across auto-pause/resume).

Sandbox lifecycle

Operation Modal Blaxel
Terminate sandbox.terminate() sandbox.delete()
Detach (release connection) sandbox.detach() N/A (stateless API calls)
Wait for completion sandbox.wait() sandbox.process.exec({ waitForCompletion: true }) (max 60s) or sandbox.process.wait('name', { maxWait: 300000 }) for longer
Exit code sandbox.returncode / result.wait() result.exitCode on process response, or (await sandbox.process.get('name')).exitCode
Poll exit code sandbox.poll() sandbox.process.get('name') — check .status and .exitCode
List sandboxes Sandbox.list(app, tags={...}) SandboxInstance.list() (SDK) or bl get sandboxes (CLI). Labels in metadata for filtering
Get by ID Sandbox.from_id(id) SandboxInstance.get('name') (Blaxel uses human-readable names, not opaque IDs)
Get by name Sandbox.from_name(name, app) SandboxInstance.get('name')
Tags / Labels (read) sandbox.get_tags() sandbox.metadata.labels
Tags / Labels (write) sandbox.set_tags({...}) Set labels at creation; update via SandboxInstance.updateMetadata('name', { labels: {...} })
Process management result.wait() / result.terminate() sandbox.process.get('name'), .stop('name') (SIGTERM), .kill('name') (SIGKILL), .list(), .wait('name')

TypeScript:

// BEFORE (Modal)
await sandbox.terminate()

// AFTER (Blaxel)
await sandbox.delete()

Python:

# BEFORE (Modal)
sandbox.terminate()

# AFTER (Blaxel)
await sandbox.delete()

Key differences

  • No client/app boilerplate: Modal requires ModalClientAppImageSandbox (TS) or modal.App()Imagespawn_sandbox() (Python). Blaxel uses a single SandboxInstance.createIfNotExists() / create_if_not_exists() call
  • No programmatic image building: Modal builds images via chained SDK methods (fromRegistry().dockerfileCommands(), .pip_install(), .apt_install()). Blaxel uses standard Dockerfiles deployed with bl deploy
  • No GPU support: Modal supports GPU-accelerated sandboxes (T4, A10G, A100, H100, etc.). Blaxel sandboxes are CPU-only
  • No entrypoint command at creation: Modal allows command: [...] at sandbox creation. Blaxel requires creating the sandbox first, then calling process.exec()
  • High-level filesystem API: Modal uses low-level file handles (open/read/write/close). Blaxel has fs.write(), fs.read(), fs.ls(), fs.rm(), fs.cp(), fs.writeTree() / write_tree()
  • Log streaming model: Modal uses pipe-based stdout/stderr streams. Blaxel uses callbacks (onLog, onStdout, onStderr) on process.exec(), process.streamLogs(), and process.logs() for retrieval
  • Native env vars and working dir: Modal requires manual shell string construction for env vars. Blaxel accepts env and workingDir / working_dir as parameters
  • Environment variables as secrets: Modal has vault-based Secret.from_name(). Blaxel uses envs: [{ name: 'KEY', value: 'val', secret: true }] — no separate vault API
  • Auto scale-to-zero: Blaxel sandboxes auto-pause after ~5s of inactivity and resume in <25ms. No manual snapshots needed. Modal requires explicit snapshotFilesystem() / snapshot_filesystem() + terminate(). For idle-based deletion, Blaxel uses lifecycle expiration policies (ttl-idle)
  • Region format: Modal uses cloud provider regions (us-west-2, us-central1). Blaxel uses us-pdx-1
  • Ports declared at creation: Blaxel requires ports at sandbox creation time; they cannot be added later. Ports 80, 443, 8080 are reserved
  • Preview URLs vs Tunnels: Modal uses encrypted_ports + tunnels(). Blaxel has dedicated previews.createIfNotExists() / create_if_not_exists() with public/private access control and token-based authentication
  • Named sandboxes: Blaxel supports createIfNotExists / create_if_not_exists with a name for idempotent creation (globally unique, not scoped to an app). Blaxel uses human-readable names, not opaque IDs
  • Process naming and management: Blaxel processes can be named for later reference (get, stop, kill, list, wait, logs by name). Modal returns ContainerProcess objects
  • Dev server binding: Dev servers must bind to 0.0.0.0 (not localhost) for preview URLs to work in Blaxel
  • Async Python: Blaxel Python SDK is async-first (all calls use await); Modal Python SDK is sync by default
  • ~50% memory reserved for tmpfs: Blaxel uses half of sandbox memory for the in-memory filesystem. Use volumes for extra storage
  • Proxy/VPC: Modal uses modal.proxies.fromName(...). Blaxel uses VPC + egress gateway via network: { vpcName: '...', egressGatewayName: '...' } at sandbox creation

Complete migration example

TypeScript

BEFORE (Modal):

import { ModalClient } from 'modal'

const client = new ModalClient()
const app = await client.apps.fromName('my-app', { createIfMissing: true })
const image = client.images.fromRegistry('python:3.11-slim')

const sandbox = await client.sandboxes.create(app, image, {
  regions: ['us-west-2'],
  memoryMiB: 2048,
  cpu: 1,
  timeoutMs: 1000 * 60 * 15,
  encrypted_ports: [3000],
})

// Write a file
const file = await sandbox.open('/app/index.js', 'w')
await file.write(new TextEncoder().encode('console.log("hello")'))
await file.close()

// Run a command
const result = await sandbox.exec(['bash', '-c', 'node /app/index.js'])
const stdout = await result.stdout.readText()
await result.wait()
console.log(stdout)

// Get tunnel URL
const tunnels = await sandbox.tunnels()
console.log(`Preview: ${tunnels.get(3000)?.url}`)

await sandbox.terminate()

AFTER (Blaxel):

import { SandboxInstance } from '@blaxel/core'

const sandbox = await SandboxInstance.createIfNotExists({
  name: 'my-sandbox',
  image: 'blaxel/node:latest',
  region: 'us-pdx-1',
  memory: 2048,
  ports: [{ target: 3000, protocol: 'HTTP' }],
  envs: [{ name: 'NODE_ENV', value: 'production' }],
})

// Write a file
await sandbox.fs.write('/app/index.js', 'console.log("hello")')

// Run a command
const result = await sandbox.process.exec({
  command: 'node /app/index.js',
  workingDir: '/app',
  waitForCompletion: true,
})

// Get a shareable preview URL
const preview = await sandbox.previews.createIfNotExists({
  metadata: { name: 'app-preview' },
  spec: { port: 3000, public: true },
})
console.log(`Preview: ${preview.spec?.url}`)

await sandbox.delete()

Python

BEFORE (Modal):

import modal

app = modal.App("my-app")
image = modal.Image.debian_slim().pip_install("numpy")

sandbox = app.spawn_sandbox(
    image=image,
    timeout=900,
    cpu=1,
    memory=2048,
    encrypted_ports=[3000],
)

# Write a file
with sandbox.open("/app/main.py", "w") as f:
    f.write(b'print("hello")')

# Run a command
process = sandbox.exec("bash", "-c", "python3 /app/main.py")
stdout = process.stdout.read()
process.wait()
print(stdout)

# Get tunnel URL
tunnels = sandbox.tunnels()
print(f"Preview: {tunnels[3000].url}")

sandbox.terminate()

AFTER (Blaxel):

import asyncio
from blaxel.core import SandboxInstance

async def main():
    sandbox = await SandboxInstance.create_if_not_exists({
        "name": "my-sandbox",
        "image": "blaxel/py-app:latest",
        "region": "us-pdx-1",
        "memory": 2048,
        "ports": [{"target": 3000, "protocol": "HTTP"}],
        "envs": [{"name": "NODE_ENV", "value": "production"}],
    })

    # Write a file
    await sandbox.fs.write("/app/main.py", 'print("hello")')

    # Run a command
    result = await sandbox.process.exec({
        "command": "python3 /app/main.py",
        "working_dir": "/app",
        "wait_for_completion": True,
    })

    # Get a shareable preview URL
    preview = await sandbox.previews.create_if_not_exists({
        "metadata": {"name": "app-preview"},
        "spec": {"port": 3000, "public": True},
    })
    print(f"Preview: {preview.spec.url}")

    await sandbox.delete()

asyncio.run(main())

Resources

Weekly Installs
1
First Seen
8 days ago
Installed on
amp1
cline1
opencode1
cursor1
kimi-cli1
codex1