# Offline programming HTTP API

> Every route of the OLP server on port 8794, including the dense-execution machine-control API for Home, Arm, jog, moves, Load, Play and Stop, with request bodies, units, target fencing and error codes.

URL: https://advancedmetalresearch.com/docs/apis/olp-http
Section: RosieOS docs / APIs
Last updated: 2026-10-10

The offline programming (OLP) server is the backend of the OLP web app and of the Steam Deck v5 pendant. It serves CAD import, seam authoring and weld planning, a local simulator, and **machine control**: Home, Arm, joint and Cartesian jog, joint and Cartesian moves, Load, Play and Stop on a selected cell, through `rt-control`.

All routes are under `/api/offline-programming/v1` on `127.0.0.1:8794` by default (`serve --listen`). The server has no authentication. It is meant to be reached from the same host, by the UI's dev proxy or by a pendant's local process. Don't expose it on a network.

> [!WARNING] **Energised motion.** This moves the robot. RosieOS has no software E-stop: keep the hardware E-stop within reach and clear the cell before you arm. Jog, moves and Home are checked against joint limits only, not against collisions. See the [safety model](https://advancedmetalresearch.com/docs/get-started/safety-model).

> [!TIP] **Machine-readable.** This page's routes, request fields and error codes as an [OpenAPI 3.1 document](https://advancedmetalresearch.com/docs/openapi/offline-programming.json), generated from this page.

## Quick start

This selects the local simulated cell that the dev stack provides, homes it, arms it, jogs J1 for a moment, and stops.

```bash
OLP=http://127.0.0.1:8794/api/offline-programming/v1

# 1. Pick a cell. The dev stack lists "local-simulation" first.
curl -s $OLP/targets
curl -s -X POST $OLP/targets/select -d '{"cell_id":"local-simulation","model_id":"rosie_1400_v3"}'

# 2. Every mutating dense-execution call carries the selection it was made against.
STATUS=$(curl -s $OLP/dense-execution/status)
GEN=$(echo "$STATUS" | jq -r .target.selection_generation)
CELL=$(echo "$STATUS" | jq -r .target.cell_id)
FENCE=(-H "X-RT-Target-Generation: $GEN" -H "X-RT-Target-Cell: $CELL")

# 3. Home, then arm. Arm acquires the rt-control lease.
curl -s -X POST $OLP/dense-execution/home "${FENCE[@]}"
curl -s -X POST $OLP/dense-execution/arm "${FENCE[@]}" -d '{"armed":true}'

# 4. Jog J1 at 10 % for one hold. A real client repeats "update" every 50 ms.
TARGET=$(curl -s $OLP/dense-execution/capabilities | jq -r .cell)
SESSION=$(curl -s $OLP/dense-execution/status | jq -r .session.id)
REV=$(curl -s $OLP/dense-execution/jog/state | jq -r .revision)
JOG="{\"target_id\":\"$TARGET\",\"session_id\":\"$SESSION\",\"revision\":$REV,\"axis\":0,\"direction\":1,\"fraction\":0.1}"
curl -s -X POST $OLP/dense-execution/jog/begin "${FENCE[@]}" -d "$JOG"
curl -s -X POST $OLP/dense-execution/jog/end -d "$JOG"

# 5. Stop always works and needs no fence. It also releases the lease.
curl -s -X POST $OLP/dense-execution/stop
```

While OLP holds the lease, send a [heartbeat](https://advancedmetalresearch.com/docs/apis/olp-http#heartbeat) at least every 5 s, or OLP stops the machine with `ui_heartbeat_lost`.

## Conventions

- Request and response bodies are JSON unless a route says otherwise. Unknown request fields are ignored; trailing data after the JSON value is refused.
- Units are in the field names: `_rad`, `_deg`, `_mm`, `_m`, `_s`, `_ms`, `_ns`. Where a name has no unit, the table says.
- Request body limits: 64 KiB for dense-execution routes, 12 MiB for `/cadquery/topology` and `/seam`, 24 MiB for `/weld-plan`.
- Responses are gzip-compressed when the client accepts it.

### Errors

Two error shapes are in use.

Machine-control routes (`/dense-execution/*`, `/targets/select`):

```json
{"error": "home_required", "detail": "home_required: establish the current joint position reference in Home before loading the program"}
```

`error` is a stable code. A refusal from `rt-control` keeps `rt-control`'s reason as the code. A limit refusal can add `limit_violation`: `{kind, segment, sample, axis, value, limit, unit}`.

Authoring and service routes:

```json
{"ok": false, "code": "seam_worker_unavailable", "error": "the seam worker is unavailable, so no plan request can be packed"}
```

### Target fencing

When the server runs with a cell catalogue (`OFFLINE_PROGRAMMING_CELLS`), every `POST` under `/dense-execution/` must name the selection it was issued against:

| Header | Value |
|---|---|
| `X-RT-Target-Generation` | `status.target.selection_generation`, a decimal integer (sent as a string in JSON) |
| `X-RT-Target-Cell` | `status.target.cell_id` |

If another client has selected a different cell since, the request is refused with `409 target_changed` and nothing is sent to the robot.

These routes are exempt, so they always work: `/stop`, `/pause`, `/heartbeat`, `/target`, `/cells`, `/jog/stop`, `/jog/end`, and `/arm` with `{"armed": false}`. `/cartesian/stop` and `/cartesian/halt` are **not** exempt.

Without a catalogue (a single target from `--rt-core-config`), there is no selection and no fencing.

## Routes at a glance

| Method and path | Purpose |
|---|---|
| `GET /health` | Server status |
| `GET /capabilities` | Feature flags |
| `GET /robots`, `POST /robots/capture-model` | Robot catalogue; compiled URDF for a recorded pose |
| `POST /cadquery/topology` | STEP topology and tessellation |
| `POST /seam` | Seam worker operations |
| `POST /weld-plan`, `POST /weld-plan/export` | Plan a program; export the `.weldplan` |
| `GET /targets`, `POST /targets/select` | List and select cells |
| `GET /dense-execution/cells`, `POST /dense-execution/cells`, `DELETE /dense-execution/cells/{id}`, `POST /dense-execution/target` | Cell registry |
| `GET /dense-execution/capabilities`, `GET /dense-execution/status`, `GET /dense-execution/cell`, `GET /dense-execution/telemetry/window` | Observe the selected cell |
| `POST /dense-execution/home`, `POST /dense-execution/arm` | Home and arm |
| `POST /dense-execution/load`, `play`, `pause`, `stop`, `heartbeat` | Run a planned program |
| `GET /dense-execution/jog/state`, `POST /dense-execution/jog/{begin,update,end}` | Joint jog |
| `POST /dense-execution/move` | Joint move |
| `GET /dense-execution/cartesian/state`, `POST /dense-execution/cartesian/{start,intent,stop,halt}` | Cartesian jog |
| `POST /dense-execution/cartesian/move` | Cartesian move |
| `POST /dense-execution/go-home` | Not available on `rt_core` |
| `/local-simulator/*` | The loopback simulator and its preview jog |
| `/programs*` | Program catalog proxy |

## Service

**Endpoint: `GET /api/offline-programming/v1/health`**

Server status. Always 200.

```json
{"ok": true, "schema": "offline-programming.server-status.v1", "mode": "offline_preview",
 "network_required": false, "connected": false, "connected_targets": 0,
 "local_simulator": { … }, "local_ready": true, "execution_enabled": false,
 "target_planning": false, "teleop_enabled": false,
 "program_catalog": {"available": false, "owner": "motion-server"}}
```

`execution_enabled` and `connected_targets` describe the legacy connected-execution path, not dense execution.

**Endpoint: `GET /api/offline-programming/v1/capabilities`**

Feature flags, schema `offline-programming.capabilities.v1`: whether the seam worker is wired (`weld_planner.enabled`), the local simulator's availability, and the planning flags.

**Endpoint: `GET /api/offline-programming/v1/robots`**

The robot descriptions this server can plan against: the workstation's own, plus any fetched from cells, by identity.

```json
{"robots": [{"model_id": "rosie_1400_v3", "robot_description_sha256": "sha256:…",
   "frames": {"base": "world", "tool": "tool0", "work": "positioner_table_a_top",
              "work_world_m": [0, -0.622, 0.1], "arm_base_world_m": [ … ]},
   "axes": {"driven": ["J1", "J2", "J3", "J4", "J5", "J6", "J7"], "held": {"J8": 0, "J9": 0}},
   "reset_pose": { … }}],
 "problems": []}
```

`problems` names each description that failed to load. A 500 with `robot_store_unreadable` means the description directory itself could not be read.

**Endpoint: `POST /api/offline-programming/v1/robots/capture-model`**

Compiles the URDF for a model at a given description and cell calibration, for recording a pose.

| Field | Type | Required | Description |
|---|---|---|---|
| `model_id` | string | yes | Robot model |
| `robot_description_sha256` | string | yes | Description identity, `sha256:<64 hex>` |
| `machine_planning_calibration_base64` | string | no | The cell's `machine_planning_calibration.json`, base64. Empty uses the empty calibration. |

Returns `{"urdf": "<xml>", "identity": …}`. A failure returns 400 `{"error": "…"}`. If the description is neither local, cached nor advertised by the selected cell: `adopt the matching robot description before recording`.

## Authoring and planning

**Endpoint: `POST /api/offline-programming/v1/cadquery/topology`**

Forwards the body to the bounded CadQuery worker (one subprocess per request) and returns its JSON.

The worker operations are `extract_topology`, `build_seam`, `build_sequence` and `tessellate`. Error codes are the same set as the seam worker's, below.

**Endpoint: `POST /api/offline-programming/v1/seam`**

Forwards the body to the seam worker, `python -m seam_worker.workers --stdin`, and returns its JSON.

The body names the operation: `{"operation": "detect_joints", …}`. Operations: `detect_joints`, `check_torch_fits`, `plan_torch_path`, `sample_seam_frames`, `build_plan_request`. The response header `X-Offline-Seam-Part` identifies the part the answer is about.

| Code | HTTP | Meaning |
|---|---|---|
| `invalid_request` | 400 | The worker refused the request |
| `payload_too_large` | 413 | Body over 12 MiB |
| `busy` | 429 | No worker free |
| `canceled` | 408 | The client went away |
| `timeout` | 504 | The worker ran out of time |
| `unavailable` | 503 | The worker's pixi environment is missing |
| `worker_failed`, `invalid_response` | 502 | The worker crashed or answered with invalid JSON |

See the [weld program format](https://advancedmetalresearch.com/docs/reference/weld-program-format) for the operations' payloads.

### Plan a program

**Endpoint: `POST /api/offline-programming/v1/weld-plan`**

Packs a `.weldplan`, sends it to the weld planner, and brings back the planner's result and the dense trajectory the robot will play. One plan at a time; up to 30 minutes.

| Field | Type | Required | Description |
|---|---|---|---|
| `program` | object | yes | A [`robot.v4.program.v2`](/docs/reference/program-format) document |
| `tooling` | object | yes | The fitted torch, packed as `tooling.json` |
| `step_base64` | string | yes | The workpiece STEP file, base64. Empty only when `workpiece_absent` is true. |
| `step_filename` | string | no | Its file name |
| `workpiece_absent` | boolean | no | Plan with no workpiece |
| `cell_id` | string | yes | The robot model whose description to plan against |
| `free_space_backend` | string | no | Passed to the planner: `bspline`, `curobo` or `legacy` |
| `machine_planning_calibration_base64` | string | no | Only read when no cell is connected. While a cell is selected, the server fetches the cell's own calibration. |
| `program_id` | string | no | Stamped into the plan identity |
| `program_digest` | string | no | Echoed in the response |
| `manifest_revision`, `plan_revision` | integer | no | Stamped into the plan identity |
| `fixtures` | object | no | Workcell bodies in the cell's world frame, packed as `fixtures.json` |

The server always asks the planner for weld and connecting trajectory optimisation, verification and a dense trajectory.

200 OK:

```json
{"result": { … }, "weldplan_sha256": "…", "program_digest": "sha256:…",
 "dense": {"trajectory_digest": "sha256:…", "plan_id": "bracket_fillet:3f1c0a9d2b7e", … },
 "dense_blob_base64": "…"}
```

| Field | Description |
|---|---|
| `result` | The planner's result document, `amr-weld-planner-v1.motion-plan-result.v1`, unchanged. See the [Weld planner HTTP API](https://advancedmetalresearch.com/docs/apis/weld-planner-http). |
| `weldplan_sha256` | SHA-256 of the packed request |
| `dense` | The dense trajectory summary: digest, `plan_id`, samples, segments, `robot_cell` |
| `dense_blob_base64` | The `.rdt` bytes, base64, so the project keeps a copy |

| Code | HTTP | Meaning |
|---|---|---|
| `weld_plan_invalid` | 400 | Invalid JSON, or missing STEP |
| `seam_worker_unavailable`, `motion_origin_unavailable` | 503 | The seam worker or `OFFLINE_PROGRAMMING_WELD_PLANNER_MOTION_ORIGIN` is not configured |
| `motion_plan_seam_refused` | 502 | One or more welds could not be planned |
| `motion_plan_unjoined` | 502 | The welds planned but the connecting moves did not |
| `motion_plan_no_trajectory` | 502 | No dense trajectory was produced; `dense_error` gives `{reason, detail, segment_index}` |
| `motion_plan_failed`, `motion_plan_invalid_response`, `dense_blob_unreachable` | 502 | The planner call failed |
| seam worker codes | as above | Packing failed |

**Endpoint: `POST /api/offline-programming/v1/weld-plan/export`**

Packs the same `.weldplan` without planning it. Same body as `/weld-plan`.

Returns the container bytes as an attachment named `<program_id>.weldplan`, with its hash in `X-Weldplan-SHA256`.

## Cells and targets

A **cell** is a commissioned machine in the server's catalogue. Selecting one creates the controller for it and bumps the selection generation used for [target fencing](https://advancedmetalresearch.com/docs/apis/olp-http#target-fencing). Selection grants no authority; Arm or Load does. See [Connect to a cell](https://advancedmetalresearch.com/docs/guides/connect-a-cell) for the catalogue file.

**Endpoint: `GET /api/offline-programming/v1/targets`**

Lists the catalogue's cells with what each one reports about itself.

```json
{"targets": [{"cell_id": "local-simulation", "label": "Local simulation", "role": "LOCAL SIMULATION",
   "models": ["rosie_1400_v3"], "requested_mode": "simulation",
   "backend": "simulation", "simulation": true, "host": "localhost"}],
 "selected": null}
```

A cell that cannot be used has `backend: "unreachable"` and a `reason`, for example `cell_configuration_missing`, `cell_mode_mismatch` or `cell_unreachable`.

**Endpoint: `POST /api/offline-programming/v1/targets/select`**

Selects a cell. Stops and releases any session on the previous cell first.

| Field | Type | Required | Description |
|---|---|---|---|
| `cell_id` | string | yes | A catalogue cell |
| `model_id` | string | yes | One of that cell's `models` |

Returns `{"selected": {cell_id, model_id}, "target": {…view…}}`. Refusals, all 409: `cell_switch_busy`, `cell_switch_while_playing`, `cell_model_mismatch`, `cell_id_unknown`, `cell_mode_mismatch` (the cell's backend is not the requested mode), `cell_previous_stop_failed`, `cell_backend_unavailable`, `cell_configuration_mismatch`, `cell_description_invalid`, `cell_backend_mismatch`, `cell_unreachable`. Without a catalogue: 503 `cell_catalogue_unavailable`.

**Endpoint: `POST /api/offline-programming/v1/dense-execution/target`**

The Cells dialog's selection. Body `{"cell": "<id>"}`; an empty string clears the selection. Returns the dense-execution capabilities. Not fenced.

**Endpoint: `GET /api/offline-programming/v1/dense-execution/cells`**

The registered cells, with reachability: `{"cells": [{id, label, address, host, credential_ref, model_id, backend, configuration_digest, source, reachable, error}], "active_cell": "…"}`.

`POST /dense-execution/cells` only accepts a cell that matches a commissioned server-side binding (otherwise `cell_not_commissioned` or `cell_binding_pinned`). `DELETE /dense-execution/cells/{id}` always refuses with `cell_commissioning_required`, or `dense_cell_active` for the selected cell: commissioned cells are removed from the catalogue file, not from the UI.

## Machine control

Every route below answers 503 `dense_target_unavailable` when no cell is selected. Every mutating route except the [exempt ones](https://advancedmetalresearch.com/docs/apis/olp-http#target-fencing) needs the fencing headers.

### Observe

**Endpoint: `GET /api/offline-programming/v1/dense-execution/capabilities`**

What the selected controller is and whether it is reachable.

```json
{"backend": "rt_core", "available": true, "cell": "local-dev", "controller_id": "offline-programming:server-3f9a1c",
 "daemon_reachable": true, "rt_core": {"description": { … }, "address_host": ""},
 "active_cell": "local-simulation", "cells": [ … ]}
```

`cell` is the cell's pair ID. Joint and Cartesian jog and moves send it as `target_id`. `rt_core.description` is `rt-control`'s Describe. `reason` explains an unreachable controller, for example `rt_core_identity_mismatch`.

**Endpoint: `GET /api/offline-programming/v1/dense-execution/status`**

The machine and session state. Takes no authority. Poll it.

```json
{
  "backend": "rt_core", "available": true,
  "target": {"selection_generation": "4", "cell_id": "local-simulation", "model_id": "rosie_1400_v3",
             "label": "Local simulation", "backend": "simulation", "simulation": true, "host": "localhost"},
  "session": {"id": "ds-9b1e4f07a2c3", "kind": "trajectory", "state": "loaded", "dry_run": true,
              "trajectory_digest": "sha256:…", "plan_id": "bracket_fillet:3f1c0a9d2b7e",
              "lease": {"held": true, "expires_in_ms": 0},
              "heartbeat_age_ms": 820, "heartbeat_deadline_ms": 5000,
              "stop_reason": "", "last_error": ""},
  "armed": true,
  "robot_cell": {"configured": true, "valid": true, "model_id": "rosie_1400_v3",
                 "robot_description_sha256": "sha256:…", "machine_planning_calibration_sha256": "sha256:…",
                 "machine_planning_calibration_present": false, "error": ""},
  "rt_core": {"inhibited": false, "stop_uncertain": false, "recovery_action": "",
              "refusal": "", "refusal_kind": "", "reason": "", "status": { … }, "events": [ … ], "description": { … }},
  "round_trip_ns": 180000, "link_lease_ns": 500000000
}
```

| Field | Description |
|---|---|
| `target` | The selection this status belongs to. Use it for the [fencing headers](https://advancedmetalresearch.com/docs/apis/olp-http#target-fencing). |
| `session.id` | The session to name in heartbeat, Play, jog and moves |
| `session.kind` | `authority` (after Arm or Home), `trajectory` (after Load) |
| `session.state` | `idle`, `loaded`, `playing` or `stopped` |
| `session.lease.held` | OLP holds the `rt-control` lease |
| `session.stop_reason`, `last_error` | Why the session stopped |
| `armed` | Armed, lease held and no refusal pending |
| `robot_cell` | Which robot the machine says it is. See [Robot and cell identity](https://advancedmetalresearch.com/docs/reference/rdt-format#robot-cell). |
| `rt_core.status` | `rt-control`'s full status. See [`status`](/docs/apis/rt-control-http#status). |
| `rt_core.events` | Events since the last poll |
| `rt_core.refusal`, `refusal_kind` | The last refused operation and which kind it was |
| `rt_core.inhibited`, `stop_uncertain`, `recovery_action` | A Stop or Release was not confirmed. `recovery_action` says what to do: retry `POST /dense-execution/stop`. |
| `round_trip_ns`, `link_lease_ns` | Measured round trip to `rt-control`, and the effective lease |

**Endpoint: `GET /api/offline-programming/v1/dense-execution/cell`**

The selected machine's robot identity, its `machine_planning_calibration.json` as base64, and its execution limits. This is what a plan request for this cell carries.

**Endpoint: `GET /api/offline-programming/v1/dense-execution/telemetry/window`**

The newest seconds of the machine's telemetry ring, thinned.

| Query | Type | Default | Description |
|---|---|---|---|
| `seconds` | number, s | 3 | In (0, 30] |
| `stride` | integer | 1 | Keep every N-th record, 1–100 |

Returns `{daemon_incarnation, configuration_sha256, cycle_period_ns, axis_count, stride, seconds, first_sequence, last_sequence, head_estimate, fetched_records, fetch_ms, records: [...]}`. One second of 1 kHz telemetry is about 4.9 MB from the cell before thinning. Errors: 400 `telemetry_window_invalid`, 503 `telemetry_unavailable`, 409 `telemetry_identity_changed`.

### Home

**Endpoint: `POST /api/offline-programming/v1/dense-execution/home`**

Runs native Home and waits until Home is valid on the named axes. Acquires the lease if OLP does not hold it. Does not arm.

| Field | Type | Required | Description |
|---|---|---|---|
| `axes` | integer array | no | Axis indices, 0–15, a subset of the cell's axis mask. Omit the body, or send `[]`, to home every configured axis. |

Returns the status body. If an axis has a latched reference fault whose recovery is a reset, Home resets it first. Other latched faults refuse with `rt_core_fault`. Errors: 400 `home_axes_invalid`, 409 `capability_unimplemented`, and `rt-control` reasons.

### Arm and disarm

**Endpoint: `POST /api/offline-programming/v1/dense-execution/arm`**

`{"armed": true}` acquires the lease, enables and arms, and waits until every configured axis is ready. `{"armed": false}` runs Stop and Release.

| Field | Type | Required | Description |
|---|---|---|---|
| `armed` | boolean | yes | Arm or disarm |

200 OK:

```json
{"ok": true, "accepted": true, "status": { … }}
```

If a latched execution fault can be cleared by a reset (recovery class `reset_clears` or `reset_after_condition_clears`), Arm resets it first, then re-acquires and arms, so one Arm recovers the machine. Faults that need a re-Home or a restart are left to their refusal. Errors: 400 `dense_arm_invalid`, 409 `rt_core_arm_refused`, 409 `rt_core_arm_timeout`, and `rt-control` reasons such as `control_already_owned` or `not_ready`.

### Heartbeat

**Endpoint: `POST /api/offline-programming/v1/dense-execution/heartbeat`**

Tells OLP a supervising client is still there. Send it at least every 5 s while OLP holds the lease. Not fenced.

request:

```json
{"session_id": "ds-9b1e4f07a2c3"}
```

200 OK:

```json
{"ok": true, "deadline_ms": 5000, "age_ms": 1004}
```

`age_ms` is how long the previous beat had stood. If no beat arrives for 5 s, OLP stops the machine with `ui_heartbeat_lost`, except while an accepted Cartesian move is finishing. Errors: 400 `dense_session_required`, 409 `dense_session_mismatch`.

### Load, Play, Stop

**Endpoint: `POST /api/offline-programming/v1/dense-execution/load`**

Fetches a verified `.rdt` from the weld planner by digest, checks it, and prepares it on `rt-control`.

request:

```json
{
  "trajectory_digest": "sha256:…",
  "plan_id": "bracket_fillet:3f1c0a9d2b7e",
  "program_id": "bracket_fillet",
  "program_digest": "sha256:…",
  "manifest_revision": 1,
  "plan_revision": 3,
  "dry_run": true
}
```

| Field | Type | Required | Description |
|---|---|---|---|
| `trajectory_digest` | string | yes | `sha256:<64 hex>`. The server fetches `GET /api/motion/dense/{digest}` from the weld planner. |
| `plan_id`, `program_id`, `program_digest` | string | yes | Must equal the `.rdt` header exactly |
| `manifest_revision`, `plan_revision` | integer | yes | Must equal the header. JSON integers. |
| `dry_run` | boolean | no | Strip the torch bits and process markers before upload. The planner sets the torch bit on weld segments unless the program's `execution_mode` is `dry_run`, and a program that needs process outputs is refused, so set this for any planned weld program. |

Load runs these checks in order, before any byte reaches the robot:

1. The identity matches the header (`dense_identity_mismatch`).
2. The plan's `robot_cell` matches the machine (`robot_cell_missing`, `robot_cell_mismatch`, `robot_cell_unavailable`).
3. Every configured axis has valid Home (`home_required`).
4. OLP acquires the lease and calls `prepare_program`. `rt-control` applies [its admission checks](https://advancedmetalresearch.com/docs/reference/rdt-format#rt-control-admission).
5. The prepared identity, axis mask and process requirement match (`program_identity_or_process_mismatch`).

200 OK:

```json
{"ok": true, "session_id": "ds-9b1e4f07a2c3",
 "trajectory": {"trajectory_digest": "sha256:…", "total_sample_count": 4210, "total_duration_s": 42.09, "dt_s": 0.01,
                "segments": [{"index": 0, "kind": "freespace", "source_id": "approach-1", "sample_count": 350, "duration_s": 3.49}]},
 "status": { … },
 "notes": ["the machine's configuration changed since this plan was made (…); the robot description and planning calibration still match, so it loads"]}
```

Other errors: 400 `dense_identity_invalid`, 400 `dense_trajectory_invalid`, 404 `dense_blob_not_found`, 503 `dense_blob_source_unavailable`, 409 `program_already_executing`, and `rt-control` reasons with an optional `limit_violation`.

**Endpoint: `POST /api/offline-programming/v1/dense-execution/play`**

Starts the loaded program. Body `{"session_id": "…"}` from the Load answer. Waits until the machine is armed and every axis ready, then sends `start_program`. Returns the status body.

Refused with `control_session_stale` unless the session is the loaded one and OLP holds the lease. Arm before Play.

**Endpoint: `POST /api/offline-programming/v1/dense-execution/stop`**

Stops the machine now, then releases the lease. Needs no body and no fence. Retry it until it succeeds.

200 OK:

```json
{"ok": true, "stopped": true, "attempts": 1, "status": { … }}
```

`note: "nothing_held"` means there was nothing to stop. If Stop or Release cannot be confirmed, the answer is an error that still carries `stopped`, `attempts` and `status`, and further motion is blocked (`rt_core_inhibited`) until a Stop succeeds. A receipt does not prove the robot is standing still; see [Software stops](https://advancedmetalresearch.com/docs/get-started/safety-model#software-stops).

**Endpoint: `POST /api/offline-programming/v1/dense-execution/pause`**

Always refused on `rt_core` with `capability_unimplemented`. Use Stop.

**Endpoint: `POST /api/offline-programming/v1/dense-execution/go-home`**

Always refused on `rt_core` with `capability_unimplemented`. Use Home, or a joint move.

### Joint jog

**Endpoint: `POST /api/offline-programming/v1/dense-execution/jog/{action}`**

Holds one axis at a fraction of its described velocity. `action` is `begin`, `update` or `end`. Needs an armed session. `GET /dense-execution/jog/state` returns the current state.

| Field | Type | Required | Description |
|---|---|---|---|
| `target_id` | string | yes | The cell's pair ID, `capabilities.cell` |
| `session_id` | string | yes | `status.session.id` |
| `revision` | integer | yes | `jog/state.revision`. It advances when a jog ends or a move starts, so a late request from an old hold is refused. |
| `axis` | integer | yes | Axis index, within the cell's axis mask |
| `direction` | integer | yes | `1` or `-1` |
| `fraction` | number | yes | (0, 1]. The axis moves at `fraction × max_velocity × 0.75`. |

Send `begin`, then `update` every 50 ms while the operator holds the control, then `end`. An update may not change `axis`, `direction` or `fraction`; end the hold and begin a new one. Each update lives for the cell's jog input age (250 ms on a LAN cell). If updates stop, the jog ends with `jog_input_deadline_expired` and the axis ramps to a hold.

jog/state:

```json
{"active": true, "revision": 12, "generation": 3, "sequence": 41, "axis": 0, "reason": "", "cadence_ms": 50,
 "move": {"axis": 0, "state": "done", "reason": "", "revision": 11}}
```

Errors (409 unless noted): `jog_session_stale`, `jog_mode_conflict` (a move, a program or another hold is active), `no_grant`, `jog_not_ready`, `invalid_axis_mask`, 400 `jog_invalid_vector`, `jog_input_deadline_expired`, `jog_transport_congested`, `dense_target_unavailable`, and native jog reasons.

### Joint move

**Endpoint: `POST /api/offline-programming/v1/dense-execution/move`**

Moves one or more joints by a bounded amount and returns when the move is done. Needs an armed session.

request: all arm joints to zero:

```json
{"target_id": "local-dev", "session_id": "ds-9b1e4f07a2c3", "revision": 12, "fraction": 0.2,
 "axis": 0, "degrees": 0,
 "axes": [{"axis": 0, "position_rad": 0}, {"axis": 1, "position_rad": 0}, {"axis": 2, "position_rad": 0},
          {"axis": 3, "position_rad": 0}, {"axis": 4, "position_rad": 0}, {"axis": 5, "position_rad": 0}]}
```

| Field | Type | Required | Description |
|---|---|---|---|
| `target_id`, `session_id`, `revision` | | yes | As for joint jog |
| `axis` | integer | yes | The joint, for a single-joint move |
| `degrees` | number, **degrees** | yes | Signed relative move for a single joint. Non-zero. |
| `fraction` | number | yes | (0, 1] of each joint's velocity and acceleration |
| `axes[]` | array | no | A synchronised move of several joints. Each entry has `axis` and either `degrees` (relative, **degrees**) or `position_rad` (absolute, **radians**, from the held position). When present, it replaces `axis` and `degrees`. |

Each joint follows its own trapezoid at `fraction × max_velocity × 0.75` and `fraction ×` its jog acceleration (or its described maximum acceleration, if lower). Shorter profiles are stretched so all joints start and finish together. The points are sampled at the core's cycle and sent with `prepare_trajectory` and `start_trajectory`. The target must be inside the joint's limits.

200 OK:

```json
{"axis": 0, "state": "done", "reason": "", "revision": 13, "axes": [0, 1, 2, 3, 4, 5]}
```

Errors: `joint_move_invalid`, `joint_move_limits`, `joint_move_units`, `joint_move_description_invalid`, `joint_move_too_many_points`, `joint_move_prepare_timeout`, `joint_move_prepare_busy`, `joint_move_interrupted`, `joint_move_completion_timeout`, `joint_move_feedback_invalid`, `client_disconnected`, plus the joint jog errors. If the HTTP client disconnects, OLP stops the move.

### Cartesian jog

**Endpoint: `POST /api/offline-programming/v1/dense-execution/cartesian/{action}`**

Jogs the tool in the base or tool frame. `action` is `start`, `intent`, `stop` or `halt` (`stop` and `halt` both end the hold). Same session, revision and timing rules as joint jog. `GET /dense-execution/cartesian/state` returns the jog state.

| Field | Type | Required | Description |
|---|---|---|---|
| `target_id`, `session_id`, `revision` | | yes | As for joint jog |
| `frame` | string | yes | `base` or `tool` |
| `twist` | 6 numbers | yes | X, Y, Z in m/s (each \|v\| ≤ 1), RX, RY, RZ in rad/s (each \|ω\| ≤ π). At least one non-zero. |
| `fraction` | number | yes | (0, 1] |

OLP resolves the twist into joint velocities from the measured pose with `robot-v4-cartesiand --resolve-only`, then scales the whole vector so no joint exceeds `fraction × max_velocity × 0.75`. The twist sets the direction; the joint ceilings usually set the speed. A step that would leave a joint's limits within one input lifetime is refused with `joint_limit`.

Errors: 400 `cartesian_input_invalid`, `cartesian_resolver_unavailable`, `cartesian_resolver_invalid`, `cartesian_pose_stale`, `cartesian_axes_invalid`, `joint_limit`, `jacobian_gate`, `ik_no_solution`, plus the joint jog errors.

### Cartesian move

**Endpoint: `POST /api/offline-programming/v1/dense-execution/cartesian/move`**

Moves the tool a bounded distance along one axis of the base or tool frame, in a straight line, and returns when done.

| Field | Type | Required | Description |
|---|---|---|---|
| `target_id`, `session_id`, `revision` | | yes | As for joint jog |
| `frame` | string | yes | `base` or `tool` |
| `axis` | integer | yes | 0–2 for X, Y, Z; 3–5 for RX, RY, RZ |
| `distance_mm` | number, **mm** | for axes 0–2 | Non-zero for a linear axis, zero otherwise. At most 1,000 mm. |
| `angle_degrees` | number, **degrees** | for axes 3–5 | Non-zero for a rotary axis, zero otherwise. At most 180°. |
| `fraction` | number | yes | (0, 1] |

The resolver solves IK along the line at 1 mm or 0.25° spacing and checks each waypoint against joint limits and the singularity gate. OLP then times the path with a smooth (quintic) progress clock, so that no joint exceeds `fraction × max_velocity × 0.75`, reduced further near a singularity, or `fraction ×` its acceleration limit. It sends the result as one trajectory. An accepted move finishes even if the HTTP client disconnects; Stop still cancels it.

Errors: `cartesian_input_invalid`, `cartesian_reach`, `ik_no_solution`, `joint_limit`, `cartesian_pose_stale`, `cartesian_resolver_invalid`, `joint_move_too_many_points`, plus the joint move errors.

## Local simulator

The server can own a loopback-only rt-core simulator for preview. It requires a loopback `--listen` address and is on by default (`--enable-local-simulator`).

| Method and path | Description |
|---|---|
| `GET /local-simulator/status` | Simulator phase and readiness |
| `POST /local-simulator/start`, `POST /local-simulator/stop` | Start or stop it. No request body. |
| `POST /local-simulator/plans` | Plan a saved program against the simulator |
| `POST /local-simulator/jog/start`, `/jog/stop` | Start or stop the preview jog runtime |
| `POST /local-simulator/jog/intent` | `{mode: "base"\|"tool"\|"joint", axes: [6 values in -1..1], selectedJoint, speedScale: 0..1}` |
| `GET /local-simulator/jog/state` | Joints in rad, axis names, running flag |
| `POST /local-simulator/jog/home`, `/jog/halt` | Home or halt the preview |
| `POST /local-simulator/jog/set-zero` | `{axisMask}` |
| `POST /local-simulator/jog/step` | `{joint, deltaDeg}` (degrees); answers 202 |

Once a machine is selected, preview jog requests are refused with `target_changed`, so a preview cannot run beside a selected controller.

## Program catalog

`GET`/`POST /programs`, `GET`/`PUT /programs/{program_id}` and `GET /programs/{program_id}/revisions` proxy to an external program catalog set with `--program-catalog-origin`. Without one they answer `catalog_unavailable`. The OLP app stores projects in the browser (IndexedDB) and does not need the catalog.

## Legacy and disabled routes

- `connected-targets`, `execution-gateway`, `execution`, `targets/{profile}/plans` and `plans/{plan_id}` belong to the older connected-execution path over NATS. They stay off unless the server is started with `--enable-connected-planning` or `--enable-connected-execution`. Don't use them for new work.
- `POST /connect`, `POST /execute` and `POST /teleop` are retired and answer 403 `{"ok": false, "enabled": false}`.

## Error codes

Codes specific to machine control, with their usual HTTP status. `rt-control` reasons such as `control_already_owned`, `not_ready`, `native_limit_exceeded` or `mode_conflict` pass through with 409; see [Error codes](https://advancedmetalresearch.com/docs/reference/error-codes).

| Code | HTTP | Meaning |
|---|---|---|
| `dense_target_unavailable` | 503, 409 | No cell selected, or the backend cannot do this |
| `target_changed` | 409 | The fencing headers name an old selection |
| `ui_heartbeat_lost` | 409 | OLP stopped the machine: no heartbeat for 5 s |
| `rt_core_inhibited` | 409 | A previous Stop or Release was not confirmed. Retry Stop. |
| `rt_core_identity_mismatch` | 409 | Describe no longer matches the cell's pinned identity |
| `rt_core_request_slow` | 409 | `rt-control` did not answer within the call budget. The lease is kept; retry. |
| `rt_core_connection_reopened` | 409 | The request was not sent; the client reconnected. Retry. |
| `rt_core_transport_lost`, `rt_core_protocol_error`, `rt_core_closed`, `rt_core_cancelled`, `rt_core_failure` | 409 | Transport or protocol failures |
| `rt_core_incarnation_changed`, `rt_core_event` | 409 | `rt-control` restarted, or an event (a fault, a lost grant, dropped events) ended the session |
| `control_renewal_lost` | 409 | Lease renewal failed; OLP stopped |
| `control_session_stale` | 409 | The session was stopped or replaced before this request ran |
| `capability_unimplemented` | 409 | Pause, go-home, or a missing `rt-control` capability |
| `dense_session_required`, `dense_session_mismatch` | 400, 409 | Missing or unknown `session_id` |
| `dense_internal` | 500 | Unexpected server error |

## Related pages

- [Connect to a cell and run a program](https://advancedmetalresearch.com/docs/guides/connect-a-cell)
- [Motion paths and planning](https://advancedmetalresearch.com/docs/concepts/motion-and-planning#olp)
- [Program format](https://advancedmetalresearch.com/docs/reference/program-format)
- [Weld planner HTTP API](https://advancedmetalresearch.com/docs/apis/weld-planner-http)
- [rt-control HTTP API](https://advancedmetalresearch.com/docs/apis/rt-control-http)

## Sources

Written from these files in the RosieOS repository (https://github.com/advanced-metal-research/RosieOS):

- `offline-programming/v1/server.go:29-38,145-218,220-264,522-600,855-1100,1294-1330,1482-1502,1602-1614,1631-1696`
- `offline-programming/v1/dense_execution.go:21-361`
- `offline-programming/v1/dense_cells.go:1-57`
- `offline-programming/v1/cell_targets.go:1-47`
- `offline-programming/v1/cartesian_jog.go:1-84`
- `offline-programming/v1/robots.go:26-127`
- `offline-programming/v1/weld_plan.go:29-224,420-445,495-540`
- `offline-programming/v1/main.go:257-320,896-940`
- `offline-programming/v1/internal/denseexec/session.go:21-317`
- `offline-programming/v1/internal/denseexec/rt_core.go:25-146,217-238,267-397,399-504,556-871,980-1031,1086-1193,1256-1406`
- `offline-programming/v1/internal/denseexec/joint_jog_api.go:1-70`
- `offline-programming/v1/internal/denseexec/joint_jog.go:17-31,196-420,515-524`
- `offline-programming/v1/internal/denseexec/joint_move.go:17-305,437-520`
- `offline-programming/v1/internal/denseexec/cartesian_jog_api.go:1-60`
- `offline-programming/v1/internal/denseexec/cartesian_jog.go:52-215`
- `offline-programming/v1/internal/denseexec/cartesian_move.go:17-60`
- `offline-programming/v1/internal/denseexec/robot.go:12-104`
- `offline-programming/v1/internal/denseexec/registry.go:1-57`
- `offline-programming/v1/internal/denseexec/fleet.go:1-43`
- `offline-programming/v1/internal/denseexec/rt_core_telemetry.go:49-80`
- `offline-programming/v1/internal/targets/picker.go:16-260`
- `offline-programming/v1/internal/seam/types.go:20-56`
- `offline-programming/v1/internal/cad/types.go:17-20`
- `offline-programming/v1/internal/simulator/contracts.go:23-26`
- `offline-programming/v1/internal/simulator/jog_manager.go:10-17`
- `offline-programming/v1/internal/simulator/jog_session.go:37-57`
- `robot_description/go/description.go:298-323`
- `motion-server/v1/src/cartesian_resolver.hpp:14-97`
