--- title: Running in production | Lightcone description: A checklist for taking Lightcone from prototype to production, covering pinning, idempotency, resumable streams, error handling, and cost control. --- Prototypes tolerate a lost stream or a double-run. Production doesn’t. This guide covers the things that separate a demo from a service you can page someone about. ## Pin your model version Use a fully versioned model ID: ``` client.agent.tasks.start( instruction="...", kind="browser", model="tzafon.northstar-cua-fast-1.6", # pinned ) ``` ``` await client.agent.tasks.start({ instruction: "...", kind: "browser", model: "tzafon.northstar-cua-fast-1.6", // pinned }); ``` Unversioned aliases move under you: behavior, step counts, and costs shift when the alias is repointed to a newer model. Pin a version in production and upgrade deliberately, with your own evals in between. ## Make retries safe with idempotency keys Networks fail mid-request. If you retry a task start without protection, you run the task twice: twice the cost, and possibly twice the side effects (two orders placed, two emails sent). Pass an `idempotency_key` on task creation. A retry with the same key returns the same task with `reused: true` instead of starting a new one: ``` task = client.agent.tasks.start( instruction="Submit the expense report for invoice INV-4821", kind="browser", idempotency_key="expense-INV-4821", ) if task.reused: print(f"Already running as {task.task_id}") ``` ``` const task = await client.agent.tasks.start({ instruction: "Submit the expense report for invoice INV-4821", kind: "browser", idempotency_key: "expense-INV-4821", }); if (task.reused) { console.log(`Already running as ${task.task_id}`); } ``` Derive the key from your own job ID so retries at any layer (your queue, your HTTP client, the SDK) all deduplicate to one task. ## Resume streams after disconnects SSE connections drop when load balancers rotate, laptops sleep, or pods restart. The task keeps running; you just need to reattach without losing events. Reconnect to `GET /agent/tasks/{id}/stream` with the standard `Last-Event-ID` header (SSE clients send it automatically), or pass `?from=` explicitly. The server replays buffered events from that point: ``` import httpx with httpx.stream( "GET", f"https://api.tzafon.ai/agent/tasks/{task_id}/stream", headers={ "Authorization": f"Bearer {api_key}", "Last-Event-ID": str(last_seq), # resume where you left off }, timeout=None, ) as response: for line in response.iter_lines(): ... ``` ``` // EventSource sends Last-Event-ID automatically on reconnect. // With fetch-based clients, pass ?from= explicitly: const res = await fetch( `https://api.tzafon.ai/agent/tasks/${taskId}/stream?from=${lastSeq}`, { headers: { Authorization: `Bearer ${apiKey}` } }, ); ``` If you reconnect after the buffer has evicted your position, you receive a `replay_gap` event, meaning some events are gone for good. Re-sync from `retrieve_status` and continue from live events. Streams remain available for roughly 10 minutes after a task finishes, so a consumer that reconnects late can still collect the terminal event. ## Handle errors by taxonomy, not by string A terminal `failed` event carries `error_code` and `retryable`: - **`retryable: true`** (e.g. `session_expired`, upstream 503): retry with exponential backoff, ideally on a fresh computer. - **`retryable: false`**: don’t loop. Route to human review with the last screenshot and the task ID. `retryable` tells you the *platform* failure was transient. It does **not** tell you whether the task already caused a side effect. Before auto-retrying a task that submits a form, sends a message, or places an order, classify the outcome: **untouched** (the target app was never reached, safe to retry), **committed** (you verified the side effect happened, so do not retry), or **uncertain** (reached the app, outcome unknown, so send to human review and never blind-retry). A [fail-closed verification step](/cookbook/wrap-a-legacy-app-in-an-api/index.md) is what lets you tell these apart. On the HTTP side: | Status | Meaning | What to do | | ----------- | -------------------------------- | --------------------------------------------------------------------------------------------------- | | `402` | Out of credits | Top up at [lightcone.ai/dashboard/usage](https://lightcone.ai/dashboard/usage). Alert, do not retry | | `429` | Concurrent-session limit reached | Queue and retry with backoff, or raise your limit | | `4xx` other | Request problem | Fix the request; retrying won’t help | | `5xx` | Server problem | Retry with backoff (the SDKs do this automatically) | Error responses use RFC 7807 `application/problem+json` bodies, so you can branch on the `type` field rather than parsing message strings. ## Session hygiene Leaked computers are the most common source of surprise bills. - **Always delete computers when done.** Use context managers (Python) or `try/finally` (TypeScript), and set tight `max_lifetime_seconds` / `inactivity_timeout_seconds` as a backstop. - **Keepalive during long think-time.** Any action resets the idle clock, but gaps between your process’s calls do not. If your code spends minutes deciding between actions (waiting on a model, a human, or a queue), send `keep_alive()` periodically or the computer will be reaped under you. ``` computer.keep_alive() # call periodically during long gaps between actions ``` ``` await client.computers.keepalive(computer.id!); ``` ## Warm sessions for latency-sensitive services Creating and signing in to a fresh computer per request is slow. For interactive services, keep one authenticated computer per worker and adopt it on startup: ``` def get_computer(client, env_id): # Adopt a live computer if one exists for c in client.computers.list(): if c.status == "running": try: # Verify liveness with a cheap real action # a stale session can still return screenshots client.computers.navigate(c.id, url="https://app.example.com/ping") return c except Exception: client.computers.delete(c.id) # Otherwise recreate from the authenticated snapshot return client.computers.create( kind="browser", environment_id=env_id, persistent=True ) ``` ``` async function getComputer(client: Lightcone, envId: string) { for await (const c of client.computers.list()) { if (c.status !== "running") continue; try { // Verify liveness with a cheap real action, not just a screenshot await client.computers.navigate(c.id!, { url: "https://app.example.com/ping" }); return c; } catch { await client.computers.delete(c.id!); } } return client.computers.create({ kind: "browser", environment_id: envId, persistent: true, }); } ``` The verification action matters: check liveness with something that exercises the session (a navigation, a real click), not just a screenshot. A session whose login has expired will happily screenshot the login page. On any failure, delete and recreate from your persistent `environment_id` (see [Logins and sessions](/guides/logins-and-sessions/index.md)). ## Set timeouts at every layer - **Wrap SDK calls in your own timeouts.** Individual actions default to \~20 seconds server-side, but model calls in tasks and the Responses API can take longer. Do not let one hung call stall a worker forever. - **Set `max_duration_seconds` and `max_steps` on every task.** Never ship a task request without both; they’re your only hard ceilings on wall-clock time and cost. ``` client.agent.tasks.start( instruction="...", kind="browser", model="tzafon.northstar-cua-fast-1.6", max_steps=40, max_duration_seconds=600, idempotency_key=job_id, ) ``` ``` await client.agent.tasks.start({ instruction: "...", kind: "browser", model: "tzafon.northstar-cua-fast-1.6", max_steps: 40, max_duration_seconds: 600, idempotency_key: jobId, }); ``` ## Watch costs - `max_steps` caps runaway agents. An agent stuck in a loop burns one model call per step until something stops it. - Each computer-use request carries a fixed overhead of roughly 2k tokens (screenshot and context framing) before your instruction, so many tiny tasks cost more than one well-scoped task. - Idle computers bill until they time out or are deleted; see session hygiene above. See [Pricing](/guides/pricing/index.md) for rates and how metering works. ## Production checklist - Model pinned to a full version (`tzafon.northstar-cua-fast-1.6`), upgrades gated on your own evals - `idempotency_key` on every task start, derived from your job ID - Stream consumer resumes with `Last-Event-ID` / `?from=` and re-syncs from status on `replay_gap` - `failed` events branched on `retryable`; 402 alerts, 429 backs off - Computers deleted in `finally` blocks, tight timeouts set, keepalive during long gaps - Warm sessions verified with a real action, recreated from `environment_id` on failure - Client-side timeouts on every SDK call; `max_steps` and `max_duration_seconds` on every task - Cost dashboards watched at [lightcone.ai/dashboard/usage](https://lightcone.ai/dashboard/usage) ## See also - [**Logins and sessions**](/guides/logins-and-sessions/index.md): persistent authenticated sessions - [**Tasks**](/guides/tasks/index.md): events, streaming, and configuration - [**Best practices**](/guides/best-practices/index.md): reliability patterns for computer control - [**Pricing**](/guides/pricing/index.md): rates and metering