Sessions
Persistent sandboxes that survive across agent tool calls.
Sessions are the primary primitive for agents. Create once, call exec/files/expose many times, destroy when done.
All requests require Authorization: Bearer inis_... and Content-Type: application/json. Path parameter {id} is the session ID.
Prefer the CLI (inis sessions, inis exec --session) or an SDK for day-to-day use.
Create
Optionally set an Idempotency-Key header to make the create retry-safe — see Idempotent retries below.
Request body
{
"size": "medium",
"volume_id": "vol_...",
"max_lifetime_ms": 3600000,
"idle_timeout_ms": 300000,
"on_pty_detach": "pause",
"paused_ttl_ms": 86400000,
"egress": { "mode": "deny", "allow": ["api.openai.com"] }
}| Field | Required | Description |
|---|---|---|
size | no | small, medium, or large. Omitted uses the server's default tier |
volume_id | no | Attach a persistent volume you already created via POST /v1/volumes. Naming an id you don't own, or one that doesn't exist, is a 404. See Volumes |
max_lifetime_ms | no | Hard cap on session age. Omit for no cap of your own — every session still force-pauses after 24 hours continuously live regardless, as a fleet backstop (see Session lifecycle). Reaching your own max_lifetime_ms auto-pauses the session permanently — it cannot resume again after this. Capped at 24 hours; cannot be combined with a negative paused_ttl_ms (rejected 400) |
idle_timeout_ms | no | Auto-pause after idle period. Omit for no auto-pause. Capped at 1 hour |
name | no | Caller-assigned label for the session |
labels | no | Arbitrary key/value metadata |
external_id | no | Caller-owned index key (e.g. your own user/workflow/thread id) to look this session up later without maintaining your own mapping table. See Index by your own id below |
template | no | Base environment to start from. Empty or base uses the default base template; name@version pins a specific version. See Templates |
egress | no | Outbound allowlist — { "mode": "allow" | "deny", "allow": [...] }. Omitted keeps full public egress. See Networking & egress |
no_sudo | no | Run in hardened mode for untrusted code — the default user loses passwordless sudo. Omitted leaves sudo available |
on_pty_detach | no | What the server does when the last terminal (PTY) client detaches: pause (default), keep_live, or destroy. No effect for API-only sessions |
paused_ttl_ms | no | Auto-destroy a paused session after this long. Omit/0 for the server default (30 days); negative for never. A negative value cannot be combined with a positive max_lifetime_ms (rejected 400) — that pause is permanent, so "never expire" would leave it paused and billed forever |
destroy_on_completion | no | Skip persistence and tear the session down as soon as the first command completes |
artifacts | no | { "paths": [...] } to auto-capture on teardown. See Artifacts |
project_id | no | Attribute the session to this project instead of the credential's default. A project-scoped key must omit this or match its own project (403 otherwise) |
command_capture | no | Command metadata retained in the execution log: hash, executable (default), or full (explicit content-capture opt-in) |
env | no | Plain, non-sensitive environment variables (config/flags), set for every process this session's exec/PTY spawns. See Environment and secrets |
secrets | no | Sensitive environment variables — encrypted at rest, never echoed back. See Environment and secrets |
connections | no | Bind the session to specific external API origins, injecting a credential you supply on the way out — code calls the provider's real hostname unmodified. See Connections |
Response 200
{
"session_id": "2476b0a6-722e-4bd9-9f25-771ccbdd53bd",
"state": "creating",
"created_at": "2026-06-22T13:46:00Z"
}Most creates return state: "creating" immediately and finish provisioning in the background. Poll GET /v1/sessions/{id} until live (or failed). SDKs that wait on create hide this; raw HTTP clients should not assume live on the create response. When present, status_detail / status_reason explain provisioning progress or failure.
Environment and secrets
Both env and secrets are maps of string to string, set once at create and injected into every process this session's exec/PTY spawns:
{
"env": { "LOG_LEVEL": "debug" },
"secrets": { "API_KEY": "sk-..." }
}session = client.sessions.create(
env={"LOG_LEVEL": "debug"},
secrets={"API_KEY": "sk-..."},
)const session = await client.sessions.create({
env: { LOG_LEVEL: "debug" },
secrets: { API_KEY: "sk-..." },
});env is plain config — stored as-is and echoed back on GET /v1/sessions/{id}. secrets is for anything sensitive: encrypted at rest immediately on create, never logged, and decrypted only in memory at the moment a guest request is sent. A secret is never echoed back — GET a session to see only the configured secret names (secret_names), never values. Creating a session with secrets on a server without a secret-encryption key configured fails with 503.
Pairing secrets with a deny-by-default egress policy means the code can use the key but can't send it to an arbitrary destination.
Secrets and saved state. This protects the value in storage and in transit, not inside the running session. Once a secret is set in a process's environment, pausing or checkpointing that session saves its memory, which can include the secret's plaintext for any process that was holding it. That saved state is stored on the exec node and, if cold archiving is enabled for your plan, uploaded to object storage — currently under the storage provider's default at-rest encryption, not a key of ours tied to your secret. Deleting a secret does not retroactively scrub state already saved. Treat any secret injected into a session as retrievable for as long as saved state containing it exists.
Index by your own id
external_id stamps a session with a caller-owned key — a user id, workflow id, or thread id from your own system — so you can find it later without maintaining a separate external_id -> session_id mapping table on your side:
{ "external_id": "workflow-42" }session = client.sessions.create(external_id="workflow-42")
sessions, _ = client.sessions.list(external_id="workflow-42")const session = await client.sessions.create({ externalId: "workflow-42" });
const { sessions } = await client.sessions.list({ externalId: "workflow-42" });It's echoed back on every session read (GET /v1/sessions/{id}, list, create) and filterable via GET /v1/sessions?external_id=.... Not unique — unlike name, multiple sessions may share one external_id (e.g. every session a long-running workflow spins up), so a filtered list can return more than one result. Capped at 256 characters; must be valid UTF-8 with no control characters.
Idempotent retries
Set an Idempotency-Key header on POST /v1/sessions to make a create safe to retry — the case where your process crashes, times out, or loses the response after the request already reached the server, and you don't know whether the session was actually created:
Idempotency-Key: order-42-session-createsession = client.sessions.create(idempotency_key="order-42-session-create")const session = await client.sessions.create({ idempotencyKey: "order-42-session-create" });A retry with the same key, the same account, and the same request body, within 24 hours, returns the original session (200, with an Idempotent-Replayed: true response header) instead of creating a second one or 409ing on a name clash. A concurrent duplicate request for a key that's still mid-create gets 409 idempotency_key_in_progress (retryable — respect the Retry-After header) rather than racing a second session into existence; it resolves to the same session once the in-flight request finishes. Omit the header for an ordinary, non-idempotent create. Max 255 characters; letters, digits, and _.:=+/- only.
Reusing a key with a different request body is an error, not a silent replay. If a key is reused with a different name, template, size, or any other create field, the request is rejected with 409 conflict rather than returning the first request's session — the same contract Stripe and most idempotent-write APIs use. Use a new key for a genuinely different request; only retry with the same key when the body is byte-for-byte the same logical request.
Get
Response 200 — session metadata: session_id, state, volume_id, created_at, last_active_at, size, vcpus, mem_mb, template, no_sudo, on_pty_detach, paused_ttl_ms, egress, exposed_previews, archive, snapshot_tier (paused sessions only), env, secret_names (configured secret names only — never values), external_id (if set at create — see Index by your own id). See the OpenAPI spec for the full shape.
List
List your sessions, newest first. Filter with ?state= (live, paused, creating, or ended) and ?external_id= (exact match — see Index by your own id; not unique, so this can return more than one session). ?limit= caps every state. ?cursor= only pages state=ended (see below) — a live/paused/unfiltered list is a flat query capped at limit, with no cursor to page the rest with; narrow the filter or raise limit instead. See Pagination for the difference in practice.
state=ended returns the history of destroyed sessions — terminal rows that carry ended_at and end_reason (client_destroy, shell_exit, on_detach_destroy, max_lifetime, paused_ttl, or error). History is metadata only; the saved snapshot is deleted when the session is destroyed, or 30 days after it ended if the machine it ran on was lost or that deletion failed.
Destroy
Optionally pass ?reason= (client_destroy — the default — or shell_exit) to record why the session ended on its history row.
Response 204 — empty body.
Exec
Request body
{
"command": ["python3", "-c", "print(42)"],
"cwd": "/workspace",
"timeout_ms": 30000
}Response 200
{
"stdout": "42\n",
"stderr": "",
"exit_code": 0,
"duration_ms": 41,
"timed_out": false
}Streaming exec
Same request body as above. Instead of one buffered response, the connection stays open and streams the command's stdout/stderr as it's produced, as Server-Sent Events — for a long build, a test run, or anything else where you want output as it happens instead of after the fact:
event: stdout
data: <base64>
event: stderr
data: <base64>
event: exit
data: {"exit_code":0,"timed_out":false,"duration_ms":1042}stdout/stderr payloads are base64 (output is arbitrary bytes, not necessarily UTF-8) and arrive chunk-by-chunk with no total-output cap, unlike the buffered mode's 4 MiB truncation. The stream always ends in exactly one of event: exit (the command ran to completion or its timeout fired — data is the same shape as the buffered response minus stdout/stderr) or event: error (a guest-side failure). Disconnecting before either arrives kills the command — it does not keep running in the background the way a named background process does.
for chunk in session.exec_stream(["python3", "-u", "train.py"]):
if chunk.stream == "stdout":
print(chunk.data, end="") # already a str
elif chunk.stream == "exit":
print(f"exit code: {chunk.exit_code}")for await (const chunk of session.execStream(["python3", "-u", "train.py"])) {
if (chunk.stream === "stdout") process.stdout.write(chunk.data);
else if (chunk.stream === "exit") console.log(`exit code: ${chunk.exitCode}`);
}Files
Reading, listing, finding, and searching are all a single GET with query parameters; writing is a PUT with a body (or PUT .../files/batch for several small files at once).
Query params: path (required for read; defaults to /workspace for list/find/grep), op (read default, list, find, grep), and encoding (read only — text default, or base64 for binary files).
Read — GET /v1/sessions/{id}/files?path=/workspace/out.txt
Response includes "content": "hello".
Read binary — GET /v1/sessions/{id}/files?path=/workspace/logo.png&encoding=base64
Response's content is base64; encoding echoes back "base64" so the caller knows how to decode it.
List — GET /v1/sessions/{id}/files?path=/workspace&op=list
Response includes "entries": ["out.txt", "data.csv"].
Write
{
"path": "/workspace/out.txt",
"content": "hello",
"encoding": "text"
}encoding is text (default, content written as-is) or base64 (decoded first, for binary files).
session.write_file("/workspace/out.txt", "hello")
session.write_file("/workspace/logo.png", png_bytes) # bytes auto-base64-encodedawait session.writeFile("/workspace/out.txt", "hello");
await session.writeFile("/workspace/logo.png", pngBytes); // Uint8Array auto-base64-encodedBatch write
Scaffold several small files in one call instead of one PUT each. Capped at 64 files / 8 MiB total content — for one large file (a dataset, a binary), stream it in with session.upload_file / session.uploadFile instead (PUT /v1/sessions/{id}/files/stream, no batch cap); for a whole directory, zip it and upload the archive — see bulk file transfer. Not fail-fast: the response carries a {path, ok, error?} result per file, so one bad path doesn't sink the rest of the batch.
{
"files": [
{ "path": "/workspace/a.py", "content": "print('a')" },
{ "path": "/workspace/b.py", "content": "print('b')" }
]
}results = session.write_files([
{"path": "/workspace/a.py", "content": "print('a')"},
{"path": "/workspace/b.py", "content": "print('b')"},
])const results = await session.writeFiles([
{ path: "/workspace/a.py", content: "print('a')" },
{ path: "/workspace/b.py", content: "print('b')" },
]);Find and grep
GET /v1/sessions/{id}/files also takes op=find (glob match on paths) and op=grep (regex match on file contents).
Find — ?path=/workspace&op=find&pattern=*.py
pattern is a glob: an exact path, */?/[...], or a trailing /** for recursive. Response includes "paths": [...] and "truncated" — set (not an error) when max_results caps the match set.
Grep — ?path=/workspace&op=grep&pattern=TODO&file_pattern=*.go&context_lines=1
pattern is a regular expression (Go RE2 syntax — no PCRE lookaround/backreferences) matched against each line. Optional params: file_pattern (restrict by base name glob), case_sensitive (default false), context_lines, exclude_dirs (comma-separated), max_results (caps matching files, not match lines). Response includes "matches": [{ "path", "line_number", "line", "before"?, "after"? }], "files_searched", and "truncated".
Both find and grep enforce internal scan budgets (10,000 files; grep also caps at 256 MiB read) so a pattern that matches nothing still returns promptly, with truncated: true, instead of scanning the whole workspace.
found = session.find_files("*.py", path="/workspace")
hits = session.grep_files("TODO", path="/workspace", file_pattern="*.go", context_lines=1)const found = await session.findFiles("*.py", { path: "/workspace" });
const hits = await session.grepFiles("TODO", { path: "/workspace", filePattern: "*.go", contextLines: 1 });Background processes
Named, detached commands that keep running — and keep producing output — after the call returns and after the caller disconnects, independent of any one HTTP request.
Request body
{
"name": "web",
"command": ["python3", "-m", "http.server", "8080"],
"cwd": "/workspace",
"keep_alive": false
}name is caller-chosen (lowercase alphanumerics and hyphens) and must be free — starting a second process under a name still in use by a running process is rejected, but a name whose process has already exited may be reused. A session holds at most 32 named processes (running, or exited but not yet reused) at once; one more is rejected with a clear error. keep_alive: true suppresses the session's idle timer for as long as the process runs, the same mechanism an attached PTY uses; default false means the process runs detached but doesn't by itself keep an otherwise-idle session live.
Response 200
{
"name": "web",
"pid": 4213,
"command": ["python3", "-m", "http.server", "8080"],
"state": "running",
"keep_alive": false,
"started_at": "2026-07-17T10:00:00Z"
}List every named process in the session, running and exited (an exited entry is kept until its name is reused or the session is torn down).
Kill sends SIGTERM, then SIGKILL after a grace period if the process hasn't exited. Killing an already-exited process is a no-op that just reports its final state.
Without ?follow=true, returns the process's captured stdout/stderr so far as one JSON body — each stream capped at 4 MiB from the tail (the underlying on-disk log is itself capped at 10 MiB per stream; once exceeded, older output is dropped so a long-lived, verbose process can't grow its log or fill the guest's disk without bound). With ?follow=true, upgrades to a Server-Sent Events stream instead: event: stdout / event: stderr frames (base64-encoded, since output is arbitrary bytes), ending with event: eof once the process exits and its output is fully drained, or event: error on a guest-side failure.
proc = session.start_process("web", ["python3", "-m", "http.server", "8080"], keep_alive=True)
for p in session.list_processes():
print(p.name, p.state)
for event in session.stream_process_logs("web"):
if event.event in ("stdout", "stderr"):
print(event.data, end="")
elif event.event in ("eof", "error"):
break
session.kill_process("web")const proc = await session.startProcess("web", ["python3", "-m", "http.server", "8080"], { keepAlive: true });
for (const p of await session.listProcesses()) console.log(p.name, p.state);
for await (const event of session.streamProcessLogs("web")) {
if (event.event === "stdout" || event.event === "stderr") process.stdout.write(event.data);
if (event.event === "eof" || event.event === "error") break;
}
await session.killProcess("web");Expose
Ensure an HTTPS preview URL is running for a port inside the guest. The process must bind to 0.0.0.0, not 127.0.0.1.
Request body
{
"port": 8080,
"visibility": "token",
"auth": "bearer"
}auth controls access to the preview URL: none (default) leaves it open, bearer requires every inbound request to carry an Authorization: Bearer <auth_token> header.
Response 200
{
"session_id": "2476b0a6-722e-4bd9-9f25-771ccbdd53bd",
"port": 8080,
"preview_url": "https://8080-2476b0a6-722e-4bd9-9f25-771ccbdd53bd.ams1.sb.inis.run",
"guest_ip": "10.42.3.2",
"auth": "bearer",
"auth_token": "qN3kf2...",
"result": "created"
}When auth is bearer, inbound requests without a matching Authorization: Bearer header are rejected with 401. The active mode shows up as auth on each entry in exposed_previews from a session fetch; the token itself is only ever returned from this endpoint.
Preview URLs follow https://{port}-{session_id}.{node-domain}.
Expose is idempotent
Calling expose again for a port that is already exposed with the same visibility, auth and login_url returns the preview URL and token it already handed out. It does not mint new ones, and it does not wake a paused session.
That matters because a preview URL is a credential someone else may be holding — a webhook you registered with an external service, a link in a browser, a URL stored in your own database. Rotating it silently breaks the holder with no error and no event, so a retry, or a loop that re-asserts the state it wants, is safe here by default.
result says what the call actually did:
result | meaning |
|---|---|
created | the port was not exposed; a route and its credentials were minted |
unchanged | already exposed exactly as asked — nothing minted, no wake, preview_url is the URL an earlier call returned |
updated | a route existed and this call rewrote it, because the settings differed or rotate was set |
On an updated call the ingress token is kept wherever the new settings leave room for it — changing login_url, or turning auth: "bearer" on, does not invalidate a URL in use. Three things move it: visibility (public has no token to keep, and coming back to token mints a new one), rotate, and turning auth: "bearer" off.
Turning bearer auth off mints a fresh ingress token, and the previous preview URL stops working — on a token route. It has to: auth defaults to "none", so omitting it on a route that is currently bearer is the downgrade — and if the URL survived that, everyone already holding it (a log line, a CI artifact, a pasted link) would go from 401 to unauthenticated access without anyone asking. Send auth: "bearer" on every convergence pass if you mean to keep it.
A public route is the exception, and it cuts the other way. Its URL carries no token, so there is nothing to rotate: dropping bearer leaves the same hostname reachable with no auth at all, and every holder of that URL gains access rather than losing it. There is no way to revoke a public route's URL — that is what public means. If the gate matters, use visibility: "token".
Two things are converged on every call whatever the result: the route's backend, so a session that came back on a new guest IP is repointed rather than left aiming at a dead one, and its expiry lease.
Rotating a token deliberately
{ "port": 8080, "rotate": true }rotate: true mints a fresh ingress token (and a fresh bearer token when auth is bearer) even if the port is already exposed exactly as requested. Every URL carrying the old token stops working the moment it returns — that is the point of it, so send it only when you mean it. On the CLI it is inis sessions expose <id> --port 8080 --rotate.
Pause and resume
Pause stops billing for compute; storage for the paused state continues. Any exec/files call on a paused session wakes it automatically, as does attaching with inis ssh. Expose wakes it too when it has actual work to do — creating a route, or changing one — but an expose that is already satisfied answers from the existing route and leaves the session asleep.
Pause also happens on its own: when the last client detaches (if the session's on_pty_detach is pause, the default) and after the idle_timeout_ms window. A paused session is auto-destroyed once it sits past its paused_ttl_ms.
Fork
Clone a live session into N independent children — useful for parallel eval or RL rollouts.
Request body
{
"count": 10
}Response 200
{
"parent_session_id": "2476b0a6-722e-4bd9-9f25-771ccbdd53bd",
"children": ["...", "..."]
}Checkpoints
A checkpoint is a named, retained save-point of a live session. It survives the session's destruction and is deleted only when you ask. See Concepts for checkpoint vs fork vs pause.
Capture a checkpoint. The source session keeps running.
Request body
{
"name": "clean"
}Response 200
{
"checkpoint_id": "ckpt_3f9a...",
"parent_session_id": "...",
"name": "clean",
"size_bytes": 268435456,
"created_at": "2026-06-23T10:00:00Z"
}List checkpoints captured from a session.
Fetch a checkpoint's metadata.
Delete a checkpoint and free its disk.
Restore in place
Roll a session back to a checkpoint, reusing the same session ID. The "run untrusted code, then roll back to clean" flow.
Request body
{
"checkpoint_id": "ckpt_3f9a..."
}New session from a checkpoint
Start a fresh, independent session from a checkpoint (the template path). Via the CLI: inis checkpoints create-session <checkpoint-id>.
Lifecycle
creating → live ⇄ paused → destroyed → (ended history)
↑ ↓
wake-on-op auto-pause on detach / idlelive → paused happens on detach (on_pty_detach: pause) or idle timeout; paused → live on any operation or attach. A session reaches destroyed explicitly, at max_lifetime, or when a paused session passes its paused_ttl — and leaves an ended history row behind either way.
Full walkthrough of timers, wake-on-op, billing, and recipes: Session lifecycle. For when to use sessions vs stateless execute, see Concepts.
Limits
Default caps on the managed cloud today — talk to us if one of these doesn't fit your workload:
| Limit | Default | Notes |
|---|---|---|
| Concurrent live sessions per account | 10 | Across all API keys on the account. Exceeding it returns account_concurrency_exceeded (429, retryable) |
| Requests per second | No fixed limit on the execution API | The concurrency cap above is what bounds usage. Auth endpoints (sign-in code, CLI pairing, OAuth, passkey, 2FA) are separately rate-limited per IP/email against brute-force and abuse |
idle_timeout_ms | Off by default; 1 hour max if you set one | — |
max_lifetime_ms | Off by default (no cap); 24 hours max if you set one | Every session also force-pauses after 24 hours continuously live regardless of this setting — a fleet-wide backstop, not something you opt out of |
paused_ttl_ms | 30 days | Overridable per session; a negative value disables it |
timeout_ms (a single exec) | 30 seconds; 1 hour max | — |
A new account (trial, no card on file) is more limited while unverified: 1 concurrent session, 15 minutes of runtime per day, and 5 GB of cumulative disk across live/paused sessions. Paid accounts get 10 concurrent sessions and 50 GB cumulative disk by default.