Skip to content

Migrate

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:

Terminal
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: bash

Both 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.

RunloopPromigenceWhat changes
Devbox: runloop.devbox.create()Sandbox: Sandbox.create(), AsyncSandbox.create() in Python, promigence fork --count 1 --cap 1 in the CLIA 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_64Templatespromigence 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-dockerfileSnapshots you build: promigence snapshot create --dockerfile PATH --alias NAMEStart 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 SIDNo 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 DURThe 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 DURThe 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 callbacksonStdout 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_httpNo equivalent todayTo 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 VARGive 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:

RunloopPromigenceWhat changes
Named shells: devbox.shell(name), shell_nameNo equivalentEach 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 sshpromigence sandbox ssh SIDPort 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_hostnamesAn outbound allow-list: allow_hosts=[...] in Python, allowHosts on Sandbox.fork in TypeScript, --allow HOST in the CLIallowInternetAccess: 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_SIZETiers: --tier xsmall up to 2xlarge when you build a snapshotA sandbox gets its snapshot's size, and the base is small (2 vCPU, 4 GiB). There are no custom sizes
architecture: "arm64"No equivalentThere is no architecture setting. Write to support@promigence.ai before you move an arm64 workload
Mounts: code_mount, object_mount, file_mount, agent_mountNo equivalentClone 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 objectsNo equivalentKeep artifacts in your own storage and move them with files.read and files.write
Axons and the BrokerNo equivalentKeep events and state in a store of your own
Agent Gateways and MCP HubNo equivalent for MCP HubFor 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:

StepRunloopPromigence
Clientconst runloop = new RunloopSDK();const client = new PromigenceClient();
Createconst devbox = await runloop.devbox.create();const sbx = await Sandbox.create({ client, spendCap: 1 });
Run a commandconst result = await devbox.cmd.exec("echo 'Runloop!!'");const result = await sbx.commands.run("echo 'Hello from Promigence'");
Read the resultawait result.stdout(), result.exitCoderesult.stdout, result.exitCode
Write a fileawait 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 fileconst contents = await devbox.file.read({ file_path: '/home/user/test_results.txt' });const contents = await sbx.files.read("/tmp/main.py");
Snapshotconst snapshot = await devbox.snapshotDisk();const snap = await client.sandboxes.snapshot(sbx.sandboxId);
Suspend, or pauseawait devbox.suspend(); then await devbox.awaitSuspended();await sbx.pause();
Resumeawait devbox.resume(); then await devbox.awaitRunning();await sbx.resume();
Start from the snapshotconst devbox = await snapshot.createDevbox();const copy = await Sandbox.create(snap.snapshot_id, { client, spendCap: 1 });
Shut downawait devbox.shutdown();await sbx.kill();

Python, with the async SDKs on both sides:

StepRunloopPromigence
Clientrunloop = AsyncRunloopSDK()client = AsyncPromigence()
Createdevbox = await runloop.devbox.create()sbx = await AsyncSandbox.create(client=client, spend_cap=1)
Run a commandresult = await devbox.cmd.exec(command="echo 'Runloop!!'")result = await sbx.commands.run("echo 'Hello from Promigence'")
Read the resultawait result.stdout(), result.exit_coderesult.stdout, result.exit_code
Write a fileawait 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 filecontents = await devbox.file.read(file_path="/home/user/test_results.txt")contents = await sbx.files.read("/tmp/main.py")
Snapshotsnapshot = await devbox.snapshot_disk()snap = await client.sandboxes.snapshot(sbx.sandbox_id)
Suspend, or pauseawait devbox.suspend() then await devbox.await_suspended()await sbx.pause()
Resumeawait devbox.resume() then await devbox.await_running()await sbx.resume()
Start from the snapshotawait snapshot.create_devbox()copy = await AsyncSandbox.create(snap["snapshot_id"], client=client, spend_cap=1)
Shut downawait 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:

TypeScript
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: typescript

And the same in Python:

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: python

spendCap: 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:

Terminal
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: bash

A 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.

text
# 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: dockerfile

Then build it, with the same build context:

Terminal
promigence snapshot create --dockerfile ./docker/Dockerfile --context . \
  --verify "python3 --version && jq --version" \
  --alias my-agent-env --wait --wait-timeout 10m
Language: bash

Start 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 RunloopOn 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 mountA 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 logspromigence 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:

  1. Commands: for a command that succeeds and for one that exits 7, the exit code matches and so does the output on both streams.
  2. UTF-8 text: a file with non-ASCII characters reads back exactly as written.
  3. Binary data: bytes with a NUL in them read back unchanged.
  4. Modes and links: after chmod 750 and ln -s, stat -c %a and readlink show the mode and the link you set.
  5. Shell state: no environment variable or working directory carries over between commands. Pass cwd and envs where your Runloop code used a named shell.
  6. User and paths: id -un, echo $HOME and pwd give what your code expects. On Runloop the default user is user, with its home at /home/user.
  7. Pause and resume: a file written before a pause is there after the resume, and nothing your resume logic starts is running twice.
  8. Snapshots: a sandbox started from a snapshot has the same files, the same modes and the same links.
  9. Network: when you used a network policy, an allowed host answers and a host outside the list is refused.
  10. Clean-up: promigence sandbox list --state ready,running,paused lists 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:

Python
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: python

The 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.

RunloopPromigence
BillingUsage billed separately from the plan, with no minimums; rates listed per hour and per secondPer 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 environmentBlueprint builds at $0.252 an hourBilled per second at the size's rate while the build runs, for at most an hour: $0.17 an hour at small
StorageDevbox 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 ProSnapshot 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 pausedNo compute or memory charges while suspended; storage charges continueNot billed by the second while paused
PlansBasic: free, plus usage. Pro: $250 a month plus usage. Enterprise: contact salesFree: $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 creditA 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.

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.

Get started

Leave your work email and we will get you started.

See our Privacy Policy for how we use your email.