Metadata-Version: 2.5
Name: sequence-ai
Version: 0.56.8
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
Project-URL: Changelog, https://generalsequences.com/docs/changelog/
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<1,>=0.24
Requires-Dist: pillow>=10
Requires-Dist: pydantic>=2; python_version >= '3.10'
Requires-Dist: sequence-base<0.9,>=0.8.7; python_version >= '3.10'
Provides-Extra: data
Requires-Dist: av>=17; (python_version >= '3.10') and extra == 'data'
Requires-Dist: numpy>=1.24; extra == 'data'
Requires-Dist: pyarrow>=14; extra == 'data'
Requires-Dist: zstandard>=0.22; extra == 'data'
Provides-Extra: dev
Requires-Dist: av>=17; (python_version >= '3.10') and 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'
Requires-Dist: sequence-base<0.9,>=0.8.7; (python_version >= '3.10') and extra == 'dev'
Requires-Dist: zstandard>=0.22; extra == 'dev'
Provides-Extra: observe
Requires-Dist: av>=17; (python_version >= '3.10') and extra == 'observe'
Requires-Dist: numpy>=1.24; extra == 'observe'
Requires-Dist: zstandard>=0.22; extra == 'observe'
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_dev_...     # create one at app.generalsequences.com
seq init pi05-droid my_pi05_user         # an official template: the policy, the robot's client.py, a README
seq deploy my_pi05_user                  # the platform builds it and serves it, as your own deployment
```

Your key, which starts `seq_dev_`, manages your policies and reads the account, so the CLI and your own code
use it; making keys, paying and changing the account take you signed in (`seq login`, once, in your browser; `seq
login --profile NAME` keeps another sign-in apart, and `seq profile list` shows them). Adding a card and deleting the
account are done on the console's pages, which `seq billing card` and `seq account delete` open. A robot connects
with its own robot key, `seq_robot_`: made on a policy's Robot keys tab in the console, it drives that one policy and
nothing else. A key made earlier may start `seq_prod_` or `seq_live_`, and works as any other
([the kinds of key](https://generalsequences.com/docs/reference/#keys)).

What changed in each release: [generalsequences.com/docs/changelog](https://generalsequences.com/docs/changelog/).

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_user") as policy_user:  # your deployment, by the name you gave it
    while not done_user():
        policy_user.observe(robot_user.read_observation())  # cameras, joints and the task, in the shape your policy reads
        out_user = policy_user.act()                    # what the policy's act function returned, as it returned it
        for row_user in out_user["actions"][:8]:        # to its replan step, action_chunk_replan_at=8: then ask again
            robot_user.apply_action(row_user)           # your loop plays them, at your rate

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

`observe()` hands the policy what the robot sees, and `act()` returns what the policy answered. When to ask again and
how to play the rows is your own loop: this one stops the arm while each answer is on its way, and
[The loop is yours](#the-loop-is-yours) asks for the next chunk while one plays.

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. `seq init <template>` writes the template's stable version
when this sequence-ai can deploy it, and otherwise the newest version it can, saying which and why; `@stable`,
`@beta` or `@vN` after the name is written as named, with a warning when this sequence-ai cannot deploy it.

---

## 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*: the sentence goes to your deployed policy with the robot's cameras and joints, as any input does, and
`act()` asks the policy for what to do next, which your loop plays.

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

| | endpoint | who ships it |
|---|---|---|
| slow brain | `POST /v1/chat` | Messages in, one message out |
| fast brain | this package | `policy.observe(x)`, then `policy.act()` |

The sentence is one of the policy's inputs, in the shape the policy reads: the brain's thread keeps the latest one,
and the robot's loop hands it over with every observation it makes. Neither waits for the other.

```python
instruction_user = "pick up the cup"             # the brain's thread replaces it whenever it decides
while not done_user():
    policy_user.observe({**robot_user.read_observation(), "prompt": instruction_user})
    out_user = policy_user.act()
```

The act already on its way was asked with what was observed before it: the first rows that follow a new sentence come
with the next act's answer.

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

---

## Changing your mind

```python
policy_user.interrupt()
```

Thread-safe: call it from any thread — the brain's, a watchdog's — at any moment.

**It cuts through any wait.** An `act()` waiting for a starting worker, for a place in line or for a slow answer, the
first connect of an `observe()`, `wait_until_ready()`: the call raises `sequence_ai.Interrupted` rather than sit out
the rest. Stop latency is a property of your loop and never of the service.

`Interrupted` is a `BaseException`, so an `except Exception` around your own hardware errors cannot swallow it:

```python
try:
    chunk_user = policy_user.act()
except sequence_ai.Interrupted:
    robot_user.hold()            # the arm is your loop's to stop
```

An interrupt that comes while nothing waits ends nothing: each act clears it as it starts, so it does not cancel the
next one.

---

## What it does for you

Three 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 |
| Classifies errors | No way to tell "wait 118 s" from "stop and page someone" |
| One driver per Policy | Two threads acting through one handle interleave its episodes |

None of the three 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.

On Linux, a connection that sent nothing for longer than its retransmission timeout restarts its congestion window
(`net.ipv4.tcp_slow_start_after_idle`, on by default), so an upload of camera frames after a pause can take one more
round trip. The SDK has its connection to the worker use BBR where the robot allows it: BBR does not restart, and
`sudo modprobe tcp_bbr` makes it available - the SDK then takes it on its own. Or turn the restart off on the robot:
`sudo sysctl -w net.ipv4.tcp_slow_start_after_idle=0` (in a container, which has its own copy of it:
`docker run --sysctl net.ipv4.tcp_slow_start_after_idle=0 ...`).

## 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_user") as policy_user:
    policy_user.observe(observation_user)  # goes to the policy's worker as it is made
    out_user = policy_user.act()           # what the act function returned, as it returned it
    print(out_user["actions"].shape)
    # (15, 8): 15 steps of seven joint velocities and a gripper position, at the 15 Hz the policy declares
```

**The rows are your policy's own output, untouched.** Nothing converts them — not the platform, not
this client — so what they mean is the policy's own: 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. Turning a row into the command your controller
runs is your robot's own code ([below](#what-to-do-with-the-rows-that-come-back)).
How many rows of an answer are valid and at which step the next answer is asked for are the policy's to declare,
`@seq.policy(action_chunk_valid_len=15, action_chunk_replan_at=8)`, and how an eval plays each answer. Playing rows
past the valid ones fails the same quiet way; how many your robot plays is your loop's.

## The gap between chunks is the hard part

A loop that waits for its chunk to run out before asking for the next one 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 ask for the next chunk *while the current one is still playing*: `act()` on a background thread, a few rows before
the end ([The loop is yours](#the-loop-is-yours)).

**The trade, stated plainly:** a chunk asked for early 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. Asking only once a chunk has run out is strictly
closed-loop, stall included — right for bench work, wrong for a moving arm.

## Many robots at once

**One `Policy` per robot.** A `Policy` is one robot's session — its episodes, their numbering, its seat on its
worker. An `act()` from a second thread while another thread's act is under way raises, because both quieter options
are worse: interleaving would mix two robots' episodes, each thread believing it alone drives its robot, and a blocking
lock would have the second thread act at half the rate it thinks it runs at. `observe()` is not driving: a robot
observes on one thread while it acts on another.

```python
def drive_user(robot_user, model_user):
    with sequence_ai.connect(model=model_user) as policy_user:  # one each
        while not robot_user.done():
            policy_user.observe(robot_user.read())
            for row_user in policy_user.act()["actions"][:8]:
                robot_user.apply(row_user)

with ThreadPoolExecutor() as pool_user:
    list(pool_user.map(drive_user, [arm_l_user, arm_r_user], ["my_pi05_user"] * 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 seats (one robot a model copy, unless
its infer batches, `@seq.concurrent(sizes=[1, 2, 4, 8])`) 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 — its `act()` waits it out.

## A policy can plan

Give a policy's class an `@seq.plan` method and it runs in the background for each robot connected to the policy: what it
returns is that robot's next step, and the act function reads the newest from its episode. It runs on the policy's own
machine, out of the act's way, and each robot has its own, which sees and writes that robot alone:

```python
@seq.policy(gpu="L4", max_gpus=1, hz=15, models=["qwen3-8-27b"])
class Pick_user(seq.Policy):
    @seq.plan(every_s=2.0, wake_on=["say_user"])
    def plan_user(self, robot):                       # def or async def
        seen_user = robot.state["seq.observations"][-1]
        return ask_vlm_user(seen_user)                # or seq.ask_human("the bin is full")

    @seq.infer
    def act_user(self, obs, episode):
        plans_user = episode.state.get("seq.plans")   # none until its first plan returns
        step_user = plans_user[-1]["value"] if plans_user else obs["task_user"]
        return self.model_user(obs["image_user"], step_user)
```

`robot.state` is its episode, read only, the newest observation last in `"seq.observations"`. Any value but `None` is
added to `episode.state["seq.plans"]`; `None` changes nothing. `every_s` is how often, start to start, and `wake_on=` names
top-level keys of an observation whose arrival plans at once. A plan runs only when its robot has sent a new observation,
and one that raises is logged while the act goes on. A class has one `@seq.plan` at most, and `models=` declares a model it
calls.

A robot connects as to any policy, `sequence_ai.connect("pick_user")`. An agent of your own tells it what to do through
that connection, under a key of your own - `policy_user.observe({"image_user": frame_user, "say_user": "the red cups
first"})` - and the plan reads it; a key that starts `seq.` cannot be observed. See
[External agents](https://generalsequences.com/docs/agents/).

## Cold starts

A worker that is not running starts when a robot connects: the platform starts a machine with your
image, mounts your weights, builds your class — its `__init__` loads the model — and, when your policy has a
sample episode (`@seq.sample`), plays its first act once, so a robot's first act is not the model's first run.
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 while a robot is connected, and for its idle timeout after

A worker stays up while a robot is connected to it, whether or not that robot acts, with no limit, billed as it runs:
a robot can stay connected to several policies and act on one at a time, each answering the moment it is asked, each
billed as its own. Once its last robot disconnects the worker stays for its idle timeout, 60 seconds unless you set the
deployment's `idle_timeout` (2 seconds to 20 minutes), then ends; the next connect pays a cold start on a new machine,
every time.

```
connect ──► cold start ──► warm while connected ──► disconnect ── 60 s idle timeout ──► ended
                                                        ▲                  │
                                                        └── a robot connecting keeps it
```

**Why a minute.** A held GPU bills whether or not you call it, so close a connection you no longer need: a minute after
that covers a robot coming back without paying for the gaps between
runs. `seq policy set <name> idle_timeout=600` keeps a worker 10 minutes after its last robot disconnects instead
(2 seconds to 20 minutes), billed all the while; `min_workers=1` on `@seq.policy` keeps a worker
running with no robot connected (no cold start, billed while it runs), and `seq policy set <name>
schedule.hours="Mon-Fri 08:00-18:00" schedule.min_workers=1` 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()` starts a cold worker (its connect does), but it does not hold a running one —
only acting does.

### Connect first, then drive

```python
policy_user.wait_until_ready()                 # once, before the robot needs to move
while running_user:
    policy_user.observe(robot_user.read_observation())
    out_user = policy_user.act()               # warm by construction
    for row_user in out_user["actions"][:8]:
        robot_user.step(row_user)
```

`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. The poll is authenticated but
**not billed**: it touches no GPU, and it starts nothing — a poll only looks. While the robot holds nothing
(no seat, no worker starting for it, no place in line) the answer says `cold`, and `wait_until_ready()` then
connects: that seats the robot on a running worker with room, or starts a worker for it in the region nearest
the robot — by where its connect comes from, so behind a VPN, where the VPN exits — or puts it in line, where its
connect asks for a seat again at the line's pace. A worker started for it is billed from the moment its machine
has its GPU, its start included.

`ready()` is the non-blocking form, returning `(ready, eta_seconds)` - a `Ready` that, once a worker serves, also
says the card it runs on: `card` and `usd_per_hour` (one worker's price an hour on it, as billed).

**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, which
is what you see with `startup_timeout_s=0`:

```python
try:                                         # connect(..., startup_timeout_s=0)
    policy_user.observe(observation_user)    # its first observe() raises too: it connects
    chunk_user = policy_user.act()
except sequence_ai.Unavailable as exc_user:
    if exc_user.warming:
        print(f"loading; ready in ~{exc_user.retry_after_s}s")  # 174
```

By default `act()` waits that out for you, on every call, within `startup_timeout_s` — unset, as long as the platform
gives the start, and 1800 s where it names no deadline. It says so on stderr while it does, so a wait is never
mistakable for a hang (`on_warming=` replaces the line):

```
[sequences] starting the model, which can take minutes (waited 40s so far)
```

```python
sequence_ai.connect(model="my_pi05_user", startup_timeout_s=0)  # opt out: raise at once on a cold worker
```

`wait_until_ready()` waits within `startup_timeout_s` too, unless you pass it its own `timeout_s`; with
`startup_timeout_s=0` it still waits, as long as the platform gives the start.

Before the first action nothing is moving, so waiting is free. Once the arm is in motion, a wait of minutes is your
loop's to handle: an arm whose rows run out while `act()` waits should hold, and `interrupt()` ends the wait.

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, and `act()` asks again at the pace your key's rate limit allows, printing
`[sequences] queued for a seat: position 2, about 30s — …` (`on_queue=` replaces the line). It gives up after two
minutes in line 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.

A slot follows the policy that holds it. While the policy is open, the SDK keeps the slot alive in the background,
between acts too, so a robot that stops for a minute or two to reset its scene keeps it. It is given back the moment
the policy closes (`close()`, or the end of a `with`), is garbage-collected, or its process exits or is stopped
(`SIGTERM`, `SIGHUP`); a process that is killed or loses its network gives it back within 60 s. So is what
`wait_until_ready()`'s connect gives a robot before its first act — its slot, the worker started for it, its place
in line — so a robot that gives up waiting frees them for the robots behind it at once, or within 60 s when it is
killed.
A robot cut off from the platform for up to about 40 s keeps its slot (up to 60 s when the cut comes just after a
keepalive, sent every 20 s); one cut off for longer is told on stderr that its slot went, and its next act takes one again or waits in
line. Ending an episode does not give it back — the same policy acts again — so close the policy when the robot is
done. Keeping a slot keeps its worker, whether or not the robot acts, with no limit, billed as it runs, so a policy
left open costs for as long as it stays open. A robot whose slot went
takes one again at its next act, or waits in line; when a worker has to start for it, `act()` waits for it, within
`startup_timeout_s`.

## 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 compressed frames, not raw arrays.** An array goes as its raw bytes - a 224x224 RGB frame is 150 kB, the same
frame as JPEG 11.6 kB (below) - and it is the most common way to land on the slow side of that table.
`sequence_ai.Video(frame)` sends a camera's frames as low-latency video (`pip install "sequence-ai[observe]"`).

**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.

### Fit each frame on the robot, the way the policy was trained

Fit each frame on the robot, before sending, with the tools the SDK gives you: a worker never resizes one, and nothing
in the SDK resizes a frame behind your back (`encode()` sends a frame at its own size).

```python
img_user = sequence_ai.resize_image(frame_user, 224, 224, mode="pad", filter="bilinear")
data_user = sequence_ai.encode(img_user)
```

`mode="pad"` keeps the aspect ratio and pads the rest black (openpi's `resize_with_pad`);
`"stretch"` fills the box. The filter is one of `nearest`, `box`, `bilinear`, `hamming`, `bicubic`,
`lanczos`. Neither has a default: they are how the policy was trained, and a frame fitted another way
differs pixel by pixel. Nothing checks a frame's size on the way: a frame of another size reaches the policy as it is.

Fitted on the robot, a pi0.5 camera's 224x224 frame was 11.6 kB on the wire; the same frame sent at
640 px and resized on the worker, as `encode()` did until 0.34.12, was 44.9 kB.

**JPEG, never PNG.** 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

Its own inputs: whatever you hand `policy.observe(x)`, sent as it is — a dict of frames, arrays, numbers and strings,
in the shape the policy reads. Nothing checks them against a declaration: a policy that cannot read an
observation answers with its own error.

## What to do with the rows that come back

They are what your policy's act function returned, as it returned it: an array as a numpy array of its dtype and
shape, bytes as bytes. What they mean is the policy's own — the pi05-droid template answers `{"actions": ...}`, 15
rows of seven joint velocities and a gripper position — and turning a row into a command for your controller is the
robot's half, your own code: nothing in the platform or this client clips or converts it. A gripper the policy answers
as a fraction is the template's own `client.py` to turn into open or closed. Which rows are valid and when to ask for
the next answer are the policy's `action_chunk_valid_len` and `action_chunk_replan_at`, as an eval plays each answer;
how many your robot plays is your loop's.

`act()` hands back the answer alone. What the platform noted of it is said with Python's `warnings.warn`, once for each
kind, and every note goes to the debug log of the `sequence_ai` logger. Latency is read on the console.

## The loop is yours

The SDK does not play actions. `observe()` hands the policy what the robot sees, and `act()` returns what the
policy's act function returned; when to ask for the next answer and how to play it is your own code — the code that
calls `act()` already.

```python
policy_user.observe(x_user)   # every input, as the robot makes it
out_user = policy_user.act()  # carries no data: what the act function returned, as it returned it
```

`act()` carries no data: every input goes through `observe()`, and `act(x)` is refused before it goes.

Three things make a loop play smoothly, and all three are your code:

1. **Ask for the next chunk at the policy's replan step.** Count `action_chunk_replan_at` steps from the moment the
   act went out, then call `act()` on a background thread: the rows left up to `action_chunk_valid_len` play while the
   next one comes ([the gap](#the-gap-between-chunks-is-the-hard-part)), and they are the time the platform has to put
   several robots' acts into one batch. A policy that asks at its last valid step has its arm wait for every answer.
2. **Send each step to the motors at its time.** Sleep until the i-th tick — `t0 + (i + 1) / hz` — not a fixed
   `1 / hz`: a fixed sleep adds each step's own time to every tick, and the arm falls behind its plan.
3. **Join the old chunk and the new one.** The new chunk was computed from an observation taken while the old one
   played. Skip the rows of it that are already stale; or blend the seam — only for absolute positions: blending
   deltas rescales the increments and moves the arm somewhere the model never asked for; or hand the rows not yet
   played to `observe()`, so the act function sees them (a policy of its own inputs).

```python
import concurrent.futures, time

HZ_user, VALID_user, REPLAN_user = 15, 15, 8  # the policy's hz, action_chunk_valid_len and action_chunk_replan_at

pool_user = concurrent.futures.ThreadPoolExecutor(max_workers=1)
policy_user.observe(read_obs_user())
sent_user, ahead_user = time.monotonic(), pool_user.submit(policy_user.act)
while not done_user():
    rows_user, t0_user, ahead_user = ahead_user.result()[:VALID_user], sent_user, None  # step 0 of these rows: the moment their act went out
    for i_user in range(int((time.monotonic() - t0_user) * HZ_user), VALID_user):  # the steps that passed while it came are stale
        if ahead_user is None and i_user >= REPLAN_user:  # REPLAN_user steps since its act went out: ask for the next one now
            sent_user, ahead_user = time.monotonic(), pool_user.submit(policy_user.act)
        arm_user.move_to(rows_user[i_user])
        policy_user.observe(read_obs_user())
        time.sleep(max(0, t0_user + (i_user + 1) / HZ_user - time.monotonic()))  # sleep until step i's tick
        if ahead_user is not None and ahead_user.done():  # the next one is in: switch to it - how is your loop's
            break
    if ahead_user is None:                         # every row went stale on the way: ask again at once
        sent_user, ahead_user = time.monotonic(), pool_user.submit(policy_user.act)
```

Here the act function returns its rows. Each chunk's steps count from the moment its act went out, so the next ask
comes `REPLAN_user` steps after the last one whatever the answer's latency, and the rows whose time passed while it came
are skipped; playing the old rows to `VALID_user` first, or blending the seam, is as much your loop's. `observe()` is not driving, so the loop observes while `act()` runs on the
pool's thread, and `ahead_user.result()` raises what that act raised — `sequence_ai.Interrupted` too.

**Episodes.** `observe()` and `act()` with no `episode_id` go under the handle's current episode: the first call opens
it, `policy.end_episode()` ends it — a policy that keeps an episode's state frees it at once — and the next call
opens a new one; `close()` ends one left open.

### Inputs and answers are the policy's own

A policy takes whatever you hand `observe()`, sent as it is, and `act()` returns what its act function returned, as
it returned it: an array as a numpy array of its dtype and shape, bytes as bytes, a torch tensor as a numpy array.
Nothing reads steps out of it: which rows to play, and at what rate, is your loop's.

```python
# the policy's act function returns an (8, 7) array
policy_user.observe({"depth": camera_user.read()})
actions_user = policy_user.act()    # a numpy array of shape (8, 7)
for row_user in actions_user:
    arm_user.move(row_user)
```

`observe()` returns at once — it waits only on its first connect, which waits out a worker that is starting as an
`act()` does — and each observation goes to the worker on a connection of its own. The control rate is the policy's,
`@seq.policy(hz=...)`, never the robot's.

## 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_user = sequence_ai.chat("claude-opus-5-5", [{"role": "user", "content": "what should the arm do?"}])
r_user.content       # the reply — no choices[0] wrapper
r_user.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_dev_...           # 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.

An adapter is two conversions on a plain class - the env's observation to the policy's input, the policy's answer to
the env's actions, one a step - and says in its code which benchmark → policy pairs it converts between; an eval runs
it on those alone:

```python
@seq.adapter(name="toy-adapter", pairs=[("template:toy-benchmark", "template:toy-policy")])
class ToyAdapter_user:
    @seq.observation
    def to_policy_user(self, obs):       # the env's observation → what the policy's observe is sent
        return {"state": obs["arm_x"], "target": obs["target"]}

    @seq.action
    def to_benchmark_user(self, answer):  # the policy's answer → the actions the env plays, one a step
        return [[float(answer["delta"][0])]]
```

Each side is a name in your account (`my_libero_user`), `template:<name>` for anything made from that template, or `*`
for any one; `pairs="any"` opens it to every pair. `seq deploy` refuses an adapter that declares none, and an eval
whose benchmark and policy it does not declare is refused before anything runs, the error listing the pairs it does
declare. Changing its pairs is a new version, like any change to its file.

```bash
seq deploy toy-policy                                     # a @seq.policy deploys, as toy-policy
seq deploy toy-benchmark --name toy-benchmark             # a @seq.benchmark registers (its folder holds a CPU one too)
seq deploy toy-adapter                                    # a @seq.adapter registers, as toy-adapter@v1 (nothing builds)
seq eval run toy-policy --benchmark toy-benchmark --adapter toy-adapter --dry-run
seq eval run toy-policy --benchmark toy-benchmark --adapter toy-adapter
```

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

```python
import seq

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

report_user = seq.eval("toy-policy", "toy-benchmark", adapter="toy-adapter")
report_user.metrics["success"].overall   # the share of episodes whose last step said success (@seq.step's third value)
report_user.metrics["success"].per_task  # each task's value with its episode count
report_user.cost_usd       # actual dollars, reconciled from real GPU-seconds
report_user.dashboard_url  # the eval in the console
```

`--adapter ./toy-adapter/adapter.py:ToyAdapter` registers a local file first. `seq adapter ls` lists your
adapters, each with its latest version, its pairs and the eval that last ran it, `seq adapter source 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` reads it here, its two methods and its pairs,
without sending anything.

Every version of a benchmark that passed its check is kept, as a policy's is: `seq benchmark versions toy-benchmark`
lists them, `--benchmark toy-benchmark@v2` runs an eval on a kept one, and `seq benchmark rm toy-benchmark@v2` deletes
one for good. `seq deploy --override` deletes the version evals run once the new one has passed its check; if the new
one fails, nothing is deleted.

`seq eval status <id>` says an eval's status (pending, queued for GPUs, running, completed, failed or canceled) and `seq
eval cancel <id>` stops it; `seq eval report <id> [--wait] [--csv [-o PATH]] [--records DIR] [--replays DIR]` reads it
later - with, under the policy's load, what the eval's own measurements say is worth declaring (a `hint:` line), as the
console says it in its Load's tip; `--records DIR` saves each episode's record, what its policy was sent and answered.
`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 — the lists of policies, benchmarks, adapters, evals (an adapter's come whole, its newest
200) and volumes page this way, the same pages as the console).

**You lay the eval out; the platform never picks the numbers.** The policy runs on its own machines
(`--policy-machines`, on the deployment's own card) and the benchmark on its own GPU
machines (`--benchmark-machines`, each on the benchmark's card — an L4, RTX-PRO-6000, T4 or A100, or a CPU machine for a
`gpu="cpu"` benchmark — each building the worlds the eval needs); unset, each is one machine. `--spend-cap USD` is the
eval's spend cap, and
`--param eval.max_seconds=N` an episode's time limit on the robot's clock. 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 fails with stop reason `queue_timeout`;
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 available, how many short and why) and the estimate.
`--sweep` adds arms to the same eval, run side by side on the same episodes. If the policy cannot
read what the benchmark sends, it raises on its first act and the eval stops there (stop reason `refused`, in the
policy's own words, billed only for what ran) rather than handing you a silently-wrong score.

**The full reference — a benchmark's tasks, seeds and time limit, its constructor (`init=`) and its `@seq.reset` /
`@seq.step` (a vectorized one's `@seq.concurrent(sizes=[N])`), `assets=` and the 3D replay, an adapter's two functions, billing,
composite (VLM) evals, and every `seq.eval` parameter — is in `eval.md`, installed with the package as
`seq/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 and an e-stop belong between this library and your motors.

**When the arm runs out of answer.** The SDK knows when the answer the robot plays runs out: its act's send time
plus its `action_chunk_valid_len` steps at `hz`. Once a robot asks ahead - an act sent while the answer it plays
still has valid steps - and that answer runs out with no next one in hand, `on_stale` is called that moment, from a
thread of the SDK's, and `on_resume` as the next answer comes, before `act()` returns it:

```python
def hold_user(stale_user):         # the arm has nothing valid to play: hold it
    arm_user.hold()

def resume_user(stale_user):       # an answer again, stale_user.stale_s seconds later
    arm_user.release()

with sequence_ai.connect("my_pi05_user", on_stale=hold_user, on_resume=resume_user) as policy_user:
    ...
```

The `sequence_ai.Stale` each is called with says why in its `reason`: `"late"`, the act for the next answer is still on
its way (its `request_id`, for `seq policy logs --rid`); `"failed"`, that act raised and your loop has its error;
`"not asked"`, your loop asked for none in time - a loop that hangs mid-run is caught as surely as a late answer. A
robot that asks only once its answer ran out waits for every answer and is not watched, nor is an episode
`end_episode()` ended, nor a teleop robot while a person drives it. Each stretch is logged on the `"sequence_ai"` logger
and kept on `policy.stale` (`.now`, `.events`, `.stale_s`, `.last`); with no `on_stale` set, the first is said once a
process.

The platform hears your robot too: from its first `observe()` or `act()` on, its SDK sends its worker a heartbeat every
0.4 s saying how the arm stands, and `seq robot status` says each connection's - acting (asking ahead), waiting, stale
(and why, for how long) or silent once it was not heard for 2 s: its process gone, hung, or its link down. A stretch
that lasts past a heartbeat is two lines of `seq policy logs` too, as it began and as it ended, with the late act's
request id.

It is no emergency stop: it runs in your robot's process and needs that process alive. Keep a physical e-stop between
this library and your motors.

The SDK runs no loop, so nothing in it bounds one: give the loop that moves your robot a bound of its own — a time, a
step count — and a way to hold the arm when it stops early.

## 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()` / `act()`, `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 — a robot key calling a model its policy does not declare, a key bound to another deployment | 3 |
| `OutOfCredit` | `insufficient_credit`, `budget_exhausted` | stop and hold: this month's monthly limit is reached (it resets on the 1st), or usage past the monthly credit needs a payment method (add one on the console's Billing page); 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; deleting a secret a deployment still uses is `details["reason"] == "secret_in_use"` with the deployments in `details["deployments"]` (`seq secret rm --force` deletes anyway) | 5 |
| `NewVersion` | `conflict` | a `Conflict`: a new version went live, or a rollback made another serve, which ended this connection, whatever it was doing: every call on it raises this. `connect()` again reaches the version that serves, `.version` | 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 — the SDK waits both out on every `act()`, within `startup_timeout_s`; with no GPU available, `details["reason"]` says why (`gpu_busy`, `gpu_limit_reached`, `gpu_low_stock`, `deployment_full`, `in_line`, `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 |

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>              # its state, capacity, last start and a worker's /ping, 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,
its capacity, its last start, and a running worker's `/ping`. It never starts a worker: a deployment scaled to zero
says so. Whether one request was slow on the network or in inference, `seq policy logs --rid` says: the client's
timing beside the worker's own.

`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. Acts and prints are kept 7 days, refused requests 14, the
rest 30. Times print on this machine's
clock with the zone's name (`14:05:09 EDT`, or a `── Mon 2026-10-12 · EDT ──` line above a day's lines); usage and
bills - `seq usage`, a policy's monthly budget, your plan's credit - go by the billing period, the UTC month, and say
UTC. `--json` gives the platform's own timestamps. 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 what this month has spent under your plan's monthly limit, usage, limits and GPUs.

Full walkthrough, the segment catalog and the redaction guarantee are in `observability.md`, installed with the package
as `seq/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 is `httpx` and Pillow only, without `seq`.

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(
    hz=15,                                     # the steps a second its answers play at: every policy names it
    gpu="L4",                                  # the one card `seq gpus` lists ("H100:2" is two of them), or "cpu";
                                               # a start waits for it and never switches to another
    max_gpus=1,                                # the most cards it holds at once: required on a GPU
    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_user"),  # your checkpoint: an uploaded volume, hf://, or a template's (below)
    init={"weights_dir": "{weights}"},             # what the worker builds the class with: "{weights}" is where it mounts
)
class Pi05_user(seq.Policy):
    def __init__(self, weights_dir):           # cold start: pull the checkpoint into VRAM
        self.model_user = ...

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

`init=` is what a worker builds your class with as it starts: its constructor's arguments, by name (a dict) or by
position (a list). In any string `"{weights}"` is filled with the directory your weights are mounted at
(`"{weights.<part>}"` with a named part's), and `seq.config("deploy.yml", key=value)` reads a config file beside the
class's file where it runs, the keys you give laid over it. Without `init=` the class is built with no arguments. A
class your repository already has deploys as it is: its constructor is handed what it takes, none of it rewritten.

`seq deploy [folder]` (this one, `.`, by default) uploads the folder, whole — its helpers, configs and `third_party/`
with it — and that snapshot is the version's code: nothing of an earlier version is kept. The class it deploys is found
by reading the folder's files: its one `@seq.policy`, `@seq.benchmark` or `@seq.adapter` class (an
adapter beside a policy or a benchmark is not the one); of several, `--name` picks one by its name. Its file is then imported here, on
this machine, as it is on the platform — so what the file imports at its top (`torch` and the like) must be installed
where you run `seq deploy`, `--dry-run` too: by its path in the folder (`policy/OpenWAM/model.py` →
`policy.OpenWAM.model`), the folder first on `sys.path` and the class's own folder second, as running its file would,
and a module the folder's code imports from outside the folder is refused, with the ways to bring it in. Left out, and
named in the output: version
control and caches anywhere; a virtualenv or a build's output at the folder's root (`.venv/`, `venv/`, `env/`,
`build/`, `dist/`, `node_modules/`, `*.egg-info`); the `build-context/` at the folder's root, where `seq policy source`
and `seq benchmark source` put a version's Dockerfile context, so a downloaded version deploys again as it is; a `.env`
file — secrets go through `seq secret`, and a `!.env` line uploads it; and what a `.seqignore` lists — one in any
folder, for that folder down, in gitignore's form: a path from its folder (`third_party/openpi/docs`), a name at any
depth (`data`), `*` and `**`, a trailing `/` for directories only, and `!` to take back what a line before it left
out. The deploy prints how many files, how large, and their tree
sha256 — the hash the platform keeps beside the version (`seq deploy` checks the two agree). A path the image names with
`add_local_file` / `add_local_dir` inside the folder is the folder's copy, uploaded once. Weights never travel with
the code, only through `weights=`.

A template's `third_party/` is the source its image installs: change it and deploy again, and your deployment runs your
version. An upstream too large to upload with a deploy (LIBERO, robosuite) is listed in `third_party/SOURCES.json`:
`seq init` downloads its official package there and checks it (`seq init --no-source` leaves it out), the image installs
that same package by its sha256, and `seq deploy` refuses a copy you changed — a large upstream runs as published for
now.

### 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)` | arbitrary build commands, run in order |
| `.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 (one in the deploy's folder is the folder's copy) |
| `.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; found here and never run here, so what it imports only needs to be in the image |

`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's own credential — for a private git repo or a private package index, `.run_commands(...,
secrets=[...])` — is not available yet for a policy: a policy's build that declares one fails.

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.

With no `image=`, a policy's image is made from its project's own dependency files. Build-time `env` carries ordinary
variables only: a `seq.Secret` there is refused, and a
key typed in as a string would be baked into the image - credentials go in `@seq.policy(secrets=[...])`, handed to the
policy as it starts (see below).

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

- **`gpu="L4"`** is the one card the deployment runs on. A start waits for it to become available and never switches to
  another card, which can take more than 10 minutes: one place after another is tried, the robot keeps waiting, and
  nothing is billed until a machine has its card. A robot every place turns away at once waits in line for the card, and
  past 10 minutes in line it is told which card and how long it waited. A list runs on its first card.
- Workers start in the region nearest each robot
  ([below](#state-across-steps-and-more-than-one-robot-at-a-time)).
- **Time limits are the platform's**: a worker that has not loaded within 30 minutes fails (the build check is held to
  the same), and one inference longer than 300 s is answered `504`; one still running that long again ends its copy,
  and a fresh copy starts on the same worker (`seq policy logs` says `reason=hung`). A version whose check measured a
  slower load or slower acts is given longer - three times its load, up to 5 hours, and ten times its slowest p95, up
  to an hour.
- **`seq deploy`** shows each phase of the build as it begins — the weights, the machine, the image, 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 policy logs <name>@v3 --build`: version 3's).
- **`seq secret set <name>`** reaches deployments that declare the secret without a re-deploy: their running workers
  take the new value in `os.environ` (read it there when you use it, not once at load), and the next ones start with
  it; a `SEQUENCES_API_KEY` secret reaches the next ones alone.
- **`models=["qwen3-8-27b"]`** names the platform models the policy calls while it runs (a plan's VLM, a decide
  model). The robot key that drives the deployment may call exactly these on `/v1/chat` and `/v1/decide` — any other
  model is refused `403`, and your key is never needed on the robot or the worker. `seq deploy` refuses a name the
  catalogue does not have (`seq models` lists them). These calls, and the catalogue they look a name up
  in, are the network a deployed policy can count on: any other address — a model service of your own, a
  download at run time — may not be reachable from its worker, so put what the policy needs into its image or weights.

### Before it serves: its sample episode

A policy is proven before its first robot: the build plays the episode its `@seq.sample` method returns - its
observe and act calls, in order - on a worker. 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; one that answers it says each act's time, the first and
the rest, and the GPU memory it held at most. A policy that marks no sample is acted on by no one before it serves:
only its load is checked, and the platform says how to mark one.

### 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`, `str` or `list[float]`. `seq deploy --dry-run` lists them. Their defaults are the
policy's settings, the same for every robot: `seq policy set <name> params.replan_steps=3` sets one at once, with no
redeploy; `params.replan_steps=` puts it back to the code's default. A robot names none of its own - what differs between robots goes in their observations, `policy.observe(x)`.
An eval sets one 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 constructor keywords the same way, plus its own
`eval.max_seconds` — see "Parameters in an eval" in `seq/eval.md`, installed with the package.

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

- **What a policy keeps of an episode is read from how it is written.** An `@seq.infer` that takes
  `episode: seq.Episode` is handed it: `episode.state` is a dict of its own, empty on the episode's first act and the
  same object on every act after, saved for the robot's standby copy and with capture - so keep it small;
  `episode.local`, a dict of its own beside it, is never saved - a frame history, a GPU cache - and starts empty on a
  standby that takes the episode on; the platform drops both at the episode's end — so keep nothing of an episode on
  `self`: a copy serves one robot, but an eval plays several episodes in turn on one copy. One `@seq.on_episode`
  method, `def on_episode(self, episode)`, runs as each episode begins (`episode.phase == "start"`) and as it ends
  (`"end"`); a policy has no `@seq.reset`. A class that keeps a robot's state on itself — a KV cache, a frame window —
  starts it afresh there as the episode begins, and each episode then holds a copy of its own; beside
  `@seq.concurrent` its `@seq.on_episode` is handed its own episode's, a batch's episodes sharing a copy. Without `@seq.concurrent`, a class with an
  `@seq.observe` method keeps an episode's state in `episode.state` when that method and its `@seq.infer` one both
  take `episode`, and on itself, a copy an episode, when either takes none. A robot ends its episode with
  `policy.end_episode()`, and `close()` ends one left open; an episode with no act for 5 minutes (a robot that
  crashed) is ended by the worker once its place is wanted for a new episode - or after three times its robot's usual
  time between acts, where that is longer, up to a day. A policy gets every
  observation as the robot makes it: `policy.observe(obs, episode_id=...)` hands it over at once - posted to the worker on a connection of its own; a
  first call, the one that connects, waits out a worker that is starting as a first `act()` does, and one made while
  its worker restarts mid-episode is kept for the next act -
  and the episode's next `act()` carries no data. A class with an `@seq.observe` method - or one of its own I/O that
  takes one act at a time and plans nothing - is called as L0: everything it reads goes through
  `policy.observe(x)`, and `policy.act()` carries no data - its act method is called once every observation handed over
  before the act went in, in order (to its `@seq.observe` method, or kept as the latest for an act method that reads it), the act
  taking no step, so the next `observe()` goes on from it; `act(x)` is refused before it goes. With no `episode_id`,
  `observe()` and `act()` go under this handle's current episode - the first call opens it, `end_episode()` ends it -
  and what the act method returns comes back as it is, its arrays numpy arrays of their dtype and shape. A policy that
  reads more than the newest keeps them in its `@seq.observe` method (`observations=seq.Observations(...)`, a window the
  worker kept, is gone). When the copy holding an episode stops (out of memory, a crash), the robot goes on in a new episode with a
  warning - its steps counted from 0 again, none of the old one's observations handed to it; on a worker of one copy the
  act sent again finds it still starting, and `act()` waits that out.
- **A worker has seats; a robot holds one for its whole control loop.** A worker runs one copy of the model on each
  card group, each copy its own process. `@seq.policy(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. Without more, a copy
  serves one robot at a time.
  `@seq.concurrent(sizes=[1, 2, 4, 8])` on the `@seq.infer` method lists the batch sizes infer is called with - the
  sizes the model was compiled for - and infer then takes the acts that run together as one batch: the platform
  stacks their inputs, each array along a new first axis (numbers too; text a list), a first axis that differs
  padded with `<key>_mask` beside it, (B, L) and True where real, a key not every act sends a list with None where one
  sent none; each inference parameter is one value, acts that
  set others going in calls of their own, each padded to a listed size with its last act's inputs and seed (rows
  no act gets back); `seed` is a list of the batch's rows, `episode` of its acts' own episodes. Infer returns one batch,
  its arrays a row
  an act (or a list of answers in order), and each act gets its row. `@seq.concurrent(sizes=[...], collate=False)`
  hands infer the list instead - every argument a list, one entry an act - for a policy that batches its acts in
  its own code. A sample episode (`@seq.sample`) goes beside it. Before a worker says it is ready it times each
  listed size
  on the sample episode, and a copy then seats robots while two things hold. Its latency: the largest size whose batch
  answers within the time its robots can wait for an answer - the steps from `action_chunk_replan_at` to
  `action_chunk_valid_len`, at `hz`. Its load: their batches fill at most 90% of the time between a robot's asks,
  `action_chunk_replan_at` steps at `hz`. Each robot asks on its own clock, so n robots run in the fewest, smallest
  batches that clear that time: four robots whose batches of 2 clear it run two batches of 2, not one of 4. One robot
  a copy when the policy asks at its last valid step or declares no valid length. An act that comes before another
  robot's is held, within the time its robot has left, so robots whose acts come close together share a batch - no
  robot is told when to send. An earlier replan step gives an act longer to wait and asks more often: a policy its
  load seats one robot a copy seats more with a later one.
  `@seq.policy(max_wait_for_batch=1.7)` lets each robot wait up to that many seconds for an answer, from when it
  sends its act, so that its act can share a batch: a copy then seats robots by that wait in place of the steps they
  play while an answer comes - the largest size whose batch answers within it, the robot's round trip and 50 ms to
  spare included - while their batches fill at most 90% of a robot's time between asks, its replan step after its
  wait for the answer. A policy that asks at its last valid step, one robot a copy without it, seats as many as answer
  within the wait: one whose batches of 1, 2, 4 and 8 take 1.09, 1.16, 1.28 and 1.61 s (p95) on an L4 seats 1 robot a
  copy unset, 2 at 1.3 s, 4 at 1.4 s and 8 at 1.75 s for robots 47 ms from their worker. A robot whose answer is not in
  hand stops for up to that long; what it does meanwhile is its own code's (`connect(on_stale=)`). Unset, nothing
  changes.
  A call that raises - acts whose inputs do not stack among them - is run again act by
  act, so an act gets an error only when it fails on its own. An infer that takes `episode` beside it is handed
  `episode: list[seq.Episode]`, one for each observation in order, and a batch holds at most one act of an episode;
  when such a call raises, only the acts of episodes that had none before on that copy are run again alone, on an
  empty state, and the other episodes end - their state may be half-written - so their robots go on in new ones.
  A worker runs one copy of the model on each card: two pi05 copies on an L4 made each act nearly twice as long when
  both acted, and a 15 Hz robot executing 8 steps a chunk ran dry.
- **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; a GPU deployment must
  declare it, and `seq policy set <name> max_gpus=N` changes it) or your account's GPU limit. A robot that arrives when every slot is taken
  and no GPU is left waits in line *before* its control loop starts — its first `act()` waits it out and prints `queued
  for a seat: position 2, …` — for up to two minutes. 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; an idle worker ends after its idle timeout, 60 seconds unless set. `seq policy set <name>
  max_gpus=4 min_workers=1 buffer_workers=1` changes those three at once, without a redeploy (see [Settings without a
  redeploy](#settings-without-a-redeploy)), and `seq policy status <name>` shows
  each worker's copies of the model, the robots it seats and those on it, the robots in line, and why a worker died -
  and, under the figure it comes from, what the deployment's own measurements say is worth adding next (a `hint:`
  line), as the console says it in that figure's tip.
- **Where its machines run.** Each robot's worker starts in the platform's region nearest the robot, and in the next
  nearest when that one has no card available. The regions are the platform's own geographic ones — US East,
  US Northwest, US Southwest, US Central, US East Central, US Southeast, US Mountain — each a metro, never a provider's
  region.

### 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 as
many GPUs at once as its plan allows - 8 on Starter, 32 on Team, what is agreed on Custom - serving and evals together;
`seq account` shows its limit, 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, for at least 60 s each time a
machine starts. 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 a copy on each of
several cards 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 usage` shows what your machines used and cost over this billing period (the UTC month
so far), per hour or day in UTC: GPU card-hours, and dollars for the GPU, CPU and memory together. `--policy <name>`
keeps one deployment's rows and adds what its robots did, `--since` / `--until` name another window, and `--csv`
prints the rows alone.

When a request finds no GPU or no seat, the error says which of six 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, no card of the
type you declared became available in the robot's 10 minutes in line, the deployment holds its `max_gpus`, every seat on
the deployment's workers is taken, or a queued robot waited in line past two minutes.
`details["reason"]` carries the same as a code (`gpu_limit_reached`, `gpu_busy`, `gpu_low_stock`, `deployment_full`,
`in_line`, `queue_timeout`).

### One machine per worker

A worker runs on one machine, at most 8 cards. A deploy that asks for more is refused (`multi_node_unavailable`):
`@seq.policy(gpu="H100:16")` would span two machines, which the platform does not run yet.

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

`weights=` is copied once, at deploy, before its workers start — never baked into an image layer (so a multi-GB
checkpoint never bloats the build), and every cold start reads that copy. Your class is handed its directory
(`"{weights}"` in `init=`), whose place depends on the machine: read it from there, never write the path in. 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_user ./checkpoints/pi05_ft_user  # 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_user"), ...)  # or weights="vol://pi05_ft_user@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 changed files makes the next version; with none changed it makes none.
  `seq volume rm` is refused while a deployment's weights, or a registered benchmark's assets, use the volume.

**Weights from your own storage bucket or an https URL are not supported.** 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.

### 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. The moment the new version takes over, every worker of the old one ends and its robots are disconnected,
mid-episode too. A robot does not go on by itself: its next call raises `NewVersion`, and `connect()` again reaches the
new version, on the machine its build was checked on while that one runs.

```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_user     # v4 building · v3 serving · v2, v1 kept — each with its build
seq policy rollback pi05_ft_user     # serve the previous version again, nothing rebuilt: robots are disconnected, connect() again
seq policy rollback pi05_ft_user@v1  # or a specific kept version: a version is always NAME@vN
seq policy stop pi05_ft_user         # emergency stop: every worker ends now, every connect is refused
seq policy start pi05_ft_user        # let robots connect again
seq policy rm pi05_ft_user           # delete it for good: stops its workers now, frees the name and the quota slot
                                     #   (refused while robots are connected or evals run on it: --force ends them);
                                     #   it lists first what goes with it, and --dry-run lists it alone
```

Every version is kept until you delete it: `seq policy rm pi05_ft_user@v3` deletes one for good, and the policy and its
other versions stay. `seq deploy --override` deletes the version serving once the new one has passed its check and its
workers have ended; if the new one fails, nothing is deleted.

A stop ends workers, never a build: a version still building finishes and is checked as usual, its check billed like
any worker, and once it passes it is the version that serves when you `seq policy start` the deployment.

**Trying a new version first.** Deploy it as a policy of its own beside the one your robots run (`seq deploy --name
<name>-next`, or `seq policy fork <name> <name>-next` for a copy of it, weights and all), and give a test robot a key
to it (`seq robot add --policy <name>-next`): a robot reaches the policy its key is for, and the others stay where they
are.

### Settings without a redeploy

`seq policy settings <name>` lists every setting of a deployment - its value, what its code says, where the value comes
from, who changed it last and when, and how many of its running workers have taken a change. `seq policy set <name>
KEY=VALUE ...` changes them at once, all of them or none, and says when each takes effect; `KEY=` puts one back to what
the code says. From Python, `seq.policy_settings(name)` and `seq.policy_set(name, {"min_workers": 1})`. The keys:

- scaling: `max_gpus`, `min_workers`, `buffer_workers`, `idle_timeout` - how long, in seconds, a worker with no robot connected
  stays up before it ends, 2 to 1200 and 60 unless set, billed all the while - and warm hours -
  `schedule.hours="Mon-Fri 08:00-18:00"`,
  `schedule.min_workers`, `schedule.tz` - taken at once; the warm hours' workers start before the hours, by the time
  they take to come up, so they serve as the hours open, and are billed from that start;
- `params.<name>=VALUE`: an inference parameter, what every robot runs with - each running worker takes it within a
  minute, for the episodes begun after it (an act carries none);
- `plan.every_s` - each running worker takes it within a minute;
- `action_chunk_valid_len` and `action_chunk_replan_at`, how its answers play: a robot takes them with its next connect
  (one that connects then, one already on as its lease renews), each running worker seats and batches its robots by
  them within a minute, and an eval of it plays by them - its report says whether each was set or is the code's. The
  step asked at is never past the valid steps: `seq policy set` refuses one that would be;
- `max_wait_for_batch`, in seconds: how long a robot waits for an answer so that its act can share a batch - a robot
  takes it with its next connect and each running worker seats and batches its robots by it within a minute; an eval
  plays as before, its world held still for every answer;
- `capture=on|off` and `capture.retention_days` ([Field data](#field-data-capture-label-export));
- `budget.monthly_usd`, and `alerts.webhook`;
- `models`;
- the machine - `gpu`, `weights`, `cpu`, `cpu_limit`, `memory`, `memory_limit`, `replica_start`: one of these builds a
  new version from the same image and code, which takes over once its check passes.

A setting changed this way stays when you deploy again; `seq deploy` says where the file says otherwise.

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

- `seq deploy <folder> --dry-run` prices the policy's card from the platform's list once the platform has checked it:
  a worker's price by the hour while robots use it, its warm floor around the clock, the robots a worker seats and what
  more robots add. Nothing is built.
- `seq policy set <name> budget.monthly_usd=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 clear it, `budget.monthly_usd=`), then
  `seq policy start <name>`; under a budget already reached, `seq policy start` is refused.
- `seq policy set <name> schedule.hours="Mon-Fri 08:00-18:00" schedule.min_workers=2 schedule.tz=America/Los_Angeles`
  keeps two workers warm in those hours (billed whether or not a robot is connected); `schedule.hours=` removes it.
- `seq policy set <name> alerts.webhook=https://…` posts JSON to your https endpoint when a version fails to build, an
  hour's error rate reaches twice its last 7 days' and 5 points over it or its p95 twice theirs (each over at least 20
  acts), or the month's spend reaches 80% and then 100% of `budget.monthly_usd`. Each body is signed: `X-Seq-Signature: sha256=HMAC(secret, body)` with the secret printed
  once.

### Robot keys

Each robot connects with its own key: `seq robot add --policy <name>` prints a new one, this once (`seq.robot_add(policy=
name)` returns it, for a robot's provisioning to write); `seq robot list` lists every robot key, offline ones too, each
by its id and last four with its connections now, and `seq robot revoke <id or last four>` stops one.

### Field data: capture, label, export

`seq policy set <name> capture=on` makes the deployment's workers keep every episode whole: what each robot observed,
the chunks it got, the inference parameters, its camera frames as they were sent. No redeploy: running workers pick it
up within a minute. A robot marks how an episode went with
`policy.label(success=True, notes="")` after the episode's last act. `seq policy data export <name> --format lerobot -o DIR`
writes a LeRobot v3.0 dataset that `LeRobotDataset` opens as it is (a frame for each step the robot observed, with the
action its act's answer gives that step, `driver` saying whether the model or a person drove it, and 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 and removed after `capture.retention_days` (default 30). Deleting the
deployment never deletes it: it stays as a dataset of its own, `capture:<name>-<id8>` in `seq volume ls`, billed the
same way until you delete it with `seq volume rm`. `seq policy set <name> capture=off` stops it.

### Have the platform validate it

```bash
seq init pi05-droid my_pi05_user           # scaffold a working template to edit
seq deploy my_pi05_user --dry-run         # a folder of several classes:  seq deploy my_folder_user --name pi05_user --dry-run
```

`seq deploy --dry-run` imports the policy's file as a deploy does (what it imports must be installed here) and reads
its declaration — it never builds the class or runs `infer` — into the deployment request, validates it against this SDK's copy
of the platform's contract, and echoes back exactly what will be installed, where the weights mount and which files
are uploaded — with **nothing built or run on the platform**. It says, as `seq
deploy` does, what the declarations make of the policy: what it reads of each observation and how its answers play (a
policy of its own I/O), whose its episodes are, what its build check acts on, and how many robots a copy seats - and
says once what to add when a policy that could seat several robots a copy (`@seq.concurrent`, or an infer handed its
`seq.Episode`) seats one because nothing times it: a sample episode (`@seq.sample`). With
`SEQUENCES_API_KEY` set, the platform checks it too, says whether it would be a new deployment or a new version of
yours - and for a name deployed before, its last first start: how long its newest measured version took to ready, step
by step, and about what this version's check costs on its card (a name with no version measured yet says there is no
data) - and the dry run prices it; without a key it is the checks here alone, and **nothing is sent**:

```
policy      my_pi05_user
contract    valid for this SDK's deployment contract 0.5.43 (the platform checks it again when you deploy)
image       nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04@sha256:131e238d724ee145317f10d6c8eba0d301439c6c8764b02473510e7035756e81
  add               third_party/openpi/src → /opt/openpi/src
  add               third_party/openpi/packages/openpi-client → /opt/openpi/packages/openpi-client
  run               python -m pip install --no-cache-dir --no-index --find-links /seq/frozen/wheels --no-deps --require-hashes -r /seq/frozen/requirements-policy.lock
  run               python -m pip install --no-cache-dir --no-index --find-links /seq/frozen/wheels --no-deps -e /opt/openpi/packages/openpi-client -e /opt/openpi
  env               OPENPI_DATA_HOME=/data/.cache/openpi
weights     template://pi05-droid@v1
            → copied before its workers start; your class is handed the directory as weights_dir, such as /srv/ckpt/my_pi05_user: read it from there, never write the path in - where it is depends on the machine its worker runs on
code        the folder ./my_pi05_user, whole: 85 file(s), 1.0 MB (0.31 MB compressed)
            the class it deploys is in policy.py
            .seqignore, CHANGELOG.md, LICENSE, NOTICE, README.md, client.py, frozen.toml, pins-policy.txt, policy.py, requirements-policy.lock, third_party/ (74), upstream.toml
            tree sha256 d8c162d015be05ec8aafccc36b8977770442d4a7e91e3dd32d66f7d9b9e32bc0
            left out: third_party/openpi/.github/ (.seqignore: third_party/openpi/.github)
                      third_party/openpi/docs/ (.seqignore: third_party/openpi/docs)
I/O         the policy's own — the platform hands its functions what its robot sends and returns what they return, unchecked
episodes    batched (@seq.concurrent): a copy serves several robots' episodes together
check       plays its sample episode, sample(), call by call
seats       as many robots a copy as the largest of its batches of 1, 2, 4, 8 and 16 that answers within 0.467 s, the 7 steps a robot plays between asking for an answer and needing it at 15 Hz, as long as their batches fill at most 90% of the 0.533 s between a robot's asks (its 8 steps) - each timed on its sample episode by its build check
name        'my_pi05_user' (not checked: the platform could not be asked)
platform    not checked: the platform could not be asked (no key, or offline); it checks this when you deploy

--dry-run: nothing was sent; without it, `seq deploy` submits this request.
```

(Shortened here: the template's image has 5 more steps, and its `.seqignore` leaves out eleven more paths.)

A benchmark and an adapter are checked the same way: `seq deploy my_reach_user --dry-run` has the platform check the
registration — a benchmark's asset volumes and secrets (each yours, uploaded), its card, cores and memory, and your
benchmark limit; an adapter's name (one another class holds needs `--override`) and its pairs — and says whether it
would be a new one or the next version of yours, and for a benchmark its last first start, as for a policy. A refusal
ends the dry run as it would end the registration, and nothing is registered or built; without a key it is the checks
here alone.

The folder goes up whole, the robot's `client.py` and the README with it, less what its `.seqignore` files list.

Without `--dry-run`, `seq deploy my_pi05_user` hands the build to the platform (it needs
`SEQUENCES_API_KEY`). 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_dev_...       # or pass api_key= to connect()
```

Get a key at [app.generalsequences.com](https://app.generalsequences.com): it starts `seq_dev_`. A robot connects with
its policy's robot key instead - `seq_robot_…`, made on the policy's Robot keys tab - which reaches that one policy only.
A key made earlier may start `seq_prod_` or `seq_live_`, and works as any other.

## 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 policy's plan needs none: by default it
calls the platform's own models through `/v1/chat`, with a short-lived credential the platform hands its worker,
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 model_key --env MODEL_API_KEY     # a plan 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' "$MODEL_API_KEY" | seq secret set model_key --env MODEL_API_KEY
# or, interactively, you are prompted per key with the input hidden:
seq secret set model_key --env MODEL_API_KEY
#   Value for MODEL_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_user --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 model_key          # refused while a deployment uses it (it lists them); --force deletes anyway
seq secret rm model_key --force  # delete even in use — those deployments' workers then stop (fail-closed)
```

`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 reaches every worker, the running ones and the ones that start next.

**Removing a secret a live deployment still binds is refused** (it would stop those deployments' workers at
once): the error lists them. Pass `--force` to delete anyway, after which the bound workers fail closed until
the deployment drops the reference. To retire a **leaked** credential, `seq secret set` it to a new value
rather than removing it — the new value reaches every worker, and nothing stops serving.

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

```python
import seq

@seq.policy(hz=15, gpu="L4", max_gpus=1, secrets=[seq.Secret.from_name("model_key")])
class MyPolicy_user(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 dev key (`export SEQUENCES_API_KEY=seq_dev_...`) and are scoped to your account, not
to the key: every dev key of it lists the same names (never a value), another account's key none. 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 | 300 s, or ten times the slowest p95 its version's check measured, up to an hour. Past it the call is answered `504`; one still running a timeout later ends its copy, and a fresh copy starts on the same worker (`reason=hung`). |
| Loading a worker | 1800 s, or three times the load its version's check measured, up to 5 hours. A worker that has not loaded by then fails; the build check is held to the same. |
| Observations waiting for an act | An act carries no data: a robot's observations go to its worker ahead of it. A worker holds at most 1,024 observations and 64 MiB of an episode that its policy has not read yet, and 1 GiB across its episodes; past it an observation is answered `413 payload_too_large`. |
| A management request | 16 MiB; a deploy or a benchmark's registration, which carries its code and its build context, 24 MiB. |
| The code a deploy uploads | 8 MiB compressed: the folder, whole, and what `seq.Image.add_local_*` names outside it. A source tree over 64 MiB is refused before it is sent; the refusal names the largest paths and how to leave them out (`.seqignore`). Weights never travel with the code — they go in `weights=`. |
| Robots one worker serves at once | Its seats: one robot per model copy, one copy a card group of `gpus_per_replica` cards (default: all of the worker's); `@seq.concurrent(sizes=[1, 2, 4, 8])` on infer seats more, sharing a copy — as many as the largest listed size whose batch answers within the time its robots can wait for an answer (the steps from `action_chunk_replan_at` to `action_chunk_valid_len`, at `hz`) and whose batches fill at most 90% of the time between a robot's asks (`action_chunk_replan_at` steps at `hz`), 64 robots a copy at most whatever it lists. A request within a worker's seats 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 — the robot waits in line before its control loop starts, as long as the platform keeps its place. A loop that has started is never interrupted. |
| A model across machines | Not available yet: a worker runs on one machine, at most 8 cards (`gpu="H100:8"`); a deploy, upgrade, rollback or eval that asks for more cards per worker is refused. For more robots, raise `max_gpus`: more workers, each on a machine of its own. |
| GPUs at once | Your plan's: 8 on Starter, 32 on Team, 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 ends after its idle timeout with no robot acting: 60 seconds unless its deployment sets another, 2 seconds to 20 minutes. `min_workers` (default 0) keeps some warm. |
| Cards | The platform's price list names them (`GET /v1/pricing`): A100, A100-40GB, B200, H100, L4, RTX-PRO-6000 and T4 as of 2026-11. A benchmark's sims run on L4, RTX-PRO-6000, T4 or A100, or on CPU machines when it says `gpu="cpu"`. An A100 has no RT cores, so Isaac Sim does not run on it: name an L4 or an RTX-PRO-6000 for an Isaac Sim benchmark. |
| Regions | A deployment's workers start in the region nearest each robot. A benchmark's and an eval's machines run wherever a card is available. |
| Billing | Per second of a worker's life: each card for at least 60 s each time the worker starts, 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), two dependencies: `httpx`, and Pillow, which fits and encodes each camera frame on the robot
(`resize_image`, `encode`). On Python 3.10+, `seq` adds pydantic and `sequence-base`.

Extras add what some uses need: `data` (pyarrow, for the LeRobot export), `observe` (what `sequence_ai.Video`,
`Depth` and `Tactile` encode with) and `port`, what moving a CPU benchmark to the GPU runs on (`seq port export`, `check-model`, `scaffold`,
`compare`): 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 port 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. Pillow is the one the robot's own work needs: it has
wheels for every platform a robot runs (Linux aarch64 included), and an environment pinned below
Pillow 10 is upgraded by the install.
