IronStratum

VLA action route: the /v1/action endpoint contract

What the /v1/action endpoint takes and returns: camera frames, an instruction, and robot state in, an action chunk with norm stats out, one billed call.

A posture note first, because this page names eight models. None is on the public list. Each alias answers 404 by name and stays off the model list. No rate for this route renders anywhere. The models index is the live catalog, and a listed row's rate appears on the pricing page, drawn from the platform's model data. The contract itself is fixed. This page documents it as built, so your client can be written and checked against the shapes today.

What the route is

Strip the frameworks away and a robot action endpoint is one remote procedure. You send what the robot senses. It returns what the robot should do. The request carries one or more camera frames, a plain instruction string, and the robot's own state as numbers. The response carries an action chunk, the norm stats that shaped it, and a usage object for the bill.

The open source world already runs this shape three ways. Physical Intelligence serves its pi models from a policy server over a WebSocket, default port 8000 (openpi remote inference docs). LeRobot, the robotics library from Hugging Face, ships an async pair, a policy server and a robot client, over gRPC (LeRobot async inference docs). NVIDIA's Isaac-GR00T runs the policy on a GPU box and a small client sends observations and receives actions over ZeroMQ on port 5555 (Isaac-GR00T README). All three make the same split: the model lives on a heavy machine, the motors on a light one.

This route makes that split a hosted product, and it speaks plain HTTP. The body is multipart form data, the same shape a web form uses to upload files. No WebSocket library, no gRPC channel, no extra client stack. Any HTTP client in any language already speaks it. The field by field listing lives on the run-action reference page, machine-derived from the same contract this page describes. What follows are the design notes: the shapes, the model differences, the client mapping, and the polling math.

The request, field by field

FieldRequiredKindWhat it is
modelyesstringThe alias of a model on this route, for example pi05 or act.
instructionyesstringThe task in plain words, such as "put the red cube in the bowl". Required on every row, even where the model's own code ignores it.
stateyesstringThe robot's state as a JSON array of numbers, most often joint positions. The empty array [] is the legal stateless embodiment.
imageone or more partsfileOne camera frame per part. Every frame part uses this same field name.

Every alias on this route answers 404 today. The example below shows the exact shape you will send the day a lane opens. Point your plumbing at it now, and expect the 404:

curl -sS -w '\n%{http_code}\n' $IRONSTRATUM_BASE_URL/v1/action \
  -H "Authorization: Bearer $IRONSTRATUM_API_KEY" \
  -F "model=pi05" \
  -F "instruction=put the red cube in the bowl" \
  -F 'state=[0.12, -0.80, 1.25, 0.40, -0.20, 0.90, 0.50]' \
  -F "image=@cam0.jpg" \
  -F "image=@cam1.jpg"

The state field is text, not a native array, because multipart bodies have no array type. Send a JSON array of numbers and nothing else. Anything that does not parse as one is a 400 that names state. A robot with no state sensors sends the two characters [] and nothing more.

Chunk length is not a request knob. Send horizon, or chunk_size, or any other field the route does not take, and the answer is a 400 unknown_param naming the field you sent. The length belongs to the model. It is set where the model runs, not per call. That is deliberate. A control loop should not be able to ask a model for a shape it was not trained to produce.

The response, field by field

FieldKindWhat it is
actionsarray of arrays of numbersThe action chunk. Each inner array is one step, one number per actuator dimension. The step count is the model's own horizon.
normalizationobjectThe norm stats the model applied to produce those actions, passed through untouched so your controller can check them or undo them.
usageobjectOur count and our cost. calls is 1 on every served answer. cost is the wallet debit for the call at six decimals.

The shape, not the values, is the contract:

{
  "actions": [
    [0.11, -0.79, 1.24, 0.41, -0.19, 0.91, 0.55],
    [0.10, -0.78, 1.22, 0.43, -0.18, 0.92, 0.60]
  ],
  "normalization": {
    "q01": [ "one number per action dimension" ],
    "q99": [ "one number per action dimension" ]
  }
}

The usage object rides on every served answer and never carries the model's own usage block. The count is stamped by the route when the request is served, never reported by the model. One served request bills one call. A refused request bills nothing, whatever the reason.

Send an Idempotency-Key header if you like. It is accepted and logged. It does not replay. This route takes image bodies, and a half replayed image call would be corruption, not idempotence. A network retry therefore bills twice. Retry on purpose, never by reflex.

Action chunk shapes, model by model

An action chunk is a short run of future actions predicted in one pass. Your controller plays them back one row per control tick, then calls again with fresh frames. How long the run is belongs to the model, and the publishers document it. Every row below is a written record on this route. None serves today.

ModelPublisher's chunkWhat the publisher saysSource
pi0550 actionsAll of this publisher's VLAs use a chunk of 50 actions, about one second of real time.pi.website
pi05-droid50 actionsThe DROID dataset tune of the same model. Same chunk law.pi.website
pi05-libero50 actionsThe LIBERO benchmark tune of the same model. Same chunk law.pi.website
smolvla50 actionsThe action expert outputs chunks of 50 actions, with flow matching fixed at 10 steps.SmolVLA paper, 2025
openvla-oft8, 25, or 5The chunk is set per robot platform in the model's code: 8 on LIBERO, 25 on ALOHA, 5 on Bridge.OpenVLA-OFT constants
act100 actionsThe original action chunking policy, and the longest run in this set.LeRobot ACT config
molmoact2-so100-10110 actions on the LeRobot portThe port sets the horizon at 10 and overrides the checkpoint's own longer one.LeRobot MolmoAct2 docs
x-vla-0.9b32 actionsThe library default for this policy class.LeRobot X-VLA docs

What each publisher's own client sends, as a guide to your request:

ModelCamera framesState
pi05, pi05-droid, pi05-liberoOne main image and one wrist image, resized to 224 by 224 on the client.The arm's joints, sent unnormalized.
smolvlaMultiple views, resized to 512 by 512 in the paper's setup.The robot's state, joints and other readings.
openvla-oftMain image and wrist image on the repo's LIBERO example; one frame on base setups.The robot's own sensing, 7 to 14 numbers depending on the platform.
actMultiple RGB views, front and wrist.Current joint positions.
molmoact2-so100-101Exactly two, under the names cam0 and cam1.Six joint values for the SO-100 and SO-101 arm.
x-vla-0.9bUp to three views; empty slots are zero padded.The robot state, converted per the model's processor.

Two of these details save real debugging time. The pi family resizes on the client, before the bytes leave the robot, because the publisher's own client does (openpi remote inference docs). And the SO-100 and SO-101 joint frame, the signs and offsets that map that arm's motors to the training data, is already baked into the converted checkpoint. Never re-derive it by hand (LeRobot MolmoAct2 docs).

The robot license story, including why this family holds no GR00T row, is its own guide: GR00T license and commercial use.

Embodiment stats and the normalization block

An embodiment is a robot body: how many joints, where the cameras sit, what the gripper is. Models are embodiment sensitive. The same weights move a different arm only after adaptation, and part of that adaptation is numbers, not code.

Action data is stored normalized. Each joint's range gets scaled to a standard band before training, and the model writes in that band. Norm stats, short for normalization statistics, are the numbers that map between the band and your robot's real units. The publisher of the pi models keeps one named set per robot family, stored beside the checkpoint as a norm_stats.json of quantiles, and lists them in its docs: one set for Franka and UR5e arms, another for ARX and ALOHA style arms, another for the DROID setup (openpi norm stats docs).

The control rates ride the same docs, because the stats and the clock are one contract:

Robot family in the openpi statsControl rate
Franka and UR5e arms20 Hz
ARX and ALOHA style arms50 Hz
The DROID setup15 Hz, joint velocity actions

On this route, the stats cross in the response. Every answer carries the normalization block, and the block says what was applied to the actions you just received. It is passed through untouched, byte for byte, exactly as the model produced it. If your arm moves wrong, that block is the first thing to read: the most common failure is a stats mismatch between the model's embodiment and yours, and it fails silently and physically. The community's pain here is current. The LeRobot tracker carries 19 open issues and pull requests that mention norm stats, including ones where normalization was silently skipped after a data migration.

That layer gets its own reference page in this docs set, robot embodiment stats, with the per-model stats tables and the cross-embodiment story. This page carries only the contract half: the block arrives, it is the model's own artifact, and you are meant to check it.

Calling the route from robot code

The community words for the two halves are policy server and inference server for the model side, robot client for yours. If you have LeRobot or openpi code today, the mapping onto this route is short:

Your openpi clientThis route
observation/imageOne image file part
observation/wrist_imageA second image file part
observation/stateThe state field, as a JSON array
promptThe instruction field
client.infer(observation)["actions"]The actions array in the response

A control loop skeleton in Python:

import json
import os
import requests

BASE = os.environ["IRONSTRATUM_BASE_URL"]
KEY = os.environ["IRONSTRATUM_API_KEY"]

def act(model, instruction, state, frames):
    response = requests.post(
        f"{BASE}/v1/action",
        headers={"Authorization": f"Bearer {KEY}"},
        data={
            "model": model,
            "instruction": instruction,
            "state": json.dumps(state),
        },
        files=[("image", f) for f in frames],
        timeout=5,
    )
    response.raise_for_status()
    return response.json()

chunk = act(
    "pi05",
    "put the red cube in the bowl",
    [0.12, -0.80, 1.25, 0.40, -0.20, 0.90, 0.50],
    [open("cam0.jpg", "rb"), open("cam1.jpg", "rb")],
)
for step in chunk["actions"]:
    command(step)   # your robot side, one row per control tick

The route holds no state between calls. Each request stands alone. The frames, the instruction, and the state you send are the whole input. One model can serve many callers, and your client can switch models by changing one string.

LeRobot's async client names two knobs worth knowing even over HTTP: actions_per_chunk, how many actions to take at once, and chunk_size_threshold, the queue fraction at which it sends a fresh observation (LeRobot async inference docs). On this route the first knob does not exist, because the model owns the chunk length. The second is yours to implement, and the next section tells you how to size it.

The polling contract: chunks, control rate, and latency

The chunk is a buffer, not a message. Your loop calls, plays the chunk back at its control rate, and must call again before the buffer runs dry. That is the whole timing contract, and it is arithmetic, not folklore.

A chunk of n actions at a control period of dt seconds covers n times dt seconds of motion. Fifty actions at 50 Hz, 20 ms per step, covers one second. The same chunk on a 30 Hz loop covers 1.67 seconds. Your round trip, network plus model, has to fit inside what is left of the buffer when you send.

The publisher of the pi models publishes a measured example of that round trip, a 3 billion parameter model over a fixed network link (Real-Time Action Chunking, 2025):

PartMobile robotStatic robot
Model97 ms97 ms
Network21 ms6.9 ms
Image resize11 ms1.4 ms
Other9.7 ms3.0 ms
Total139 ms108 ms

Against a one second chunk, a 139 ms round trip leaves large margin. The margin is the point of the chunk: it is what lets a model slower than one control tick still drive a loop faster than its own inference.

When to send the next observation is the SmolVLA rule. The paper's controller keeps a queue and sends a fresh observation when the queue falls below a fraction g of the chunk, with g between 0 and 1 (SmolVLA paper, 2025). LeRobot's default threshold is 0.5, which says: send the new observation when half the chunk is spent. In their measured runs, async beat synchronous clearly. The same tasks finished in 9.7 seconds against 13.75, about 30 percent faster, and a fixed window held 19 completed cycles against 9, at similar success rates.

Short chunks, not slow networks, are what stall a loop. A 10 action chunk at 50 Hz covers 200 ms. One slow round trip drains the whole buffer and the arm pauses mid motion. If your arm stutters, the fix is a model with a longer chunk or a faster model, never more retry logic.

There is a third option when latency gets hard: real-time chunking, RTC. The next chunk is generated while the current one still executes, and the overlap is blended so the join does not jerk. The method comes from the pi team, and LeRobot ships it for its flow matching policies. This route is single shot by design, so RTC style blending, if you want it, is your client's job on the chunks it receives.

The wider question, a robot brain onboard or hosted, has its own numbers and its own guide in this docs set: jetson versus cloud robot inference.

What stays on your side of the wire

The route returns numbers. It does not check them. Joint limits, collision, force limits, and the e-stop are yours. Execution frameworks that consume action chunks put whole layers between the policy and the motors. MoveIt Pro, for one, checks every point of every chunk against joint limits and the planning scene before it reaches the controller, stops the run on a force spike, and fetches the next chunk before the current one runs out (MoveIt Pro VLA docs). A bare loop you write in an afternoon has none of that. Run in simulation first, and keep a physical stop within reach.

Stale frames are the quiet failure. A chunk that arrives late was computed from old images. If your queue empties and the arm pauses, the chunk that ends the pause describes a scene from before the pause. Watch the queue depth, not the retry count.

Errors, limits, and the launch posture

CodeMeaning on this route
400 invalid_paramA known field with a bad value, most often state that is not a JSON array of numbers.
400 unknown_paramA field the route does not take, horizon for example. The error names the field.
401 invalid_api_keyThe bearer token is missing or unknown.
402 credit_balance_exhaustedThe prepaid wallet is empty. A refused call bills nothing.
403 key_revokedThe key was revoked. Rotate it out of your config.
404 model_not_foundThe alias does not exist, or is not on the public list. Every model on this route answers this way today.
413 request_too_largeThe multipart body passes 25 MiB. The error names the image slot. Nothing is sent to the model.
429 rate_limitedThe lane is saturated. Retry-After comes back when the model's side sends one. The turn is not billed.
500 internal_errorOur fault. Quote the x-request-id header.
502 upstream_rejectedThe model's provider rejected the request and failover could not save it. Retrying the same body is usually right.
503 no_provider_availableEvery provider behind the model is unavailable. Back off, then retry.

Every response, success or error, carries an x-request-id header. Quote it in any support conversation. The full error envelope, the stable code list, and the SDK mapping live on the errors page of these docs.

The launch posture, plainly. The eight models named above are written into this page and not yet into the catalog. They answer 404 by name. The model list endpoint carries none of them yet, and it changes the day any lane opens. The run-action reference page and the API reference index carry the machine shape of this contract at all times. The model class itself, and the family each model belongs to, lives on the VLA page.

Frequently asked questions

What is the /v1/action endpoint?

A hosted robot action endpoint. Your code posts camera frames, a plain instruction, and the robot's state as numbers. The answer is an action chunk, a short run of motor commands for the next fraction of a second, plus the norm stats that shaped it and a usage object. It is the hosted form of what the open source stacks call a policy server.

What does the response contain?

Three fields. actions, the chunk itself, one inner array per step. normalization, the per embodiment stats the model applied, passed through untouched. usage, our call count and the wallet debit. The model's own usage block never crosses.

Can I set the chunk length?

No. The chunk length is the model's property, set where the model runs. A request that sends horizon or chunk_size gets a 400 unknown_param naming the field. The per model table above lists each publisher's documented chunk.

How is a call billed?

One served request bills one call, stamped by the route, never reported by the model. Refused requests bill nothing. A network retry bills again, because this route does not replay image bodies. No rate for this route renders on the pricing page; the robot action rows are not on the public list. The models index is the live catalog.

How do I call it from LeRobot or openpi code?

Map your observation dict onto the four form fields. The image slots become image file parts, one per camera. The state becomes the state field as a JSON array. The prompt becomes the instruction. What your openpi client got from infer(observation)["actions"], you read from the actions array in the response.

How fast can I poll?

As fast as your buffer allows. A chunk of n actions at control period dt covers n times dt seconds. Send the next request when about half the chunk is spent, and check that your round trip fits inside the rest. Fifty actions at 50 Hz covers a second, which is far more than the 139 ms round trip the pi team measured on a fixed link.

Why does pi05 answer 404?

It is not on the public list. No model on this route serves today. The 404 is the honest answer, and the models index is where that state reads.


The contract is small on purpose. Four form fields in, three fields out, one call per bill, and a normalization block that tells you what the model did to the numbers. The hard parts are exactly the parts this page hands to you: the chunk shape of each model, the embodiment stats, and the polling arithmetic that keeps the buffer from running dry. The VLA page holds the model class and the family. The run-action reference page holds the machine-derived fields. Everything else in these docs starts at the docs index.

Last verified: 2026-10-05