Metadata-Version: 2.5
Name: sequence-ai
Version: 0.33.66
Summary: The inference and benchmark platform for physical AI: deploy a robot policy, run it on your robot, score it.
Project-URL: Homepage, https://generalsequences.com
Project-URL: Documentation, https://generalsequences.com/docs/
Project-URL: Console, https://app.generalsequences.com
Author: General Sequences
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: inference,manipulation,policy,robotics,vla
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx>=0.24
Requires-Dist: pydantic>=2; python_version >= '3.10'
Requires-Dist: sequence-base>=0.3.28; python_version >= '3.10'
Provides-Extra: data
Requires-Dist: pyarrow>=14; extra == 'data'
Provides-Extra: dev
Requires-Dist: numpy>=1.24; extra == 'dev'
Requires-Dist: pillow>=10; extra == 'dev'
Requires-Dist: pyarrow>=14; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Provides-Extra: image
Requires-Dist: pillow>=10; extra == 'image'
Provides-Extra: port
Requires-Dist: mujoco-warp==3.15.0; (python_version >= '3.10') and extra == 'port'
Requires-Dist: mujoco==3.15.0; (python_version >= '3.10') and extra == 'port'
Requires-Dist: sapien==3.0.3; (python_version >= '3.10' and platform_machine != 'aarch64') and extra == 'port'
Description-Content-Type: text/markdown

# sequence-ai

Deploy a robot policy, then drive a robot with it.

```bash
pip install sequence-ai                  # Python 3.10+ for `seq`; a 3.9 robot gets the client alone
export SEQUENCES_API_KEY=seq_live_...    # create one at app.generalsequences.com
seq init pi05-droid my-pi05              # an official template: the policy, the robot's client.py, a README
seq deploy my-pi05/policy.py             # the platform builds it and serves it, as your own deployment
```

On macOS, `/usr/bin/seq` (a system tool that prints numbers) may come first on your `PATH`: check that `which seq`
points to your Python environment's `bin`, or run the CLI as `python -m seq …`.

```python
import sequence_ai

with sequence_ai.connect(model="my-pi05") as policy:    # your deployment, by the name you gave it
    out = policy.act(
        "pick up the cup",
        observe=robot.read_observation,   # cameras + joints
        act=robot.apply_action,           # one row of the chunk, as your policy defines it
        validate=robot.is_safe,
        hold=robot.hold,
        max_seconds=30,
    )

policy.interrupt()      # from any thread, at any moment
```

There is no shared model to call: every policy runs as a deployment of the account that deployed it, started from
a template (`seq init --list`) or from your own code.

---

## Two brains, one string between them

A VLM looks at a snapshot, decides *what* to do next, and says it in a sentence. This package is
the *how*: it takes that sentence with the current cameras and joints, asks your deployed
policy for the next second of actions, and plays them out while fetching the next second.

The two halves run at completely different speeds — the brain thinks between subtasks, the arm
needs a chunk every second — and `instruction` is their entire interface.

| | endpoint | who ships it |
|---|---|---|
| slow brain | `POST /v1/chat` | Messages in, one message out |
| fast brain | this package | `policy.act("pick up the cup", ...)` |

`act()` is shaped to be called as a tool. It overwrites the observation's instruction on every
call, so your eyes never have to know what the brain last decided and a stale sentence cannot
leak into a chunk after the brain has moved on. `max_seconds` is required and has no default:
this drives hardware, and a tool call a language model can start but nothing bounds is one an
arm can be left running by a dropped conversation.

Wiring the two together — retries, failure detection, choosing the next sub-goal — is yours.
This package provides inference and a control loop, not orchestration.

---

## Changing your mind

```python
policy.interrupt()
```

Thread-safe, and the only method meant to be called from a thread other than the one driving.

**It stops within one control period, not one chunk.** The loop checks between every action, so
at 15 Hz the arm stops in about 67 ms. Waiting for the current chunk to finish playing would be
up to a full second of an arm still reaching for something the brain has already given up on.

**It also cuts through a stalled fetch.** If the buffer has run dry and the loop is blocked on a
chunk that is taking five seconds, an interrupt does not wait that out — stop latency is a
property of this loop and never of the service.

Interruption unwinds through `hold()` like any other early stop, and is not raised: a stop you
asked for is an outcome, not an error. `RunOutcome.reason` says which ending happened.

| `reason` | meaning |
|---|---|
| `completed` | ran out of actions to play |
| `until` | your `until()` returned True — you judged the subtask done |
| `timeout` | `max_seconds` elapsed |
| `interrupted` | someone called `interrupt()` |
| `validate` | a `validate` callback refused an action |
| `raised:<Class>` | your callback or the network raised |

---

## What it does for you

Four things, and each of them is something a loop written straight against the HTTP endpoints
has to get right before it behaves properly on a robot:

| | Without it |
|---|---|
| Keeps the connection open | 185 ms of handshake on every call — 60% of a bare request |
| Refills before the buffer empties | The arm stops once per chunk, for a full round trip |
| Classifies errors | No way to tell "wait 118 s" from "stop and page someone" |
| One driver per buffer | Two loops on one handle interleave, and both report success |

None of the four is guesswork. Every one is a defect that existed in this client, was measured,
and was fixed — the numbers below are those measurements, not estimates.

## It keeps the connection open

Measured against the platform, six samples each:

| | median | min | max |
|---|---|---|---|
| new connection per call | **305.9 ms** | 246.7 | 766.7 |
| one reused connection | **121.0 ms** | 116.8 | 133.4 |

**184.9 ms per call — 60% of the total — is TCP and TLS handshake.** That is what
`with sequence_ai.connect(...)` removes. Nothing proprietary: any HTTP client that reuses a
connection gets the same result. This package just makes it the default rather than
something you have to remember.

Why it matters: a chunk is a deadline. A pi0.5-DROID chunk is 1.00 s of motion, and the next one
has to arrive before it finishes playing, so 185 ms of avoidable handshake is a fifth of the
entire budget.

## Actions come in chunks

One call returns a block of future actions, not a single command — as many steps as your policy
returns, at the rate it declares. That is why cloud inference works at all: the control loop does
not need a network round trip per control step.

```python
with sequence_ai.connect(model="my-pi05") as policy:
    pred = policy.predict(observation)
    print(pred.action_chunk)
    # ActionChunk(15 steps x 8 dims, joint_velocity, droid_franka, 15 Hz, covers 1.00s, replan after 8)
```

**The rows are your policy's own output, untouched.** Nothing converts them — not the platform, not
this client — so what they mean is what the policy declares, and `joint_velocity` in that line is
the one to read: pi0.5-DROID returns seven joint velocities and a gripper position, and feeding
them to a controller that expects joint positions is invisible — same shape, same dtype, values in
a normal range, no error, and the arm never completes the task. The template's `client.py` turns
them into commands for your controller ([below](#what-to-do-with-the-rows-that-come-back)).
`replan after 8` is the other field: executing all 15 rows when the policy asks to be replanned
after 8 fails the same quiet way. `run()` and `act()` honour it.

## The gap between chunks is the hard part

A loop that waits for the buffer to empty before asking for the next chunk stops the arm once
per chunk, every chunk, for a full round trip. It is not an occasional hiccup — it is
structural, and on short chunks it dominates:

| chunk covers | refill | arm actually moving |
|---:|---:|---:|
| 2.13 s | ~0.79 s | 73% |
| 1.00 s | ~0.79 s | **56%** |

So `run()` starts the next inference *while the current chunk is still playing*, and reports
what happened rather than hoping:

```python
out = policy.run(observe=..., act=..., max_actions=250, on_underrun=robot.hold_position)

print(out)        # RunOutcome(250 actions over 17 chunks in 16.8s, completed)
out.underruns     # times the buffer ran dry before the next chunk arrived
out.underrun_s    # total seconds the arm spent with no command
out.max_seam_jump # largest per-dimension step across a chunk boundary
```

**The trade, stated plainly:** a prefetched chunk is computed from an observation taken
*before* the previous chunk finished, so the overlap window is open-loop. Freshness and
continuity are in direct opposition here and no setting gets both. `prefetch=False` restores
strictly closed-loop behaviour, stall included — right for bench work, wrong for a moving arm.

`max_seam_jump` is a **measurement, not a correction**, and it stays one unless you ask
otherwise. `smooth_seam=N` ramps the first N steps of each new chunk out of the last executed
action, and it is the only setting in this library that changes a number on its way to the
motors — so it is off by default and guarded twice: it applies only when the chunk declares an
*absolute* action space (`action_chunk.action_space`), and never to the first chunk of a run.
Blending deltas is not smoothing; it rescales the increments and moves the arm somewhere the
model never asked for, so a delta chunk is passed through untouched even when you ask.
`max_seam_jump` is measured *before* any blending, so turning it on cannot hide what it smooths.

## Many robots at once

**One `Policy` per control loop.** A `Policy` holds one action buffer, and two loops popping
from it do not take turns — they interleave. Driving the same handle from a second thread
raises, because both quieter options are worse: interleaving sends each robot a shuffled half
of the other's plan while both loops report success, and a blocking lock would make the second
loop run at half rate, underrunning on every chunk.

```python
def drive(robot, model):
    with sequence_ai.connect(model=model) as policy:      # one each
        return policy.run(observe=robot.read, act=robot.apply,
                          validate=robot.is_safe, max_actions=250)

with ThreadPoolExecutor() as pool:
    left, right = pool.map(drive, [arm_l, arm_r], ["my-pi05"] * 2)
```

This costs nothing to follow: the handshake is paid *once per `Policy`*, not once per call. How
many robots one deployment serves at once is its own declaration — each worker's slots
(`@seq.concurrent(replicas_per_gpu=N)` model copies per card) and as many workers as its GPUs allow
(`max_gpus=`, your account's limit; [below](#state-across-steps-and-more-than-one-robot-at-a-time)); a robot
that finds every slot taken and no GPU left waits in line before its loop starts, or is refused with
`on_full="reject"`.

## What the loop says while it runs

`run()` reports lifecycle, not telemetry:

```
  starting…      first chunk on its way, or a cold worker loading — nothing is moving yet
  running        a command has reached the robot
  done
```

Waiting out a cold worker (as long as your policy takes to load) and losing a few hundred
milliseconds at a chunk boundary are the library's problems, not yours — `startup_timeout_s` defaults to 1800 s,
so the first call on a cold worker waits rather than failing. Every number is still in
`RunOutcome` afterwards if you want it.

```python
policy.run(..., on_status=log.info)     # programmatic
policy.run(..., progress=False)         # silent
```

The default `progress="auto"` writes a single line to **stderr** only when stderr is a
terminal — nothing in a script, a pipe, or a log file.

## Cold starts

A worker that is not running starts when a robot connects: the platform starts a machine with your
image, mounts your weights and runs your `@seq.load` (and `@seq.warmup`). For a multi-gigabyte
checkpoint that is minutes — the weights read, the model compiled — while a control loop's budget
for one chunk is a few hundred milliseconds. Those two numbers are why connecting is a separate
call from controlling.

### A worker stays warm for `idle_timeout`

After its last request a worker stays for `idle_timeout` seconds — 600 unless your policy
declares otherwise — and every call inside that window is warm. The clock restarts on each request,
so a loop that calls even once a minute never goes cold. Cross the window with no calls and the
worker stops; the next connect pays the cold start again.

```
connect ──► cold start ──► warm ◄────────── idle_timeout idle ──────────► stopped
                             ▲                                              │
                             └────── any call restarts the window ──────────┘
```

**Why 600 by default, and not longer.** A held GPU bills whether or not you call it, and a session
with thinking time in it is mostly holding: ten minutes covers a loop and a short pause between
runs without paying for a coffee break. The trade is yours: `idle_timeout=1800` on
`@seq.policy` holds longer, `min_workers=1` keeps a worker running with no robot connected (no
cold start, billed while it runs), and `seq policy scale` keeps some warm in working hours only.

The practical consequence, if you are running a batch of experiments: **the gap between them is
what costs you, not the number of them.** Ten tasks run back to back pay one cold start; the same
ten with a 20-minute analysis gap between each pay ten.

`wait_until_ready()` reports on a worker and starts a cold one, but it does not hold a running one —
only acting does.

### Connect first, then drive

```python
policy.wait_until_ready()          # once, before the robot needs to move
while running:
    policy.next_action(observe())  # every one of these is warm by construction
```

`wait_until_ready()` needs no observation — a robot should be able to bring its model up before
it is in position, which is exactly when it has no frame worth sending. It is authenticated but
**not billed**: it touches no GPU. It is also what *starts* the worker, so polling it is the
thing that brings the model up, not merely a way to watch.

`ready()` is the non-blocking form, returning `(ready, eta_seconds)`.

**Skipping it does not fail — it silently costs you the loop.** Measured on one run against a
cold endpoint, the client starting anyway:

```
first control request   13,533 ms      <- the cold start, now inside the loop
p50                        415 ms      <- the steady state was always fine
mean                     1,096 ms      -> 0.49 arms sustainable   
mean without that one      441 ms      -> 1.21 arms sustainable   
```

One request that should not have been in the loop is the entire difference between sustainable
and not.

### If you skip it anyway

Connecting to a cold deployment does not stall — it answers 503 at once with the number:

```python
try:
    policy.predict(observation)
except sequence_ai.Unavailable as exc:
    if exc.warming:
        print(f"loading; ready in ~{exc.retry_after_s}s")   # 174
```

`run()` waits that out for you by default (`startup_timeout_s=1800`), **and only before the
first action**. It reports `starting…` while it does, so a wait is never mistakable for a hang:

```python
policy.run(..., startup_timeout_s=0)     # opt out: fail immediately on a cold worker
```

That line is the whole design. Before the first action nothing is moving, so waiting is free.
Once the arm is in motion, silently pausing it for two minutes and resuming from a
two-minute-old plan is worse than stopping — so mid-run warming is raised, and `hold` fires.

A deployment whose every slot is taken, with no GPU left to start another worker, is waited out the same way: the
robot is put in line before its control loop starts, and `connect()` asks again at the pace your key's rate limit
allows, printing `[sequences] queued for a GPU slot: position 2, about 30s …` (`on_queue=` replaces the line). It
gives up after the deployment's `queue_timeout_s` with the reason in one sentence. Closing the policy (`with` does it)
gives its slot — or its place in line — to the next robot at once.

## How big your frames are is how fast you go

**Latency scales with the bytes you send, at roughly 16 ms per kB.** This is the single largest
thing under your control, and it is larger than the model:

```
observation    p50      mean     sustainable arms
14.6 kB      586 ms    623 ms         0.86        
 3.4 kB      384 ms    433 ms         1.23        
```

Measured alternating A/B against one warm worker over one connection, so that drift in the
service cancels. `inference_ms` did not move between the two (119 ms against
111 ms) — every millisecond of the difference was transport. A direct measurement against the
worker agreed: 181 ms for the same 11.2 kB.

**Send JPEG, not arrays.** A frame as a list of integers is roughly 16x the bytes of the same
frame as base64 JPEG, and it is the most common way to land on the slow side of that table.

**Use quality 95. Do not go below 90.** With the sampling noise pinned, on a real DROID frame:

| quality | max\|Δ action\| | share of action amplitude |
|---|---|---|
| 95 | 0.0048 | 0.51% |
| 90 | 0.0019 | 0.20% |
| 85 | 0.0177 | **1.86%** |
| 75 | 0.0269 | 2.86% |

Quality 85 and below is where the error jumps by an order of magnitude. This section previously
read "quality 85 is safe" and quoted the 0.51% figure next to it — but 0.51% is **q95's** number,
and q85 is nearly four times worse. `sequence_ai.encode()` defaults to 95 so the decision is not
yours to get wrong.

### Hand it the native frame

**Do not downscale before calling `encode()`.** Give it whatever your camera produces: it caps the
long edge at 640 px, keeps the aspect, and encodes JPEG at quality 95. Resizing to what the model
reads — a padded square, a crop — is your policy's own preprocessing, in its `infer()`, where it
runs the operation the checkpoint was trained with. A frame you shrank yourself first is two
resamples where one would do, at a size with no claim to being right.

**Always JPEG.** Measured in action space against the model's own call-to-call spread, quality 95
is indistinguishable from lossless; the cliff is far below, so there is no reason to spend 3-4x the
payload on PNG — a native 1280x720 frame uploaded whole as PNG is 3.6 MB per view.

**Fidelity below that is not a gentle slope.** Degrading the frame further does not degrade the
policy proportionally; it stops working. Quantising each channel to four levels — layout, edges,
shadows and hues all preserved, a milder change than any sane JPEG setting — took pi0.5-DROID
from placing the cube in the bowl in 5.3 s to never lifting it at all, with its closest approach
20x worse than the successful run's final distance. Measured in simulation, 2026-09-11.

## What your deployment reads

Which cameras, how many frames of each, how long each proprioception and state vector is, whether
it needs an instruction: that is what your policy declared (`observation=` on `@seq.policy`), and
the worker refuses an act that does not match, saying what is missing. So the client checks first.
The first request connects, which hands back the declaration, and every observation is checked
against it before it is sent; frames beyond the declared history are dropped before upload.

```python
obs = sequence_ai.observation(
    images={"exterior_1": cam.read(), "wrist_left": wrist.read()},   # ndarray or PIL
    joints=arm.joint_positions(), gripper=[arm.gripper_position()],
    instruction="pick up the cup",
)
pred = policy.predict(obs)          # connects, then checks obs against what my-pi05 declares
```

A mismatch warns once and names it (`missing view(s) ['wrist_left'] … refused`);
`connect(validate="strict")` raises `InvalidRequest` instead, `"off"` skips the check. From then
on `policy.contract` holds the declaration, and `observation(..., contract=policy.contract)` checks
while it builds: a camera the policy does not read is dropped with a warning that names it, and
what the worker would refuse raises before anything is sent.

Each vector is checked against its own length. Eight joints and no gripper sums to the same eight
numbers and is still wrong — the policy reads the gripper from its own field — so it is refused,
not reshuffled.

## What to do with the rows that come back

They are what your policy's `infer()` returned. What they mean is the policy's own declaration —
`action=seq.ActionContract(...)`: the robot, the action space, the dimensions, the rate — which
the worker checks on every chunk, and which `pred.action_chunk` carries (`action_space`, `robot`,
`control_frequency_hz`). Turning a row into a command for your controller is the robot's half: in a
template, its `client.py`. Execute `open_loop_horizon` rows (`replan after N`) before observing
again; `run()` and `act()` do.

## Three ways to drive it

From most control to least. They are the same request underneath; the difference is who owns
the loop.

```python
policy.predict(obs)            # the whole chunk, you do everything
policy.next_action(obs)        # we hold the buffer, you own the cadence
policy.run(observe=, act=)     # we own the loop
policy.act("...", observe=, act=)   # we own the loop and the instruction
```

`act()` is `run()` with the instruction pinned and a time bound required — the shape a VLM calls
as a tool. Everything `run()` accepts, `act()` accepts.

## The other endpoints: chat and decide

Act is a loop; these are one-shot calls to models the platform serves itself
(`sequence_ai.models(kind="model")` lists them; `models()` with no arguments — and `seq models` —
also lists every template `seq init` writes, each marked `kind="template"` with its `template_kind`).
Same key, same typed errors.

```python
r = sequence_ai.chat("claude-opus-5-5", [{"role": "user", "content": "what should the arm do?"}])
r.content            # the reply — no choices[0] wrapper
r.tool_calls         # set instead when finish_reason == "tool_calls"
```

`chat()` is the slow brain: a sentence in, a sentence (or a tool call) out. It is answered by an
external provider with no cold start. `reasoning={"effort": "minimal"}` and the other model
parameters (seed, stop, top_p, …) pass through under their usual names (a model's
`supported_parameters` in `models()` says which it takes); reasoning tokens are billed output and
count against `max_tokens`.
`decide(model, state, questions)` sends a state with typed questions and returns calibrated
probabilities; `help(sequence_ai.decide)` shows the `criteria` each question type needs. Both
results carry `cost_usd`. A request the provider rejects raises `InvalidRequest` in its own words;
a provider error (502) or a rate limit / no answer (503) raises `Unavailable`; none is billed.

## Benchmarks & eval

The other half of the SDK: instead of driving a robot, **score a deployed policy** on a set of
simulation tasks. You write a benchmark the way you write a policy — a decorated class — register it with
the same `seq deploy`, bridge its observations and actions to the policy with an **adapter**, and run
`seq eval run`. Before spending GPUs, `--dry-run` shows the estimated wall-clock, the dollar cost, and how
many GPUs you will actually be granted. The sims and the policy both run on the platform.

```bash
export SEQUENCES_API_KEY=seq_live_...          # identifies your account (owns deployments, billed for evals)
seq init toy-policy                            # toy-policy/     — a @seq.policy (policy.py, and client.py for a caller)
seq init toy-benchmark                         # toy-benchmark/  — a @seq.benchmark (benchmark.py)
seq init toy-adapter                           # toy-adapter/    — the @seq.adapter between them (adapter.py)
```

An adapter is the third kind you register, and the only one without a machine: it belongs to neither the policy nor
the benchmark but to the pairing an eval runs. `seq deploy` registers it as a version — the same file again is the
version it already is — and the eval names it (`--adapter toy-adapter@v1`, or `toy-adapter` for its latest); the
platform runs it inside the benchmark's container, so changing it changes neither side's version.

```bash
seq deploy toy-policy/policy.py                           # a @seq.policy deploys, as toy-policy
seq deploy toy-benchmark/benchmark.py                     # a @seq.benchmark registers; its sim image builds
seq deploy toy-adapter/adapter.py                         # a @seq.adapter registers, as toy-adapter@v1 (nothing builds)
seq eval run toy-policy --benchmark toy-benchmark --adapter toy-adapter --envs 16 --dry-run
seq eval run toy-policy --benchmark toy-benchmark --adapter toy-adapter --envs 16
```

The same from Python — `seq.deploy(Cls)` does what `seq deploy <file>` does:

```python
import seq

est = seq.eval("toy-policy", "toy-benchmark", adapter="toy-adapter", envs=16, dry_run=True)
est.wall_clock_s, est.cost_usd     # an estimate — it starts nothing and reserves nothing

report = seq.eval("toy-policy", "toy-benchmark", adapter="toy-adapter", envs=16)
report.metrics["success"].overall   # "success" is YOUR metric key from @seq.check — no platform success_rate
report.metrics["success"].per_task  # each task's value with its episode count
report.cost_usd         # actual dollars, reconciled from real GPU-seconds
report.dashboard_url    # the eval in the console
report.videos           # the recordings the platform made (if render= was declared)
report.adapter_lossy    # every lossy transform, with its reason
```

`--adapter ./toy-adapter/adapter.py:ToyAdapter` registers a local file first. `seq adapter ls` lists your
adapters, `seq adapter show toy-adapter@v1` says whether that version is lossy, `seq adapter pull toy-adapter@v1`
writes its file back exactly as it was registered (`seq.adapter_source("toy-adapter", 1)` returns it) — edit it and
`seq deploy` it again for the next version — and `seq adapter check adapter.py` (or `seq validate`) classifies every
step without sending anything; `seq adapter check adapter.py --benchmark
toy-benchmark --policy toy-policy` also runs a sample of each side through it and says where they do not fit, with no
eval and no GPU.

`seq eval status <id>` says where an eval is (in line for GPUs, running, done) and `seq eval cancel <id>` stops it;
`seq eval report <id> [--wait] [--videos DIR] [--csv PATH]` reads it later; `seq eval ls <policy>` lists a
deployment's evals over time, 50 a page (`--limit N`, and `--cursor C` for the next page, as the page's last line
says — every `ls` pages this way, the same pages as the console), and `seq eval schedule <policy> --benchmark <name> --every daily` runs one on a schedule.

**You lay the eval out; the platform never picks the numbers.** The policy runs on its own instances
(`--policy-instances`, on the deployment's cards unless `--policy-gpu` names one) and the benchmark on its own GPU
instances (`--benchmark-instances`, each on an L4 or an RTX-PRO-6000 — or a CPU machine for a `gpu="cpu"` benchmark —
running `--envs` environments at once); unset,
each is one instance. Every card counts against your account's limit: past it the eval is refused, and while your
cards are busy the whole eval waits for them (up to 10 minutes, then it is canceled; nothing is held or billed while
it waits, and `seq eval status <id>` shows the wait) — never a silently smaller layout. `--dry-run` shows the layout,
the cards against your account (what serving and evals hold, what is free, how many short and why) and the estimate.
`--compare` and `--sweep` add arms to the same eval, run side by side on the same episodes. If the policy and
benchmark disagree on obs/action shape and you pass no adapter, eval refuses up front and asks for one rather than
handing you a silently-wrong score.

**The full reference — the benchmark lifecycle (`@seq.setup` / `@seq.reset` / `@seq.check` / `@seq.score`,
the platform-owned reset→step→check loop), `assets=` + `render=`, adapter loss labelling, billing,
composite (VLM) evals, and every `seq.eval` parameter — is in [`eval.md`](eval.md).**

## Safety

**An action returned by any model is model output, not a safe robot command.**

This library does not check joint limits, reachability, collisions, or whether a step is safe
at the robot's current velocity. Bounds checking, a watchdog and an e-stop belong between
this library and your motors.

```python
policy.run(
    observe=robot.read_observation,
    act=robot.apply_action,
    validate=robot.is_safe,   # return False to stop the loop
    hold=robot.hold,          # called if it stops early, or if act() raises
    max_actions=250,
)
```

`max_actions` is required and keyword-only. There is no `run_forever()` — an unbounded loop
that moves a robot should not be startable by accident.

`validate=None` is allowed for bench and simulation work, and warns once so it cannot happen
silently on real hardware.

**Declared limits are enforced twice.** An action contract can carry `bounds` (each dimension's `[low, high]`) and
`max_delta` (the largest change between consecutive steps):
`seq.ActionContract(..., bounds=[(-0.05, 0.05)] * 6 + [(-1, 1)], max_delta=0.02)`. The worker clips every chunk
into them before it leaves — the response's `warnings` name each clipped step and dimension — or, with
`on_violation="refuse"`, answers `500 policy_error` (`details.reason: "bounds"`) instead. The robot client clips again before every action
it hands out, measuring `max_delta` from the previous action even across chunks (a new `run()` starts afresh).
They narrow what reaches the motors; they are not a substitute for the checks above.

## Errors are typed

Every error the platform sends has one shape — `detail`, `code`, `retryable`, `retry_after_s`,
`request_id`, `hint`, `details` — and becomes one exception class, the same from `chat()`,
`connect()` / `predict()`, `seq.deploy(...)` and every `seq` command. Switch on `.code`, show
`.hint`, quote `.request_id` when you report a problem.

| class | `.code` | what to do | `seq` exits |
|---|---|---|---|
| `InvalidRequest` | `invalid_request`, `payload_too_large` | fix the request; it fails the same way every time | 2 |
| `AuthError` | `unauthenticated` | the key is missing, revoked or expired | 3 |
| `PermissionDenied` | `forbidden` | this key may not do that — an act key used for chat, a key bound to another deployment | 3 |
| `OutOfCredit` | `insufficient_credit`, `budget_exhausted` | stop and hold; top up, or raise the deployment's budget | 6 |
| `NotFound` | `not_found` | no such name — or not yours, which reads the same | 4 |
| `Gone` | `gone` | retired on purpose; `.replacement` names what to use instead | 4 |
| `Conflict` | `conflict` | the state is in the way: a name taken; a deployment building, failed or stopped; a limit reached — your account's GPUs all in use is `details["reason"] == "gpu_limit_reached"`, and the message says what serving and evals hold | 5 |
| `Unavailable` | `busy`, `warming`, `unavailable`, `upstream_error`, `timeout` | worth retrying after `.retry_after_s`; `.warming` means a cold worker and `.queued` a robot in line for a slot — before the first action `connect()` waits both out; with no GPU free, `details["reason"]` says why (`gpu_busy`, `gpu_low_stock`, `deployment_full`, `queue_timeout`) | 7 |
| `PolicyError` | `policy_error` | your policy's own code raised, or broke its declared contract; `.remote_traceback` has its frames. A built-in exception (`ValueError`, …) comes back as itself | 1 |
| `InternalError` | `internal`, `dependency_missing` | ours: report it with `.request_id` | 1 |
| `ChunkExhausted` | — | asked for an action with an empty buffer and no observation: pass `observation=` every call | — |

A failed `seq` command prints `error[<code>]: <detail>`, then `hint: …` when there is one (and
`request_id: …` for a 500), and exits with the number in the last column — 0 is success, and
`seq eval run --gate` exits 8 when a metric misses its bar — so a script can tell "retry" from
"give up" without reading the text.

## Debugging a deployment

When a deployment misbehaves — some control requests slower than expected, or one that just failed —
two read-only commands localize the fault and pull the record for a single request by its id:

```bash
seq policy doctor <deployment>              # probe the direct-connect chain segment by segment
seq policy logs   <deployment>              # structured logs, connect + worker sinks merged, time-ordered
seq policy logs   <deployment> --rid r-…    # one request: worker phase joined to client timing, attributed
```

`seq policy doctor <name>` runs on the platform, segment by segment: the deployment's state and serving version,
a running worker's `/ping`, and one `/act` on a synthetic observation built from the contract you declared
(black frames, zero vectors) — with the round trip split into **network vs. worker-handler**, so you can tell
a slow network from slow inference. It never starts a worker: a deployment scaled to zero says so.

`seq policy logs <name>` shows your workers' own lines — the model load, every act with its request id and
timings, every error with its reason — beside one line per connect and the timings your robots' clients reported.
`seq policy logs <name> --rid r-…` joins one request across all three. Workers built after 2026-10-02 send their
lines; an older deployment shows them once deployed again. Lines are kept seven days.
Authorization is enforced server-side against your `SEQUENCES_API_KEY`; you only see your own deployments.
For the account side, `seq account` (`sequence_ai.account()`) shows your balance, usage, limits and GPUs.

Full walkthrough, the segment catalog and the redaction guarantee are in [`observability.md`](observability.md).

## Authoring & deploying your own policy

Everything above drives a deployment. To make one — declare the environment your policy needs,
point it at your weights, and have the platform validate that environment — use `seq`: the `seq`
import and the `seq` command. They come with the same
`pip install sequence-ai` on Python 3.10+; on a 3.9 robot that install stays httpx-only.

A policy is a class decorated with `@seq.policy`. You declare *what it needs* — the GPU, the image,
where the weights live — and mark its lifecycle methods; nothing is built or downloaded on your
machine, the declaration is pure data the gateway builds from.

```python
import seq

@seq.policy(
    gpu="L4",                                  # a card `seq gpus` lists ("H100:2" is two of them), or "cpu"; a
                                               # list is tried in order, for serving and evals alike
    cpu=4, memory=16,                          # cores and GiB requested, or (request, limit); undeclared: the
                                               # minimum, 0.125 / 0.125, and the worker may use more
    image=(
        seq.Image.debian_slim()                # a concrete Debian base, pinned
        .apt_install("git", "libgl1", "libglib2.0-0")   # system libraries
        .run_commands(                         # openpi installs from source: its PyPI name is a placeholder
            "git clone https://github.com/Physical-Intelligence/openpi /opt/openpi",
            "cd /opt/openpi && uv pip install --system -e .",
        )
    ),
    weights=seq.Volume.from_name("pi05-ft"),   # your checkpoint: an uploaded volume, hf://, or a template's (below)
)
class Pi05(seq.Policy):
    @seq.load
    def load(self, weights_dir):               # cold start: pull the checkpoint into VRAM
        self.model = ...

    @seq.infer
    def infer(self, obs, *, seed=None):        # per step: what the robot sends -> what it gets back
        return ...
```

`seq deploy pi05.py` uploads that file — and only that file, or its whole package when it lives in one (a directory
with `__init__.py`). A config file or a helper module beside it is shipped when the image names it (below); weights
never travel with the code, only through `weights=`.

### The image is declarative

Every `seq.Image` method returns a **new** image and builds nothing — the chain is a spec, and the
platform builds it into a layer. Start from one base, then extend it; the step order you
write is the build order, one for one.

| | |
|---|---|
| `seq.Image.debian_slim(python="3.12")` | a small, pinned Debian base — the default when you declare no image |
| `seq.Image.from_registry("nvidia/cuda:12.4.1-runtime-ubuntu22.04", python="3.12")` | start from any registry tag, carried through verbatim |
| `seq.Image.from_dockerfile("Dockerfile", context=".")` | build from your own Dockerfile; `context` is the directory its `COPY` / `ADD` read, shipped with the deploy |
| `.apt_install(*pkgs)` | system libraries (`apt-get install`) |
| `.uv_pip_install(*pkgs)` | Python packages (`uv pip install`) |
| `.run_commands(*cmds, secrets=[seq.Secret.from_name("gh")])` | arbitrary build commands, run in order; `secrets=` reach these commands alone |
| `.env({...})` | build-time environment variables (`PORT`, `SEQ_*` and the import path are the platform's) |
| `.add_local_file("config.yaml", "/root/config.yaml")` | a local file, baked into the image at that path |
| `.add_local_dir("assets", "/root/assets")` | a local directory, likewise (code-sized: checkpoints go in `weights=`) |
| `.add_local_python_source("helpers")` | a local module or package the code imports, importable by name on the worker |

`python=` picks the worker's Python: `"3.11"` (the default), `"3.12"` or `"3.13"`, or `"image"` for the one the
base already has (it must be 3.11 or later). An official `python:*` base brings its own; a Dockerfile brings its own
unless you pass `python=`.

A build step that needs a credential — a private git repo, a private package index — declares it on that step:
`.run_commands("pip install git+https://$GH_TOKEN@github.com/acme/private", secrets=[seq.Secret.from_name("gh")])`.
Only that step sees `GH_TOKEN`, only while it runs; no image layer and no worker holds it.

The platform's own runtime packages (an HTTP server, pydantic, numpy, pillow) go in right after the
base, **before** your steps — so a version you pin (`.uv_pip_install("numpy==1.26.4")`) is the one your
policy runs with.

`@seq.policy(deps=[...])` is shorthand for a trailing `.uv_pip_install(...)`, so a quick policy needs
no explicit `image=` at all. Build-time `env` carries ordinary variables only — a credential put there
is rejected, because the image is not a place for secrets (see below).

### Where it runs, how long it may take, and what the build is doing

- **`gpu=["H100", "A100"]`** is a fallback list: when the first card cannot be had within a minute, the next one is
  tried, in order — for serving and evals alike — and a worker is billed for the card it got. Every entry needs the
  same card count (`"H100:2"` falls back to `"A100:2"`).
- **`region="us-west"`** (or `"eu"`, `"ap"`, `"us-east"`, … — `GET /v1/pricing` lists them) starts the workers
  there, near the robots. A pinned worker is billed at the region's multiple of the card's price (1.15× for a broad
  region like `"eu"`, 1.75× for a narrow one like `"us-west"`); `seq policy estimate <file>` shows it.
- **`timeouts=seq.Timeouts(startup=900, request=5)`**: a worker that has not loaded within `startup` seconds
  fails (the build check is held to the same), and one inference longer than `request` seconds is answered `504`
  while the worker finishes it before serving again. Defaults: 1800 and 300.
- **`seq deploy`** shows each phase of the build as it begins — the image, the weights, the worker loading, the
  `/act` check — with the newest line of the build's log; **`seq policy logs <name> --build`** prints the newest version's
  phases and its whole log, then or later.
- **`seq secret set <name>`** reaches deployments that declare the secret without a re-deploy: their next workers
  start with the new value (running ones keep the old one until they are replaced).
- **`models=["qwen3-8-27b"]`** names the platform models the policy calls while it runs (a planner's VLM, a decide
  model). The act key that drives the deployment may call exactly these on `/v1/chat` and `/v1/decide` — any other
  model is refused `403`, and a full key is never needed on the robot or the worker. `seq deploy` refuses a name the
  catalogue does not have (`seq models --kind model` lists them).

### Before it serves: one real `/act`

A policy that declares `observation=` is proven before its first robot: the build ends by calling its
`/act` once on a synthetic observation — black frames at each declared camera size, zero vectors of the
declared lengths, an instruction. A policy that cannot answer it fails the build, and `seq deploy` shows
the policy's own error instead of a robot finding it later. Without an observation contract there is
nothing valid to send, so only the load is checked.

### Knobs a request may turn: inference parameters

A keyword of `@seq.infer` with a default is an inference parameter:
`def infer(self, obs, *, seed=None, replan_steps: int = 5)` declares `replan_steps`. Its type is the annotation
(or the default's): `int`, `float`, `bool` or `str`. `seq validate` lists them; a robot sets one per call with
`policy.predict(obs, params={"replan_steps": 3})`, and an eval with `seq eval run … --param replan_steps=3` (or
`--sweep replan_steps=2,5` to compare values). The worker casts each to its declared type and refuses, before
any inference, a name the policy does not declare (`400 params`). Unset, the default applies. An eval sets an
adapter's `__init__` keywords and a benchmark's `@seq.setup` keywords the same way, plus its own
`eval.steps_per_query` — see [Parameters in an eval](eval.md#parameters-in-an-eval).

### State across steps, and more than one robot at a time

- **`streaming=True`** (with an `@seq.on_episode` method) keeps every `/act` of one episode on the same
  replica, so state your class holds — a KV cache, a frame window — carries over. `policy.run()` ends the
  episode when it returns, even on an exception, and `@seq.on_episode` frees that state; an episode left
  idle for 5 minutes (a robot that crashed) is ended by the worker. A policy that only needs the last N
  frames needs no state: declare `observation=seq.ObservationContract(history=N)` and the robot sends
  them. (`frames_per_chunk=` is retired: nothing on the platform ever read it.)
- **A worker has slots; a robot holds one for its whole control loop.** Each model copy is its own process and
  serves one robot at a time, so a worker's slots are `cards // gpus_per_replica × replicas_per_gpu`.
  `@seq.concurrent(replicas_per_gpu=2)` under `@seq.policy` puts two copies on each card (the worker starts them all
  when it is ready, and copies that do not fit fail it there with an out-of-memory reason — that is how you find the
  number; a JAX policy turns preallocation off in its image to share a card). `gpus_per_replica=N` gives one copy N
  cards (it must divide `gpu="TYPE:N"`'s N); by default one copy sees every card of its worker and splits them itself.
  `target_inputs` is how many robots a worker takes before another starts and `max_inputs` the most it takes while
  that one boots; both default to the slots. Every argument is optional; without the decorator, one copy per card.
- **More workers, warm workers — limits count GPUs, never workers.** A deployment starts another worker whenever
  every slot is taken, until it reaches **`max_gpus=N`** (the most cards it may hold at once; undeclared, no cap of its
  own) or your account's GPU limit. A robot that arrives when every slot is taken and no GPU is left gets
  **`on_full`**: `"queue"` (the default) puts it in line *before* its control loop starts — `connect()` waits it out
  and prints `queued for a GPU slot: position 2, …` — for at most **`queue_timeout_s`** (default 120); `"reject"`
  refuses it at once. A loop that has started is never interrupted by a robot arriving, and closing the policy gives
  its slot to the next in line at once. `min_workers=1` keeps that many workers running with no robot connected
  (no cold start, billed while they run); `buffer_workers=1` keeps one spare while robots are connected;
  `idle_timeout=300` is how long an idle worker stays (default 600 s). `seq policy scale <name> --max-gpus 4
  --on-full reject --min 1 …` changes any of these at once, without a redeploy, and `seq policy status <name>` shows
  each worker's slots and sessions, the robots in line, and why a worker died.

### GPUs, limits and what they cost

`seq gpus` lists the cards `gpu=` can name — memory, price per hour and per second, whether a policy and a benchmark
may use it — and what a CPU core and a GiB of memory cost (`sequence_ai.gpus()` from Python). Your account may hold 8
GPUs at once, serving and evals together; `seq account` shows how many each holds now, how many are left and which
evals are waiting for GPUs (a count the platform could not read says `unknown`, never full).

**GPU, CPU and memory are billed apart, per second.** A card is billed per card. CPU and memory are billed on
**the greater of what the worker requests and what it actually uses**: `cpu=` / `memory=` is the
request, and without them a worker requests only the minimum (0.125 cores, 0.125 GiB) and may use more — you then
pay for what it uses. Write `cpu=(2, 4)` to cap it; declare `memory=` for a policy that runs several copies or holds
large buffers, so the worker is not squeezed to the minimum. `seq deploy` prints one worker's price per hour in three
parts once it is ready. `seq policy usage <name> [--days N]` shows a deployment's GPU card-hours, CPU core-hours and
memory GiB-hours per day with what each cost, and `seq billing export --from YYYY-MM-DD --to YYYY-MM-DD [--csv]` the
account's bill per day, deployment and resource.

When there is no GPU for a request, the error says which of five things happened, in one sentence:
your account's GPUs are in use (with what serving and evals hold), every GPU on the platform is busy, the card you
asked for is out of stock (list fallbacks in `gpu=`), the deployment holds its `max_gpus`, or a queued robot waited
past `queue_timeout_s`. `details["reason"]` carries the same as a code (`gpu_limit_reached`, `gpu_busy`,
`gpu_low_stock`, `deployment_full`, `queue_timeout`).

### A model on more than one machine

Write the total: `@seq.policy(gpu="H100:16")`. Past 8 cards the platform runs the model on whole machines of 8 cards —
16 is two machines, at most 256 cards — started together, stopped together and billed together (every machine's
cards, CPU and memory). The account needs a GPU limit that holds the whole group at once; past the 8 an account has
by default, ask us.

- Your `@seq.load` and `@seq.infer` run on every machine, one process each, seeing that machine's 8 cards. Before
  your code is imported the platform sets `RANK`, `WORLD_SIZE`, `MASTER_ADDR`, `MASTER_PORT` and `LOCAL_RANK=0`
  and, when your image has torch, initializes `torch.distributed` (NCCL) across the machines — check
  `torch.distributed.is_initialized()` rather than initializing it again. Code on another framework reads the same
  variables to join its own, on a port other than `MASTER_PORT`.
- A robot reaches machine 0. Every request — and every episode end — runs on every machine at once, so the
  collectives in your code meet; machine 0's answer goes back to the robot.
- The group is one copy of the model and holds one robot's control loop: leave `gpus_per_replica` and
  `replicas_per_gpu` unset, list one card type, and keep a policy with `@seq.plan` on one machine.
- When one machine stops, the whole group stops (`group_member_lost`) and the next connect starts a new one.
  `seq policy status <name>` shows each machine's rank, its state and when it last answered.

### Weights live on a volume, never in the image

`weights=` is copied once, at deploy, into a volume the platform mounts at `/data` — never baked into an image
layer (so a multi-GB checkpoint never bloats the build), and every cold start reads that copy. It names one of
three things:

- **An official template's weights** — `seq.Weights.template("pi05-droid", version=1)` (the string form is
  `"template://pi05-droid@v1"`): the unmodified checkpoint a template ships with, plus what it loads at runtime
  (pi0.5's tokenizer). A published version never changes; leave `version=` out to take the newest one at each
  deploy. No key is needed. This is what `seq init pi05-droid` writes.

- **Hugging Face** — `hf://org/model` or `hf://org/model@<revision>`. At deploy the revision is resolved
  to a commit and exactly that commit is copied, so a re-deploy is deterministic. A private or gated repo
  needs a token: store it once (`seq secret set hf --env HF_TOKEN`) and declare it,
  `secrets=[seq.Secret.from_name("hf")]`. The copy runs with only that token; declaring it also hands
  `HF_TOKEN` to your policy at runtime. A repo the platform cannot read — private without a token, gated,
  a wrong revision — is refused when you deploy, in seconds, with what to fix.
- **Your own files** — upload a local checkpoint directory once, then deploy it by name:

  ```bash
  seq volume put pi05-ft ./checkpoints/pi05-ft     # resumable: run it again after an interruption
  seq volume ls                                    # name, state, version, size (50 a page: --limit, --cursor)
  ```
  ```python
  @seq.policy(weights=seq.Volume.from_name("pi05-ft"), ...)    # or weights="vol://pi05-ft@v2" to pin
  ```
  Every file's md5 is checked when the upload completes; only then does the version become active (and
  billed as storage). A new `put` of the same name makes the next version.
  `seq volume rm` is refused while a deployment's weights, or a registered benchmark's assets, use the volume.
  Deleted a volume, or replaced a version with a new `put`, by mistake? Write to founders@generalsequences.com
  within 7 days and we can bring it back.

**Weights from your own storage bucket or an https URL are deprecated.** This release still deploys them, with a
warning; the next one refuses them. Move the files into a volume once — download them, then `seq volume put <name> <dir>` — and
deploy `weights=seq.Volume.from_name("<name>")`, or point at the Hugging Face repo they came from.

Weights are validated as you write them: an unknown scheme, a `..` traversal, an `hf://` without a repo, or a
template name that is not one is rejected with a message that names the problem.

`seq.Secret.from_name(name)` references a credential by name; the plaintext never enters the SDK or
the deploy payload. Explicit `volumes=` (`seq.Volume.from_name(...)`) is not supported yet: a deploy
that declares one is refused rather than run without it.

### Versions, rollback, stop

Deploying a name that already serves never takes it down: the new version builds **beside** the serving
one, robots keep acting on the old version the whole time, and the new one takes over only once its build
is ready. A version that fails to build changes nothing — the old one goes on serving, and `seq deploy`
says so. New sessions go to the new version at once; a robot already acting moves to it at its next lease
renewal (a few minutes), onto a worker started for it in advance.

```bash
seq policy ls                        # your deployments: the serving version, one building beside it, the card
                                     #   (50 a page; the last line names the --cursor of the next)
seq policy versions pi05-ft          # v4 building · v3 serving · v2, v1 kept
seq policy rollback pi05-ft          # serve the previous version again — instantly, nothing is rebuilt
seq policy rollback pi05-ft --to 1   # or a specific kept version
seq policy stop pi05-ft              # emergency stop: every worker ends now, every connect is refused
seq policy start pi05-ft             # let robots connect again
seq policy rm pi05-ft                # delete it: stops its workers now, frees the name and the quota slot
                                     #   (refused while robots are connected: --force disconnects them)
```

The serving version and four earlier ready ones are kept; a sixth pushes the oldest out.

**A canary.** `seq policy traffic <name> v5=10 v4=90` sends one new session in ten to v5 and the rest to v4 (any
versions `seq policy versions` lists as serving or kept); `seq policy traffic <name>` shows the split, `--clear` sends everything to the
serving version again. A session keeps the version it started on for its lease renewals, so a robot never switches
versions mid-run. To try a version on one robot first, pin that robot's key (a key bound to this deployment) to it:
`seq policy traffic <name> --pin arm-7 v5`; `--unpin arm-7` lets it follow the split again. A pin reaches every gateway within
five minutes.

### Budget, schedule, alerts, and the price before you deploy

- `seq policy estimate policy.py [--hours-per-day 8]` prices the policy's card from the platform's list:
  its warm floor around the clock, or the hours a scale-to-zero deployment is in use. Nothing is built.
- `seq policy budget <name> --monthly 100`: when the month's spend reaches it, the deployment stops — workers end,
  connects are refused and say why. To start it again, raise the budget (or `--off`), then `seq policy start <name>`;
  under a budget already reached, `seq policy start` is refused.
- `seq policy scale <name> --min 2 --during "Mon-Fri 08:00-18:00" --tz America/Los_Angeles` keeps two workers warm in
  those hours (billed whether or not a robot is connected); `--clear` removes it.
- `seq policy alerts <name> --webhook https://… [--error-rate 0.05] [--p95-ms 500] [--budget-pct 80]` posts JSON to your
  https endpoint when a version fails to build, the hour's error rate or p95 crosses the threshold, or the budget
  nears or is reached. Each body is signed: `X-Seq-Signature: sha256=HMAC(secret, body)` with the secret printed
  once.

### Field data: capture, label, export

`seq policy capture <name> --rate 0.1 [--images]` makes the deployment's workers keep a tenth of its episodes (each kept
whole: the observation it sent, the chunk it got, the inference parameters), with the camera frames when you ask.
No redeploy: running workers pick it up within a minute. A robot marks how an episode went with
`policy.label(success=True, notes="")` after its `run()`. `seq policy data export <name> --format lerobot --out DIR`
writes a LeRobot v2.1 dataset (one frame per `/act`, the chunk's first action, the episode's label as
`next.success`; needs `pip install 'sequence-ai[data]'`), or `--format jsonl` the files as kept. Kept data is
billed as the `capture:<name>` dataset volume, removed after `--retention-days` (default 30), and deleted with the
deployment. `seq policy capture <name> --off` stops it.

### Have the platform validate it

```bash
seq init pi05-droid my-pi05          # scaffold a working template to edit
seq validate my-pi05/policy.py       # or a class in a file:  seq validate my_policy.py:Pi05
```

`seq validate` reads the policy's declaration — it never runs `load` or `infer` — into the deployment request,
validates it against the platform's contract, and echoes back exactly what will be installed, where the
weights mount and which files are uploaded — with **nothing built, run or sent**:

```
policy      my-pi05
platform    accepted  (validated against deployment contract 0.4.17)
image       nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04
  apt-get install   git, git-lfs, build-essential, clang, linux-headers-generic, libgl1, libglib2.0-0
  run               pip install --no-cache-dir uv
  run               git clone --recurse-submodules https://github.com/Physical-Intelligence/openpi /opt/openpi
  run               cd /opt/openpi && GIT_LFS_SKIP_SMUDGE=1 uv pip install --system -e .
  env               OPENPI_DATA_HOME=/data/.cache/openpi
weights     template://pi05-droid@v1
            → mounted at /data  (provisioned by the platform onto a volume, not into the image)
code        1 file(s), 4.8 KiB uploaded:
              policy.py
action      droid_franka · joint_velocity · 8 dims · 15 Hz  (every chunk is checked against this)
observation exterior_1 224x224, wrist_left 224x224 · joint_positions[7] · gripper[1] · instruction  (every /act is checked against this)
```

Only `policy.py` goes up: the robot's `client.py`, the README and the tests beside it stay on your machine.

When it reports `accepted`, `seq deploy my-pi05/policy.py` hands the build to the platform (it needs
`SEQUENCES_API_KEY`); `seq deploy … --dry-run` prints the same without sending it. A malformed declaration
fails here, at authoring time, with a readable error and a non-zero exit — not as an opaque build failure
minutes later.

## Configuration

```bash
export SEQUENCES_API_KEY=seq_live_...      # or pass api_key= to connect()
```

Get a key at [app.generalsequences.com](https://app.generalsequences.com).

## Secrets — bring your own keys (BYOK)

Some policies need a credential you own: a Hugging Face token for private `hf://` weights, or the key of a
model service of your own that a policy calls itself. A composite policy's planner needs none: by default it
calls the platform's own models through `/v1/chat`, with a short-lived credential the platform hands the policy,
and each call is billed to your account like any chat call; point it at a service of your own instead and that
service bills you. You store those keys once, by name, and refer to them from a policy by that name alone — **the key value never goes into your code, your logs, or any
output.**

A secret is **one name bound to a set of environment variables**. The two
canonical bundles are expressible by name alone:

```bash
seq secret set hf      --env HF_TOKEN            # private hf:// weights (and your policy's own use)
seq secret set planner --env PLANNER_API_KEY     # a composite policy's planner on a model service of your own
```

### Values are read from stdin, never the command line

There is no `--env KEY=VALUE` and no `--value` flag — a value never lands in `argv`, your shell history, or
the process listing. Pipe it in, or type it at the hidden prompt:

```bash
printf '%s' "$PLANNER_API_KEY" | seq secret set planner --env PLANNER_API_KEY
# or, interactively, you are prompted per key with the input hidden:
seq secret set planner --env PLANNER_API_KEY
#   Value for PLANNER_API_KEY (input hidden):
```

A bundle with several env vars reads one line per `--env`, in order:

```bash
printf '%s\n%s\n' "$WANDB_API_KEY" "$WANDB_ENTITY" | seq secret set wandb --env WANDB_API_KEY --env WANDB_ENTITY
```

### List and remove — names only, never values

```bash
seq secret ls          # name + env-var NAMES + timestamps; the value is never shown, ever
seq secret rm planner
```

`ls` is metadata only. There is **no command that reads a stored value back** — from the SDK a secret is
write-only. Re-running `seq secret set <name>` **rotates** the key (it replaces the whole bundle); no delete
first. A rotated value goes live on the next worker start; `seq policy stop <name>` then
`seq policy start <name>` replaces the running workers at once.

### Use it from a policy — by name only

```python
import seq

@seq.policy(gpu="L4", secrets=[seq.Secret.from_name("planner")])
class MyPolicy(seq.Policy):
    ...
```

`Secret.from_name` carries the name and nothing else — no value parameter exists, and it does no network I/O.
`seq deploy` checks at deploy time that the name exists and is yours.

`set`/`ls`/`rm` each require your key (`export SEQUENCES_API_KEY=seq_live_...`) and are scoped to it: another
key never sees your secrets. A missing or invalid key fails closed — nothing is stored,
listed, or deleted.

### One install, two halves

`pip install sequence-ai` on Python 3.10+ brings everything: the robot client (`sequence_ai`), the `seq` command
and the decorators, with pydantic and the shared contract package `sequence-base`. On Python 3.9 — a robot's
interpreter — the same install is the client alone, and `seq` says it needs 3.10+ instead of a stack trace.

## Limits

What the platform fixes; everything else is your code's. Each value is what the platform runs today.

| | |
|---|---|
| One inference | `timeouts=seq.Timeouts(request=…)`, default 300 s. Past it the call is answered `504`, and the worker finishes it before serving again. |
| Loading a worker | `timeouts.startup`, default 1800 s. A worker that has not loaded by then fails; the build check is held to the same. |
| An `/act` request body | 4 GiB. A larger one is answered `413 payload_too_large`. |
| A deploy or other management request | 16 MiB. |
| The code a deploy uploads | 8 MiB compressed: the entry file (or its package) and what `seq.Image.add_local_*` names. A source tree over 64 MiB is refused before it is sent. Weights never travel with the code — they go in `weights=`. |
| Robots one worker serves at once | Its slots: one robot's control loop per model copy — `@seq.concurrent(replicas_per_gpu=…)` copies per card (default 1), each over `gpus_per_replica` cards (default: all of the worker's). A request within a worker's slots is never answered `429`. |
| No slot free | Another worker starts while GPUs are left; connect answers `503` with `retry_after_s` while it does, and the SDK waits for it. With none left — the deployment's `max_gpus`, your account's, the platform's — `on_full="queue"` (the default) puts the robot in line before its control loop starts, for up to `queue_timeout_s` (default 120 s), and `on_full="reject"` refuses it at once. A loop that has started is never interrupted. |
| A model across machines | `gpu="TYPE:N"` with N a multiple of 8 past 8, up to 256: N ÷ 8 machines of 8 cards, one copy of the model across all of them, one card type. |
| GPUs at once | 8 per account, serving and evals together (`seq account` shows them); `max_gpus` caps one deployment. An eval they cannot fit waits in line up to 10 minutes. |
| A worker going away | Its acts are answered `503 unavailable` (`reason: draining`); the SDK reconnects to another worker and replays the act. |
| Shutdown | Before a worker is removed, the acts in flight finish and every replica runs `@seq.shutdown` — within 30 s. After that the worker is stopped without further notice. |
| Scale to zero | A worker idle for `idle_timeout` seconds (default 600) stops. `min_workers` (default 0) keeps some warm. |
| Cards | The platform's price list names them (`GET /v1/pricing`): A100, A100-40GB, B200, H100, L4 and RTX-PRO-6000 as of 2026-10. A benchmark's sims run on L4 or RTX-PRO-6000, or on CPU machines when it says `gpu="cpu"`. |
| Regions | Three broad regions (`us`, `eu`, `ap`) at 1.15× the card's price and eleven narrow ones at 1.75×; a deployment that names none pays no multiple. |
| Billing | Per second of a worker's life, at least 60 s per worker: each card, plus its CPU cores and GiB of memory at the greater of the request and actual use (the minimum, 0.125 / 0.125, when undeclared). |

## Install footprint

On a robot (Python 3.9), one dependency: `httpx`. On Python 3.10+, `seq` adds pydantic and `sequence-base`.

Extras add what some commands need: `image` (Pillow, for `encode()`), `data` (pyarrow, for the LeRobot export) and
`port`, what moving a CPU benchmark to the GPU runs on (`seq benchmark export`, `check-model`, `scaffold`,
`compare --replay`): MuJoCo 3.15.0, MuJoCo Warp 3.15.0 and SAPIEN 3.0.3, the versions the full test suite passes on.
`pip install "sequence-ai[port]"` (Python 3.10+; on Linux aarch64, MuJoCo Warp needs glibc 2.34 or later and SAPIEN,
which has no wheel there, is left out). A benchmark that runs on another MuJoCo version installs that version instead,
as `seq benchmark export` asks.

A robot controller is often on a Jetson with a pinned, fragile Python environment, and ROS 2
Humble ships Python 3.10. Every transitive dependency is another chance for the install to
fail on the machine that actually matters.

## Running the tests

```bash
pip install -e ".[dev,port]"
pytest
```

Nothing is skipped. Without `port`, the three port test files are skipped whole when they are collected (32 tests),
and a run that looks complete is not; on Linux aarch64, where SAPIEN does not install, its file is the one skipped.
