inis.run

Production best practices

Session lifecycle knobs, why you don't need your own warm pool, bulk file transfer, idempotent creates, reconnecting after resume, pagination, and retries.

Patterns for running inis.run past the first demo — once a session is doing real work in a loop, not just answering one quickstart call. Each section is a question that comes up once you're operating this in production, with the checked answer and a link to the fuller reference.

Pick the right lifecycle knob for the job

Session lifecycle has the full table. The short version, by what you're trying to bound:

  • Gaps between work, not a hard deadline — idle_timeout_ms. Auto-pauses a session after it sits idle while live, so an agent between tool calls stops paying compute without you calling pause() yourself. It's a real idle detector, not a blunt wall clock — a busy guest or an attached terminal defers it. Live-verified: a session created with a 15-second idle_timeout_ms was already paused when checked 25 seconds later.
  • A compute ceiling regardless of activity — max_lifetime_ms. Caps total session age, live or paused, independent of how busy it's been. Use it for "this job should never still be running after N hours" — a runaway agent loop, a forgotten dev session — where idle detection alone wouldn't catch a session that's busy but should still stop. Reaching it auto-pauses the session rather than destroying it — it frees the compute, not the storage — but unlike idle_timeout_ms, that pause is permanent: the session can never resume again (any attempt, explicit or automatic, is refused). Use this only for work you're genuinely done needing live, not as a "pause and come back later" mechanism. If you also want a forgotten session to actually go away, pair it with paused_ttl_ms below; max_lifetime_ms alone will not delete anything.
  • Bounding what you pay to keep around — paused_ttl_ms. A paused session is storage, not compute, but it isn't free forever — and since max_lifetime_ms only pauses (permanently, never destroys), this is the knob that actually bounds how long an auto-paused session (or any paused session) sticks around. Set this to auto-destroy a paused session after it's sat unused for a while, instead of it lingering until you notice and clean it up.
  • One command and done — destroy_on_completion. Skip persistence entirely: the session tears itself down after its first command finishes. Use this for genuinely stateless work instead of creating a normal session and destroying it yourself right after.

None of these need to be combined — pick the one (or two) that match the actual failure mode you're protecting against, and leave the rest at their server defaults.

Skip building your own warm pool

If you've built against a sandbox platform before, you've probably built a pool: keep N containers pre-started and idle, hand one out on request, because a cold start from nothing is too slow to eat on the request path.

Don't reproduce that here. A paused session keeps its own retained state and disk, so resuming it restores from that rather than starting a template from scratch — tens of milliseconds when it's hot, a second or two when it's warm, depending on how recently it ran (see Session lifecycle). That speed comes from the session's own retained state, not from anything you have to keep live and idle on its behalf. A spare pool of started-up sessions buys you nothing a paused session doesn't already have, and costs you idle compute plus a pool-sizing problem.

The practical difference this makes: pause between uses instead of destroy and recreate. Destroying and recreating a session throws away its retained state and its disk, and pays a full template start on the next request — exactly the cost a client-side pool exists to hide. Pausing keeps both, and the next resume reads back the state it already had. For an agent harness handling concurrent requests, that means a thread_id → session_id map with sessions left paused between turns beats a pool of pre-spun, always-live sessions checked out per request — cheaper (no idle compute) and no pool-sizing problem to get wrong in the first place.

Bulk file transfer: zip, upload, extract

Two ways to get files into a session, with different ceilings:

  • write_files — batch small-file writes in one call. Capped at 64 files / 8 MiB total content, and not fail-fast: it returns one {path, ok, error?} result per file, so one bad path in the batch doesn't sink the rest.
  • upload_file — streams one local file straight into the session over PUT /v1/sessions/{id}/files/stream, reading it in bounded chunks rather than loading it into memory first. No size cap tied to the batch endpoint.

Neither is a "send this whole directory" primitive. For a project checkout, a dataset, or anything with more than a handful of files, zip it locally, upload_file the single archive, then extract it inside the session:

import shutil
import tempfile
from pathlib import Path

from inis import Client

client = Client()
session = client.sessions.create(template="python")

local_dir = Path("local_project")

with tempfile.TemporaryDirectory() as tmp:
    archive_base = Path(tmp) / "payload"
    archive_path = shutil.make_archive(str(archive_base), "zip", root_dir=local_dir)
    session.upload_file(archive_path, "/workspace/payload.zip")

session.exec(["unzip", "-q", "-o", "/workspace/payload.zip", "-d", "/workspace/project"])
session.remove("/workspace/payload.zip")
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { Client } from "@inis-run/sdk";

const client = new Client();
const session = await client.sessions.create({ template: "python" });

const localDir = "local_project";
const tmp = mkdtempSync(path.join(tmpdir(), "inis-upload-"));
const archivePath = path.join(tmp, "payload.zip");

execFileSync("zip", ["-rq", archivePath, "."], { cwd: localDir });
await session.uploadFile(archivePath, "/workspace/payload.zip");
rmSync(tmp, { recursive: true, force: true });

await session.exec(["unzip", "-q", "-o", "/workspace/payload.zip", "-d", "/workspace/project"]);
await session.remove("/workspace/payload.zip");

unzip and Python 3 both ship on the base template, so the extract step needs nothing extra installed. Going the other direction — pulling files back out — download_file is the same streamed path for one large file; zip inside the session first if you need a whole directory back out in one shot.

Idempotent creates and looking up sessions by your own key

Two session-create options solve two different problems, and it's easy to reach for the wrong one:

Idempotency-Key makes a retry safe. Set it when your process might crash, time out, or lose the response after the request already reached the server, and you don't know whether the session actually got created:

session = client.sessions.create(
    template="python",
    idempotency_key=f"order-{order_id}-session-create",
)
const session = await client.sessions.create({
  template: "python",
  idempotencyKey: `order-${orderId}-session-create`,
});

Retrying with the same key, the same account, and the same request body returns the original session instead of creating a second one. Retrying with the same key but a different body — a different template, size, or any other field — is rejected with 409 conflict rather than silently replaying the first call. That's deliberate, the same contract Stripe and most idempotent-write APIs use: it catches a caller reusing a key across two genuinely different requests instead of quietly serving the wrong one back. Only reuse a key when the body is byte-for-byte the same logical request; mint a new one for anything else.

external_id solves a different problem: finding a session again later without keeping your own external_id → session_id table. Stamp it once at create with a key from your own system — a user, workflow, or thread id — and look it up by that key instead of storing the ID yourself:

session = client.sessions.create(template="python", external_id=f"thread-{thread_id}")
# later, from anywhere, without having kept session_id around:
sessions, _ = client.sessions.list(external_id=f"thread-{thread_id}")
const session = await client.sessions.create({ template: "python", externalId: `thread-${threadId}` });
// later, from anywhere, without having kept sessionId around:
const { sessions } = await client.sessions.list({ externalId: `thread-${threadId}` });

external_id is not unique — a long-running workflow that spins up several sessions over its life can stamp all of them with the same value, and a filtered list returns every match. If you need exactly one session per key, that uniqueness is on you to enforce (check the list before creating, or fold the key into an Idempotency-Key as well). See Index by your own id for the full field reference.

Reconnecting after resume

Pausing a session freezes it completely — nothing runs while it's paused, compute included. That's already the reason preview URLs drop while a session is paused and pick back up after wake. The same thing happens to any outbound connection your own code opened and held onto — a database connection pool, a long-lived websocket, a long poll to some other API. Nothing inside the guest closed it, but the remote end has had an arbitrary gap with total silence on the wire, and may well have already timed it out or reset it from its side by the time the session wakes back up.

Treat a connection your code opened before a pause as unverified after a resume, not as still good. The fix is the same shape API clients already use for connection pools that can go stale on their own — check before you trust it, reconnect if the check fails — applied at the point you next use it, rather than assuming a connection opened at process start stays valid forever:

# Code that runs INSIDE the session — via exec, a background process, or a
# script the session executes — not the SDK client itself. SDK calls (exec,
# files, ...) are unaffected by this: each one opens its own connection to
# the session on demand and wakes it via the normal wake-on-op path.
import psycopg

_conn = None

def get_db():
    global _conn
    try:
        if _conn is None:
            raise psycopg.OperationalError
        _conn.execute("SELECT 1")
    except psycopg.OperationalError:
        _conn = psycopg.connect(DATABASE_URL)
    return _conn

A background process started with keep_alive: true is the case most worth checking: it's meant to outlive individual tool calls, which means it's also the thing most likely to be holding a connection open across a pause it didn't ask for.

Pagination: only state=ended history actually pages

GET /v1/sessions behaves differently depending on state, and the two modes don't share a pagination story:

  • state=ended (session history) has real cursor pagination: pass cursor from the previous page's response to get the next one, and an empty/absent next_cursor means you've reached the end.
  • Every other state (live, paused, creating, or unfiltered) is a flat, capped query, newest first — limit controls how many rows come back, but there's no cursor to page through the rest with. A live/paused list response never carries a next_cursor to page against.
# state=ended: page through with cursor
cursor = None
ended = []
while True:
    page, cursor = client.sessions.list(state="ended", limit=100, cursor=cursor)
    ended.extend(page)
    if not cursor:
        break

# live/paused: no cursor to page with — narrow the query or raise limit instead
live, _ = client.sessions.list(state="live", limit=200)
// state=ended: page through with cursor
let cursor: string | undefined;
const ended = [];
do {
  const page = await client.sessions.list({ state: "ended", limit: 100, cursor });
  ended.push(...page.sessions);
  cursor = page.nextCursor;
} while (cursor);

// live/paused: no cursor to page with — narrow the query or raise limit instead
const { sessions: live } = await client.sessions.list({ state: "live", limit: 200 });

If you're tracking a lot of concurrently live/paused sessions, filter by external_id or raise limit rather than looking for a cursor that isn't there for that query.

Errors and retries

Every error response carries a stable code, plus retryable, retry_after, and request_id — branch on those, not on the human-readable message (it can be reworded). Full code table: Errors.

from inis import InisError

try:
    session.exec(["python3", "task.py"])
except InisError as e:
    if e.retryable:
        time.sleep(e.retry_after or 1)
        session.exec(["python3", "task.py"])
    elif e.code == "session_not_live":
        session.get()  # refetch state — likely a wake race — then retry
    else:
        raise
import { InisError } from "@inis-run/sdk";

try {
  await session.exec(["python3", "task.py"]);
} catch (err) {
  if (err instanceof InisError && err.retryable) {
    await new Promise((r) => setTimeout(r, (err.retryAfter ?? 1) * 1000));
    await session.exec(["python3", "task.py"]);
  } else if (err instanceof InisError && err.code === "session_not_live") {
    await session.get(); // refetch state — likely a wake race — then retry
  } else {
    throw err;
  }
}

session_not_live is worth calling out specifically: it's the ordinary result of racing a session that just paused itself, not a real failure — refetch and retry once it's back. idempotency_key_in_progress is the same shape for a concurrent duplicate create with the same key. Both are retryable: true for exactly that reason.

session_node_lost is the opposite case: the session's home node was declared permanently dead and there is no restore path back to it. retryable is false — don't loop on it. Recreate the session from its template, or restore from your last checkpoint if you took one; data since that checkpoint is gone. GET and DELETE are the two exceptions — you can still read a lost session's state, and deleting it always succeeds, so it never sits undeletable in your session list. See If a node fails for the full contract.

Where to go next

  • Best practices — persistence, resume/fork, templates, scoped credentials, locked-down egress
  • Session lifecycle — full timer table, resume tiers, pause vs checkpoint vs template
  • Sessions API — request/response reference, including external_id and Idempotency-Key field details
  • Errors — full error code table
  • Agent recipes — task-oriented patterns with runnable source
  • Networking & egress — locking down what a session can reach, including across pause and fork

On this page