Migrate from Runloop
@solarisdk/runloop-compat (TypeScript) and solari-runloop-compat (Python) expose the Runloop devbox API on top of Solari sandboxes. Change the import, set a Solari API key, and keep your devbox code. Anything Solari cannot do throws a clear SolariCompatUnsupported error naming the feature; nothing is silently ignored.
“Runloop” and “Devbox” are Runloop’s product names and are used here only to describe API compatibility. These packages are made by Solari and are not endorsed by Runloop. They need a Solari API key: a Runloop key is never sent to Solari.
Install
npm install @solarisdk/runloop-compat # TypeScript
pip install solari-runloop-compat # Python (import solari_runloop)Change the import
// before: import { RunloopSDK } from "@runloop/api-client"
import { RunloopSDK } from "@solarisdk/runloop-compat"
const sdk = new RunloopSDK({ bearerToken: process.env.SOLARI_API_KEY })
const devbox = await sdk.devbox.create({ name: "demo" })
await devbox.file.write({ file_path: "main.py", contents: "print('hello from a sandbox')" })
const result = await devbox.cmd.exec("python3 main.py")
console.log(await result.stdout(), result.exitCode)
await devbox.shutdown()# before: from runloop_api_client import RunloopSDK
from solari_runloop import RunloopSDK
runloop = RunloopSDK() # reads SOLARI_API_KEY
with runloop.devbox.create(name="demo") as devbox:
devbox.file.write(file_path="main.py", contents="print('hello from a sandbox')")
result = devbox.cmd.exec("python3 main.py")
print(result.stdout(), result.exit_code)Runloop (the REST layer, client.devboxes.*), RunloopSDK (the object layer) and, in Python, AsyncRunloop and AsyncRunloopSDK are exported under Runloop’s names, together with Runloop’s error classes.
Suspend, resume and snapshot
await devbox.suspend() // Solari pause: keeps memory and disk
await devbox.resume()
const snapshot = await devbox.snapshotDisk({ name: "after-setup" })
const clone = await snapshot.createDevbox({ name: "clone" })
await clone.shutdown()
await snapshot.delete()Configuration
- API key. The
bearerToken/apiKeyoption (Python:bearer_token), thenSOLARI_API_KEY, thenRUNLOOP_API_KEY. Only a Solari key (slr_…) is ever used. A Runloop key left in the environment is skipped with a warning and is never sent anywhere; with no Solari key the constructor throws. - Base URL. The
baseURLoption, thenSOLARI_BASE_URL, thenRUNLOOP_BASE_URL, defaulthttps://api.getsolari.com.RUNLOOP_BASE_URLis honoured only when it points at Solari or loopback. Passing any other host explicitly throws, so your Solari key can never reach Runloop’s servers.
What works
Devboxes
| Runloop | On Solari | Notes |
|---|---|---|
devbox.create, createFromSnapshot, createFromBlueprintName/Id | Works | Waits until the devbox is running. name, metadata, environment_variables, file_mounts, entrypoint and launch_commands are applied after create; a failing launch command fails the create. |
devbox.fromId, list, getInfo, awaitRunning, awaitSuspended | Works | IDs are Solari’s opaque ids, not dbx_… ones. |
shutdown, suspend, resume, resumeAsync, keepAlive | Works | suspend and resume are Solari pause and resume (memory and disk). keepAlive re-arms the idle window the devbox was created with. |
launch_parameters.resource_size_request | Differs | Rounded up to whole vCPUs and capped at 8 vCPU and 16 GiB, with a warning. Nothing errors. |
launch_parameters.keep_alive_time_seconds, after_idle | Works | Become Solari’s idle window; on_idle suspend pauses, anything else kills. |
devbox.update, devbox.logs | Differs | update (name and metadata) needs a gateway that supports it and throws SolariCompatUnsupported otherwise. logs uses the gateway’s log endpoint when it has one; until then it returns only the output of commands run through the same client object, not across processes. |
Commands, files and snapshots
| Runloop | On Solari | Notes |
|---|---|---|
cmd.exec | Works | Runs from the home directory in a login shell. On a devbox built from an image (FROM <image>) it runs a non-login shell so the image’s own PATH survives, which means ~/.profile and /etc/profile.d are not read there. Output is never truncated. stdout and stderr callbacks receive lines. |
cmd.execAsync, Execution.result, getState, kill, sendStdIn | Differs | Work inside the process that started the command. closeStdIn is not supported. |
shell(name) | Differs, Python only | One persistent bash per name, so the working directory and environment carry over. Output reaches your callbacks when the command ends, not while it runs, and a shell command cannot be killed or given stdin; end the shell with shell.delete(). It throws SolariCompatUnsupported on a sandbox image that predates named shells. The TypeScript package always throws SolariCompatUnsupported. |
file.read, write, upload, download | Works | Relative paths resolve against the home directory; parent directories are created on write. |
snapshotDisk, snapshotDiskAsync | Differs | Synchronous, and keeps memory and disk. commit_message and metadata are echoed on the result, not stored. |
snapshot.getInfo, delete, list, fromId, createDevbox | Works | delete returns when the snapshot is really gone. Deleting a snapshot that has live children is a ConflictError. |
Tunnels, blueprints and secrets
| Runloop | On Solari | Notes |
|---|---|---|
getTunnel, getTunnelUrl, net.enableTunnel, removeTunnel | Differs | Uses Solari’s tunnel and preview URLs. getTunnelUrl(port) returns a signed URL; treat it as a credential. |
blueprint.create and the Blueprint object | Differs | Builds a Solari template from a Dockerfile subset: RUN, ENV, ARG, WORKDIR, ENTRYPOINT, CMD, COPY and ADD. FROM Ubuntu or Debian uses Solari’s base; any other public image needs a gateway with native image builds, and the call throws SolariCompatUnsupported without one. Multi-stage builds, USER, VOLUME, HEALTHCHECK and ONBUILD are not supported. |
storageObject.uploadFromDir, uploadFromBytes, asBuildContext | Differs | A build context is held by the client and embedded in the build recipe (about 700 KB compressed at most). Never put secrets in it. |
secrets.create, retrieve, update, list, delete; create({ secrets }) | Differs | Solari account secrets are write-only: no call returns a value. Injected as environment variables at create. |
Not supported
These throw SolariCompatUnsupported, which is an APIError:
onEvictandcancelOnEvict, SSH keys, andcloseStdIn.- Object mounts,
code_mounts,gateways,mcp,network_policy_id,required_servicesand any create parameter the package does not know. agent,axon,networkPolicy,gatewayConfig,mcpConfigand the matchingclient.agents,client.axons,client.networkPolicies,client.gatewayConfigsandclient.mcpConfigscalls.- Non-empty
defaultHeaders,defaultQueryandhttpAgent.
The full matrix, with every call, is in the COMPAT.md that ships with each package.
Errors
Errors keep Runloop’s classes, so existing catch blocks keep working:
| Runloop | On Solari | Notes |
|---|---|---|
400 | BadRequestError | |
401 | AuthenticationError | |
402, 403 | PermissionDeniedError | status keeps the real code, 402 or 403. |
404 | NotFoundError | For example a devbox that does not exist. |
409 | ConflictError | |
422 | UnprocessableEntityError | |
429 | RateLimitError | |
5xx | InternalServerError | |
network, timeout | APIConnectionError, APITimeoutError |
import { NotFoundError, SolariCompatUnsupported } from "@solarisdk/runloop-compat"
try {
await sdk.devbox.fromId("does-not-exist").getInfo()
} catch (e) {
if (e instanceof NotFoundError) console.log(e.status) // 404
}
try {
await devbox.shell("build") // TypeScript: not supported
} catch (e) {
console.log(e instanceof SolariCompatUnsupported) // true
}.error carries Solari’s error body (code, message, retryable).
