---
sidebar_position: 1.6
title: Scripting for agents
description: How an agent drives Lium from a shell with no terminal to answer — the key in the environment, --yes and --no-ssh on every rent, JSON where it exists, exit codes to branch on, pods named so a retry finds them, and a teardown that cannot be forgotten.
---

> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lium.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Scripting for agents

Lium is agent-first compute: the renter path was built to run with nobody at a keyboard. This page is the
contract for a script or an agent that has only a shell — what to pass so nothing prompts, what comes back
as JSON, which exit code means what, and how to rent so that a retry does not rent twice. Every command and
output below was run on the released CLI on a Linux box with stdin closed.

Read [AI Agents](./agents.md) first if you have no account yet; this page starts from a key.

## 1. The key lives in the environment

```bash
export LIUM_API_KEY=sk_...          # created once by a human at lium.io → Access → API Keys,
                                     # or minted by POST /api/auth/signup (see AI Agents)
lium balance --json                  # {"balance": 12.34, "balance_usd": 12.34, "currency": "USD"} — the first call that proves the key
```

- `LIUM_API_KEY` is read by every `lium` command and by the SDK's `Lium()`. Nothing else is needed: the
  SSH key is generated on first use (`~/.ssh/id_ed25519`, no passphrase) when none exists.
- Do not run plain `lium init` from a script. When stdin is not a terminal it takes the `--no-browser` path:
  it prints an approval URL and a session ID a human must act on, and exits 0 — the key is only saved once
  `lium init --session <ID>` runs after the approval. With `LIUM_API_KEY` exported, `lium init` is not needed at all.
- `lium ls` answers with **any** key, so it proves nothing about the key (with none it exits 2,
  `No API key found`). Use `lium balance --json` or `GET /users/me`.
- With no key, `lium balance --json` exits 2 with `{"error": {"code": "no_api_key", …}, "ok": false}`
  on stderr; with a wrong key it exits 3 with `"code": "invalid_api_key"` and a message that names the key and where it was read from.
  The `error` object also carries `hint` and `exit_code`.

## 2. Nothing may prompt

| Command | Pass | Why |
|---------|------|-----|
| `lium up …` | `--yes --no-ssh` | Without `--yes` the rent asks `Acquire pod on …? [y/n]`; with stdin closed that is exit 2 with the code `confirmation_required`, nothing rented. `--no-ssh` returns when the pod is ready instead of opening an interactive SSH session. |
| `lium up …` | `--ttl 15m` (or `--until`) | The platform removes the pod on time even when your script dies. Set it on every rent — and know that it is scheduled once the pod is **ready**: a rent that hits `--timeout` while still starting has no TTL (see §6). |
| `lium rm <huid>` | nothing | A pod named by huid, id or name is removed without a question. `--yes` is only needed with `--all` or a row number. |
| `lium rm --all` | `--yes` | Removes every pod on the key — including pods another process created with the same key. |
| `lium exec <pod> "cmd"` | nothing | Runs over SSH, no TTY needed; exits with the remote command's exit code. |
| `lium ssh <pod>` | avoid | Interactive. Use `lium exec`, or the `ssh_command` from `lium ps --format json` with `-o BatchMode=yes`. |

Pass the node's **`id`** to `lium up`, not the `huid` you see in the table: `lium up golden-lion-86`
answers `Node 'golden-lion-86' not found`.

## 3. JSON where it exists, today

| Command | Machine-readable form | Notes |
|---------|-----------------------|-------|
| `lium ls` | `--format json` | Array of nodes: `id`, `huid`, `gpu_type`, `gpu_count`, `price_per_hour`, `country`, `vram_gb`, `docker_in_docker`, `is_pareto`, … |
| `lium ps` | `--format json` | Array of pods: `id`, `huid`, `name`, `status`, `price_per_hour`, `spent_usd`, `uptime`, `ip`, `ports`, `ssh_cmd`, `ssh_command`, `removal_scheduled_at`. |
| `lium describe <pod>` | `--json` | Objects `pod`, `gpu`, `machine`, `ports`, `access` (`ssh_cmd`, `ssh_command`, `jupyter_url`), `template`, `storage`, `billing`. |
| `lium exec <pod> "cmd"` | `--json` | `{"ok": bool, "results": [{"pod", "exit_code", "stdout", "stderr", "error"}]}`; the process exit code is the remote command's. |
| `lium balance` | `--json` | `{"balance": 12.34, "balance_usd": 12.34, "currency": "USD"}` |
| `lium signup`, `lium topup …`, `lium audit` | `--json` | See their reference pages. |
| `lium templates` | `--format json` | Array of templates with their ids. |
| `lium up`, `lium rm`, `lium logs` | none | `lium up` prints `Pod <huid> (name: <name>, id: <id>) ready`; read the pod back with `lium ps --format json`. |

When a command with `--json` fails before it can do its work, stdout stays empty and stderr carries
`{"ok": false, "error": {"code": "...", "message": "...", "hint": "...", "exit_code": N}}`. `--json` is accepted
wherever `--format json` is, and both put the same envelope on stderr. `lium exec --json` is different: a remote command
that exits non-zero is a result, printed to stdout with `"ok": false` and the exit code inside `results`.
The REST API answers every error with `X-Request-Id` and
`{"error": {"code", "message", "hint", "request_id"}, …}` — see [API Authentication](./authentication.md#error-responses).

## 4. Exit codes to branch on

`0` success · `1` general error · `2` configuration (bad arguments, no key, a prompt nobody answered:
`confirmation_required`) · `3` the API refused or
failed the call (wrong key) · `4` SSH · `5` pod not found · `6` permission denied (a rent the balance does not
cover is a 403 → exit 6). `lium exec` is the exception: it exits with the remote command's code. Full table:
[CLI reference → Exit codes](./cli/reference/index.md#exit-codes).

## 5. Rent so that a retry does not rent twice

The CLI and SDK guard the rent itself (the SDK sends an `Idempotency-Key`, and after a lost answer looks for a
pod with the requested name before sending again) — but nothing guards a *second run of your script*. Two
things make a retried script safe:

1. **Name the pod** with a name only this job uses, and **look for it before renting**.
2. **Set `--ttl`** so an orphan removes itself once it is ready (a pod that never got ready has no TTL — §6).

```bash
set -euo pipefail
NAME="job-$RUN_ID"                                   # unique per job, stable across retries

POD=$(lium ps --format json | jq -r --arg n "$NAME" '.[] | select(.name==$n) | .huid' | head -1)
if [ -z "$POD" ]; then
  NODE=$(lium ls --gpu RTX4090 --format json \
         | jq -r '[.[] | select(.gpu_count==1)] | sort_by(.price_per_hour) | .[0].id')
  lium up "$NODE" --yes --no-ssh --ttl 2h --name "$NAME"        # returns when RUNNING with SSH up
  POD=$(lium ps --format json | jq -r --arg n "$NAME" '.[] | select(.name==$n) | .huid')
fi
```

A second `lium up` on the same node after the first succeeded says `Node '<id>' not found` — the node left
the listing when it was rented. That is not a duplicate guard; the name check above is.

Never select "my pod" by position (`.[0]`) on a key that other jobs share: the first row is whatever pod is
oldest, not yours.

## 6. Waiting, ETA, timeouts

`lium up` waits for the pod itself and prints a line whenever the status or phase changes, and every 30 s otherwise:

```text
pod job-42 (id: d4d510c6-…) created; waiting for it to become ready
waiting for golden-comet-17… PENDING (8 s) · est. ready in ~20 s (phase: creating volume)
waiting for golden-comet-17… PENDING (16 s) · est. ready in ~12 s (phase: configuring ssh)
waiting for golden-comet-17… RUNNING (19 s)
Pod golden-comet-17 (name: job-42, id: d4d510c6-…) ready
```

- `--timeout SECONDS` (default 900) bounds the whole rent; `--ready-timeout SECONDS` bounds only the wait.
  When the budget runs out the command exits 1 (`pod_not_ready`), names the pod and says
  `Auto-termination (--ttl/--until) was NOT scheduled; it is set once the pod is ready.` The pod keeps billing
  with no TTL: your script must `lium rm` it (or wait for it with `lium ps`).
- A pod that fails to start exits 3 (`pod_start_failed`) with the huid, the last status and the status history.
- In the SDK the same wait is `lium.wait_ready(pod, timeout=600)`; it raises `PodStartError` on a terminal
  status and returns `None` only when the timeout passes while the pod is still starting.
- Polling yourself: `lium ps --format json` every 2 s for the first 90 s, then every 10 s, is what the CLI does.

## 7. Run, copy, observe

```bash
lium exec "$POD" "nvidia-smi --query-gpu=name,memory.total --format=csv,noheader"
lium scp "$POD" ./payload.txt                        # lands in /root/ on the pod
lium exec "$POD" --json "python train.py; exit \$?" | jq '.results[0].exit_code'
lium logs "$POD" --tail 50                           # the pod's container log
lium ps --format json | jq -r --arg h "$POD" '.[] | select(.huid==$h) | .spent_usd'
```

Every `exec` connects over SSH with the key `lium` configured; the pod's host key is pinned on the first
connection — stderr carries `Pinning ssh-ed25519 host key SHA256:… for [<ip>]:<port> (first connection to this pod; LIUM_SSH_INSECURE=1 disables pinning)`.

## 8. Tear down, and prove it

```bash
lium rm "$POD"                       # → Removed 1 pod(s): golden-comet-17          exit 0
lium rm "$POD"                       # → No pods match targets: golden-comet-17     exit 5 — already gone
lium ps --format json | jq -e --arg h "$POD" 'map(select(.huid==$h)) | length == 0' >/dev/null
```

Billing stops at removal. `spent_usd` in `lium ps --format json` is the CLI's estimate — uptime × the
node's hourly price — while the pod exists; the account's balance is the source of truth
(`lium balance --json`). Schedule a removal instead of waiting: `lium rm "$POD" --in 30m`.

## 9. The whole loop, headless

```bash
#!/usr/bin/env bash
set -euo pipefail                                    # LIUM_API_KEY exported by the caller
NAME="job-$RUN_ID"
trap 'lium rm "$NAME" || true' EXIT                  # before the rent, keyed on the name: a rent that times out
                                                     # (pod_not_ready, exit 1, no TTL yet) still gets removed
POD=$(lium ps --format json | jq -r --arg n "$NAME" '.[] | select(.name==$n) | .huid' | head -1)   # §5: a retry finds its pod
if [ -z "$POD" ]; then
  NODE=$(lium ls --gpu RTX4090 --format json | jq -r '[.[] | select(.gpu_count==1)] | sort_by(.price_per_hour) | .[0].id')
  lium up "$NODE" --yes --no-ssh --ttl 1h --name "$NAME" --timeout 600
  POD=$(lium ps --format json | jq -r --arg n "$NAME" '.[] | select(.name==$n) | .huid')
fi
lium scp "$POD" ./job.py
lium exec "$POD" "cd /root && python job.py > out.txt 2>&1"
lium exec "$POD" "cat /root/out.txt"
```

Measured on an RTX 4090 at $0.30/h: rent to `ready` 22 s, `exec` 2.2 s, `rm` 1.1 s, total spend under $0.01.

## Related

- [AI Agents](./agents.md) — from no account to a key, fingerprint signup, self-funding
- [API Authentication](./authentication.md) — key sources and precedence, the error envelope
- [CLI reference](./cli/reference/index.md) — every flag; [exit codes](./cli/reference/index.md#exit-codes)
- [`lium up`](./cli/reference/up.md) · [`lium rm`](./cli/reference/rm.md) · [`lium exec`](./cli/reference/exec.md)
- [SDK](./sdk.md) — `Lium()`, `wait_ready`, `PodStartError`
