Moving from Runloop to Promigence
Move Runloop devboxes and blueprints to Promigence sandboxes: a concept map, the same code in TypeScript and Python, and checks that prove the move.
Also as markdown: /docs/migrate/migrate-from-runloop-to-promigence.md
This notice opens Runloop's documentation: "Runloop is winding down its services. Support for existing customers continues through November 13, 2026 at 11:59 PM PT." (Runloop transition guides, read 25 Oct 2026)
If your agents run in Runloop devboxes, this guide moves them to Promigence sandboxes. You get a map from each Runloop part to what replaces it, one program written both ways in TypeScript and Python, a blueprint rebuilt from its Dockerfile, the checks that prove the move, and both price lists. When Promigence has nothing to match a Runloop feature, the tables say so.
Before you start#
Install the CLI, sign up, then add the SDK your code uses:
npm i -g promigence # the CLI
promigence signup # in your browser: sign up, then authorize the CLI
promigence credits claim # claim your free credit before the first run
npm i @promigence/sdk # the TypeScript SDK (Node.js 20 or newer)
pip install promigence # the Python SDK
Language: bashBoth SDKs read PROMIGENCE_API_KEY, or the key promigence signup stored. Where your Runloop CI read RUNLOOP_API_KEY, set PROMIGENCE_API_KEY to a key from promigence keys create.
A new account runs 20 sandboxes at once, each for up to an hour, on sizes up to large (8 vCPU), with 20 GB of data out a month. If your Runloop fleet needs more, email support@promigence.ai before you move it.
What each Runloop part becomes#
The first table covers what most Runloop code calls. Where the Python name differs, it follows the TypeScript one in parentheses.
| Runloop | Promigence | What changes |
|---|---|---|
Devbox: runloop.devbox.create() | Sandbox: Sandbox.create(), AsyncSandbox.create() in Python, promigence fork --count 1 --cap 1 in the CLI | A sandbox starts from a snapshot or a template, or from the Promigence base when you name neither. Every sandbox runs under a spend cap: spendCap (spend_cap) in dollars; leave it out and the sandbox is held to what its full timeout costs (on the trial, to the credit left), or to your organization's default cap if it has one |
Prebuilt blueprints such as runloop/starter-x86_64 and runloop/universal-ubuntu-24.04-x86_64 | Templates | promigence templates lists them and promigence templates show NAME lists what one has installed. Start a sandbox from one by its name |
Blueprints you built: runloop.blueprint.create(), rli blueprint from-dockerfile | Snapshots you build: promigence snapshot create --dockerfile PATH --alias NAME | Start sandboxes from the alias where you passed blueprint_name. Moving an environment has the steps |
Disk snapshots: devbox.snapshot_disk(), then snapshot.create_devbox() | client.sandboxes.snapshot(id) or promigence sandbox snapshot SID, then Sandbox.create(snapshot_id) | The sandbox keeps running while the snapshot is taken. Commands started in the background end. Snapshotting a sandbox that was given a secret needs --force. Delete a snapshot with client.snapshots.delete(id) or promigence snapshot delete |
Suspend and resume: suspend(), await_suspended(), resume(), await_running() | Pause and resume: pause() and resume(), or promigence sandbox pause SID and promigence sandbox resume SID | No wait calls: each returns when it is done. Runloop keeps the disk, and you restart processes after a resume. On Promigence, commands started with background: true end at the pause; anything else you started keeps running when the sandbox resumes, so guard your restart logic against starting a second copy. A paused sandbox is not billed by the second and does not count against your concurrency limit. Paused sandboxes are kept for 30 days, and resuming starts the 30 days again |
Idle policy: lifecycle.after_idle with on_idle: "suspend" | setAutoPause(seconds) (set_auto_pause), or promigence sandbox autopause SID --after DUR | The shortest idle window is 2 seconds. Idle means no API requests; --mode activity also waits until nothing inside uses CPU, moves network traffic or holds a connection open. on_idle: "shutdown" has no equivalent: the timeout ends a sandbox |
Lifetime: keep_alive_time_seconds, 1 hour by default, and keep_alive() | timeoutMs (timeout_ms) at create, --timeout in the CLI; extend with setTimeout(ms) (set_timeout) or promigence sandbox extend SID --timeout DUR | The default timeout is 15 minutes. An extension sets a new total timeout, and it is refused if it would take the run past its spend cap. Resuming a paused sandbox starts its clock again, for billing and for the timeout |
Commands: devbox.cmd.exec() | sandbox.commands.run() | stdout, stderr and exitCode (exit_code) are fields, so await result.stdout() becomes result.stdout. Unless you pass throwOnExit: false (throw_on_exit=False), a non-zero exit raises CommandExitError. Up to 64 KiB of each output stream is kept by default; maxOutputBytes raises that, and truncated tells you when output was cut |
Long-running commands: exec_async(), execAsync() | commands.run(cmd, { background: true }) (background=True) | It returns a handle with wait() and kill() |
Streaming: the stdout, stderr and output callbacks | onStdout and onStderr (on_stdout, on_stderr) | There is no combined output callback, so pass both |
Files: file.write(), file.read(), file.upload(), file.download() | files.write(path, data), files.read(path) | Text is UTF-8 both ways. For binary data, pass bytes to files.write and read with { format: "bytes" } (format="bytes"). Whole directories move with files.uploadDir and files.downloadDir (upload_dir, download_dir) or promigence files put --recursive. Write absolute paths: Runloop resolves relative ones against /home/user, its default home |
Tunnels: enable_tunnel(), get_tunnel_url(), wake_on_http | No equivalent today | To reach a port from your own machine, forward it over SSH with promigence sandbox ssh. Nothing replaces wake_on_http |
Account secrets: secrets: { ENV_NAME: "SECRET_NAME" } | Stored secrets: promigence secrets set NAME --from-env VAR | Give one to a sandbox by name and it arrives as an environment variable of that name: secrets=["NAME"] in Python, or {"name": "NAME", "as": "ENV_NAME"} for another variable name. In TypeScript the option goes on Sandbox.fork(snapshot, { count: 1, secrets }), and in the CLI it is --secret NAME. Blueprint build secrets have no equivalent: a snapshot you build never holds a secret |
The rest of Runloop's features, most of which have no Promigence equivalent:
| Runloop | Promigence | What changes |
|---|---|---|
Named shells: devbox.shell(name), shell_name | No equivalent | Each commands.run starts a new shell, as Runloop's default exec does. Pass cwd and envs on each call, or chain dependent steps in one command. An interactive session needs a terminal: sandbox.pty.create() or promigence sandbox shell SID |
SSH: rli devbox ssh | promigence sandbox ssh SID | Port forwarding with -L works, as do scp and sftp, and --config prints a block for an IDE. Access lasts an hour at most (--ttl) |
Network policies: allowed_hostnames | An outbound allow-list: allow_hosts=[...] in Python, allowHosts on Sandbox.fork in TypeScript, --allow HOST in the CLI | allowInternetAccess: false or --network none starts a sandbox with no network. disableInternet() or promigence sandbox network SID --cut turns off a running sandbox's outbound access for good, and promigence sandbox network SID lists the hosts it was refused |
Sizes: resource_size_request, CUSTOM_SIZE | Tiers: --tier xsmall up to 2xlarge when you build a snapshot | A sandbox gets its snapshot's size, and the base is small (2 vCPU, 4 GiB). There are no custom sizes |
architecture: "arm64" | No equivalent | There is no architecture setting. Write to support@promigence.ai before you move an arm64 workload |
Mounts: code_mount, object_mount, file_mount, agent_mount | No equivalent | Clone code in a --setup step at a pinned commit, or build from a connected GitHub repository with --repo. Copy files in with files.write or a COPY line in your Dockerfile. promigence agent start starts Claude Code, Codex or OpenCode in a new sandbox |
| Storage objects | No equivalent | Keep artifacts in your own storage and move them with files.read and files.write |
| Axons and the Broker | No equivalent | Keep events and state in a store of your own |
| Agent Gateways and MCP Hub | No equivalent for MCP Hub | For a model key, the nearest match is a stored secret bound to a host (promigence secrets set NAME --host HOST), described in the CLI reference |
The same program, side by side#
Each Runloop cell below quotes a page of Runloop's docs, read 25 Oct 2026: the quickstart, the guides to commands and files, the snapshot and lifecycle pages, and the Python SDK reference for create_devbox. Next to each one, the Promigence cell does the same step.
TypeScript:
| Step | Runloop | Promigence |
|---|---|---|
| Client | const runloop = new RunloopSDK(); | const client = new PromigenceClient(); |
| Create | const devbox = await runloop.devbox.create(); | const sbx = await Sandbox.create({ client, spendCap: 1 }); |
| Run a command | const result = await devbox.cmd.exec("echo 'Runloop!!'"); | const result = await sbx.commands.run("echo 'Hello from Promigence'"); |
| Read the result | await result.stdout(), result.exitCode | result.stdout, result.exitCode |
| Write a file | await devbox.file.write({ file_path: '/home/user/main.py', contents: 'print("Hello, World!")' }); | await sbx.files.write("/tmp/main.py", 'print("Hello, World!")'); |
| Read a file | const contents = await devbox.file.read({ file_path: '/home/user/test_results.txt' }); | const contents = await sbx.files.read("/tmp/main.py"); |
| Snapshot | const snapshot = await devbox.snapshotDisk(); | const snap = await client.sandboxes.snapshot(sbx.sandboxId); |
| Suspend, or pause | await devbox.suspend(); then await devbox.awaitSuspended(); | await sbx.pause(); |
| Resume | await devbox.resume(); then await devbox.awaitRunning(); | await sbx.resume(); |
| Start from the snapshot | const devbox = await snapshot.createDevbox(); | const copy = await Sandbox.create(snap.snapshot_id, { client, spendCap: 1 }); |
| Shut down | await devbox.shutdown(); | await sbx.kill(); |
Python, with the async SDKs on both sides:
| Step | Runloop | Promigence |
|---|---|---|
| Client | runloop = AsyncRunloopSDK() | client = AsyncPromigence() |
| Create | devbox = await runloop.devbox.create() | sbx = await AsyncSandbox.create(client=client, spend_cap=1) |
| Run a command | result = await devbox.cmd.exec(command="echo 'Runloop!!'") | result = await sbx.commands.run("echo 'Hello from Promigence'") |
| Read the result | await result.stdout(), result.exit_code | result.stdout, result.exit_code |
| Write a file | await devbox.file.write(file_path="/home/user/main.py", contents='print("Hello, World!")') | await sbx.files.write("/tmp/main.py", 'print("Hello, World!")') |
| Read a file | contents = await devbox.file.read(file_path="/home/user/test_results.txt") | contents = await sbx.files.read("/tmp/main.py") |
| Snapshot | snapshot = await devbox.snapshot_disk() | snap = await client.sandboxes.snapshot(sbx.sandbox_id) |
| Suspend, or pause | await devbox.suspend() then await devbox.await_suspended() | await sbx.pause() |
| Resume | await devbox.resume() then await devbox.await_running() | await sbx.resume() |
| Start from the snapshot | await snapshot.create_devbox() | copy = await AsyncSandbox.create(snap["snapshot_id"], client=client, spend_cap=1) |
| Shut down | await devbox.shutdown() | await sbx.kill() |
Here is the whole Promigence program in TypeScript, which also starts a second sandbox from the snapshot and cleans up after itself. Run it as an ES module, for the top-level await:
import { PromigenceClient, Sandbox } from "@promigence/sdk";
const client = new PromigenceClient(); // reads PROMIGENCE_API_KEY, or the key signup stored
const sbx = await Sandbox.create({ client, spendCap: 1 });
try {
const result = await sbx.commands.run("echo 'Hello from Promigence'");
console.log(result.stdout, result.exitCode);
await sbx.files.write("/tmp/main.py", 'print("Hello, World!")');
console.log(await sbx.files.read("/tmp/main.py"));
// A snapshot of the sandbox as it is now: new sandboxes can start from it
const snap = await client.sandboxes.snapshot(sbx.sandboxId);
await sbx.pause();
await sbx.resume();
console.log(await sbx.files.read("/tmp/main.py")); // still there
const copy = await Sandbox.create(snap.snapshot_id, { client, spendCap: 1 });
console.log(await copy.files.read("/tmp/main.py"));
await copy.kill();
await client.snapshots.delete(snap.snapshot_id);
} finally {
await sbx.kill();
}
Language: typescriptAnd the same in Python:
import asyncio
from promigence import AsyncPromigence, AsyncSandbox
async def main() -> None:
async with AsyncPromigence() as client: # reads PROMIGENCE_API_KEY, or the key signup stored
sbx = await AsyncSandbox.create(client=client, spend_cap=1)
try:
result = await sbx.commands.run("echo 'Hello from Promigence'")
print(result.stdout, result.exit_code)
await sbx.files.write("/tmp/main.py", 'print("Hello, World!")')
print(await sbx.files.read("/tmp/main.py"))
# A snapshot of the sandbox as it is now: new sandboxes can start from it
snap = await client.sandboxes.snapshot(sbx.sandbox_id)
await sbx.pause()
await sbx.resume()
print(await sbx.files.read("/tmp/main.py")) # still there
copy = await AsyncSandbox.create(snap["snapshot_id"], client=client, spend_cap=1)
print(await copy.files.read("/tmp/main.py"))
await copy.kill()
await client.snapshots.delete(snap["snapshot_id"])
finally:
await sbx.kill()
asyncio.run(main())
Language: pythonspendCap: 1 caps what the sandbox may spend at one dollar. Leave it out and the sandbox is capped at what its full timeout costs (on the trial, at the credit left).
Moving an environment#
A Runloop blueprint becomes a snapshot that you build once and start sandboxes from by its alias. Builds take an image or a Dockerfile, your setup steps and a verify command. If the verify command does not exit 0, nothing is stored. A build is billed per second while it runs, whether it verifies or not, up to $0.17 at the default small size.
Runloop's blueprint guide uses launch_commands to install jq (Runloop blueprints, read 25 Oct 2026). Every launch command becomes a --setup step, and the verify command proves the step worked:
promigence snapshot create --image ubuntu:24.04 \
--setup "apt-get update && apt-get install -y jq" \
--verify "jq --version" \
--alias docs-template --wait --wait-timeout 10m
Language: bashA blueprint built from a Dockerfile, with rli blueprint from-dockerfile --name my-agent-env --dockerfile ./docker/Dockerfile --build-context ., keeps its Dockerfile. First change any FROM runloop: line. That prefix names Runloop's own images and blueprints (Runloop Dockerfile customization, read 25 Oct 2026), so start from a public image instead and install what your code used from Runloop's starter image. The Runloop devbox overview lists what that image includes.
# was: FROM runloop:runloop/starter-x86_64
FROM ubuntu:24.04
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl git jq python3 python3-pip \
&& rm -rf /var/lib/apt/lists/*
Language: dockerfileThen build it, with the same build context:
promigence snapshot create --dockerfile ./docker/Dockerfile --context . \
--verify "python3 --version && jq --version" \
--alias my-agent-env --wait --wait-timeout 10m
Language: bashStart sandboxes from the alias with Sandbox.create("my-agent-env", { spendCap: 1 }) in TypeScript, AsyncSandbox.create("my-agent-env", spend_cap=1) in Python, or promigence fork --snapshot my-agent-env --count 1 --cap 1. Other blueprint settings move to flags of snapshot create:
| On Runloop | On Promigence |
|---|---|
resource_size_request for each devbox | --tier on the build, for every sandbox from the snapshot: --tier medium is 4 vCPU and 16 GiB |
| A server started by a background launch command | --start CMD, started at build time and already running in every sandbox made from the snapshot |
| A code mount | A git clone in a --setup step at a pinned commit, or --repo owner/name for a connected GitHub repository, with --track to rebuild on every push |
rli blueprint logs | promigence snapshot status BUILD_ID --log 80 for the last 80 lines of the build log |
Promigence cannot import a Runloop snapshot. To keep data from one, start a temporary devbox from it and export the directories you need, as Runloop's transition guides suggest (Runloop transition guides, read 25 Oct 2026). Pack them with tar in that devbox and fetch the archive with file.download. Unpack it on your machine, copy it into a sandbox with promigence files put --recursive, and keep the result with promigence sandbox snapshot SID --alias NAME.
Check the move#
Runloop's guides for moving devboxes to Tensorlake, Modal and Freestyle start with a script that checks command execution and UTF-8 file round trips, then extend the checks with a validation plan for the workload (Runloop transition guides, read 25 Oct 2026). Run the same checks on Promigence before you switch any traffic:
- Commands: for a command that succeeds and for one that exits 7, the exit code matches and so does the output on both streams.
- UTF-8 text: a file with non-ASCII characters reads back exactly as written.
- Binary data: bytes with a NUL in them read back unchanged.
- Modes and links: after
chmod 750andln -s,stat -c %aandreadlinkshow the mode and the link you set. - Shell state: no environment variable or working directory carries over between commands. Pass
cwdandenvswhere your Runloop code used a named shell. - User and paths:
id -un,echo $HOMEandpwdgive what your code expects. On Runloop the default user isuser, with its home at/home/user. - Pause and resume: a file written before a pause is there after the resume, and nothing your resume logic starts is running twice.
- Snapshots: a sandbox started from a snapshot has the same files, the same modes and the same links.
- Network: when you used a network policy, an allowed host answers and a host outside the list is refused.
- Clean-up:
promigence sandbox list --state ready,running,pausedlists none of your test sandboxes, and the test snapshots are gone.
This script runs checks 1 to 4 and checks 7 and 8 in one go, printing a line per check and exiting non-zero if any fails:
import asyncio
import uuid
from promigence import AsyncPromigence, AsyncSandbox
async def main() -> None:
token = uuid.uuid4().hex
path = f"/tmp/transition-probe-{token}"
text = f"transition probe {token}: café, naïve, 東京\n"
checks: dict[str, bool] = {}
async with AsyncPromigence() as client:
sbx = await AsyncSandbox.create(client=client, spend_cap=1)
try:
r = await sbx.commands.run("printf 'stdout-probe\\n'; printf 'stderr-probe\\n' >&2")
checks["command exit 0, stdout, stderr"] = (r.exit_code, r.stdout, r.stderr) == (0, "stdout-probe\n", "stderr-probe\n")
r = await sbx.commands.run("exit 7", throw_on_exit=False)
checks["command exit 7"] = r.exit_code == 7
await sbx.files.write(f"{path}.txt", text)
checks["UTF-8 file round trip"] = await sbx.files.read(f"{path}.txt") == text
data = b"binary\x00fixture"
await sbx.files.write(f"{path}.bin", data)
checks["binary file round trip"] = await sbx.files.read(f"{path}.bin", format="bytes") == data
await sbx.commands.run(f"chmod 750 {path}.bin && ln -s {path}.bin {path}.link")
manifest = f"stat -c %a {path}.bin && readlink {path}.link"
r = await sbx.commands.run(manifest)
checks["file mode and symlink"] = r.stdout == f"750\n{path}.bin\n"
await sbx.pause()
await sbx.resume()
checks["files after pause and resume"] = await sbx.files.read(f"{path}.txt") == text
snap = await client.sandboxes.snapshot(sbx.sandbox_id)
copy = await AsyncSandbox.create(snap["snapshot_id"], client=client, spend_cap=1)
try:
checks["files in a sandbox started from the snapshot"] = await copy.files.read(f"{path}.txt") == text
r = await copy.commands.run(manifest)
checks["mode and symlink in that sandbox"] = r.stdout == f"750\n{path}.bin\n"
finally:
await copy.kill()
await client.snapshots.delete(snap["snapshot_id"])
finally:
await sbx.kill()
for name, ok in checks.items():
print("PASS" if ok else "FAIL", name)
raise SystemExit(0 if checks and all(checks.values()) else 1)
asyncio.run(main())
Language: pythonThe same calls exist in the TypeScript SDK, under the names in the tables above.
Prices#
Each column comes from that company's own pages, read on 25 Oct 2026: Runloop pricing on one side; on the other, the Promigence pricing section, pricing page and FAQ, with the build price from the quickstart, the paused rule from Limits and notices and the storage rule from the errors reference.
| Runloop | Promigence | |
|---|---|---|
| Billing | Usage billed separately from the plan, with no minimums; rates listed per hour and per second | Per second |
| CPU | $0.108 per CPU-hour | $0.0504 per vCPU-hour |
| Memory | $0.0252 per GB-hour | $0.0162 per GiB-hour |
| The vendor's own example | "A standard 2-CPU / 4 GB devbox running 8 active hours per day costs approximately $2.73/day in compute" | "$0.17 an hour for a small sandbox (2 vCPU, 4 GB)" |
| Building an environment | Blueprint builds at $0.252 an hour | Billed per second at the size's rate while the build runs, for at most an hour: $0.17 an hour at small |
| Storage | Devbox storage at $0.00034236 per GB-hour; blueprint storage, snapshot storage and object storage at $0.000072 per GB-hour each; 100 GB free on Basic, 1 TB on Pro | Snapshot storage is included in every plan. Past your account's storage limit, a new snapshot is refused with storage_limit_reached, and nothing already stored is removed |
| Suspended or paused | No compute or memory charges while suspended; storage charges continue | Not billed by the second while paused |
| Plans | Basic: free, plus usage. Pro: $250 a month plus usage. Enterprise: contact sales | Free: $0, pay as you go. Pro: $50 a month. Max: $250 a month, or $500 at its higher level. Enterprise: custom. "Plans are set up with you by email." |
| Free credit | A Pro trial with a "$50 Usage Credit", "No Credit Card Required" | "Every plan starts with $50 of free credit." |
Help with the move#
Write to support@promigence.ai with what you run on Runloop today (the SDK and its version, your blueprints, snapshot sizes and network rules), and we will plan the move with you. The founder will help with your migration personally.
Sources#
Every page below was read on 25 Oct 2026. Prices and features change, and each company's own page is the authority.
| Company | Pages | Checked |
|---|---|---|
| Runloop | Transition guides and the notice on every docs page, Quickstart, Execute commands, Named shells, Files, Snapshots, Lifecycle, Lifetime management, Tunnels, Account secrets, Network policies, Sizes, Architecture, User profile, Mounts, SSH, Blueprints, Dockerfile customization, Devbox overview, Python SDK reference, Pricing | 25 Oct 2026 |
| Promigence | Pricing section, Pricing page, FAQ, Quickstart, Limits and notices, Errors reference, CLI reference | 25 Oct 2026 |
If anything on this page is out of date or wrong, write to support@promigence.ai and we will correct it and note the date.