# Weld planner HTTP API

> The weld planner's four routes on port 8796, for health, progress, planning a .weldplan and fetching the verified dense trajectory, with query parameters, units, the result document and error codes.

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

The weld planner's motion server is a small FastAPI app. You POST a `.weldplan` container and get back a plan result, and, if every segment passed the verifier, the digest of a dense trajectory (`.rdt`) you can then fetch. The offline programming (OLP) server is its usual client. See [Weld planning and verification](https://advancedmetalresearch.com/docs/concepts/weld-planning-and-verification) for what the planner does.

The server listens on `0.0.0.0:8796` by default. It has no authentication, and there is no OpenAPI page (`/docs` is disabled).

> [!WARNING] **Bind the weld planner to localhost, or firewall it.** By default it listens on every network interface, and anyone who can reach port 8796 can queue GPU plans and download every stored trajectory. Start it with `--host 127.0.0.1` when OLP runs on the same machine. When a Steam Deck or another host must reach it, allow only those hosts through a firewall. See [Run the weld planner](https://advancedmetalresearch.com/docs/guides/run-the-weld-planner#bind).

> [!TIP] **Machine-readable.** The four routes, their query parameters and errors as an [OpenAPI 3.1 document](https://advancedmetalresearch.com/docs/openapi/weld-planner.json), generated from this page.

## Quick start

Check the GPU, plan a committed example part, and fetch its trajectory:

```bash
PLANNER=http://localhost:8796

curl -s $PLANNER/api/motion/health
# {"cuda": true, "device": "NVIDIA RTX A4000"}

# Plan with verification and a dense trajectory. This takes minutes.
curl -s -X POST --data-binary @weld_planner/v1/data/motion/bracket_a_2x.weldplan \
  -H "Content-Type: application/octet-stream" \
  "$PLANNER/api/motion/plan?run_weld_trajopt=true&run_connecting_trajopt=true&verify=true&dense=true&program_id=bracket_a_2x" \
  -o result.json

jq '.dense.trajectory_digest // .dense_error' result.json

# Fetch the verified trajectory by its digest.
DIGEST=$(jq -r .dense.trajectory_digest result.json)
curl -s -o plan.rdt "$PLANNER/api/motion/dense/$DIGEST"
```

The device name in the health answer is whatever your GPU reports. To make your own `.weldplan`, use **Export .weldplan…** in OLP, or `POST /api/offline-programming/v1/weld-plan/export` on the [OLP server](https://advancedmetalresearch.com/docs/apis/olp-http#weld-plan).

## Routes

| Method and path | Purpose |
|---|---|
| `GET /api/motion/health` | Whether CUDA is available |
| `GET /api/motion/progress` | The running plan's current stage |
| `POST /api/motion/plan` | Plan a `.weldplan` |
| `GET /api/motion/dense/{trajectory_digest}` | Fetch a stored `.rdt` |

## Health

**Endpoint: `GET /api/motion/health`**

Reports whether the planner's Torch build can see a CUDA device.

200 OK:

```json
{"cuda": true, "device": "NVIDIA RTX A4000"}
```

| Field | Type | Description |
|---|---|---|
| `cuda` | boolean | `true` if CUDA is available |
| `device` | string or null | The name of device 0, or `null` without CUDA |

## Progress

**Endpoint: `GET /api/motion/progress`**

The stage of the plan that is running now, for a client watching a long request.

200 OK:

```json
{"stage": "verify"}
```

`stage` is `null` when no plan is running. While one is, it is the last stage the planner reported, for example `sweep`, `weldseam`, `freespace seed`, `optimize` or `verify`. There is one slot, because only one plan runs at a time.

## Plan

**Endpoint: `POST /api/motion/plan`**

Plans every seam and move of the posted `.weldplan`, verifies them, and optionally writes the dense trajectory.

The body is the raw `.weldplan` bytes. Options go in the query string.

### Query parameters

| Name | Type | Default | Range | Description |
|---|---|---|---|---|
| `k_best` | integer | 5 | 1–16 | Candidate paths to keep per seam from the M4 search |
| `screen_collisions` | boolean | `true` | | Screen search poses against the collision model, and include collision avoidance in M5 and M6. `false` means unchecked, not safe. |
| `samples_per_seam` | integer or null | null | 2–512 | Space the M4 lattice by sample count. Null spaces it by time, from the weld's travel speed. |
| `run_weld_trajopt` | boolean | `false` | | Run M5, the continuous weld trajectories. Minutes rather than seconds. |
| `run_connecting_trajopt` | boolean | `true` | | Run M6, the approach, transits, retract and taught moves |
| `free_space_backend` | string | `bspline` | `bspline`, `curobo`, `legacy` | The M6 solver. `curobo` uses cuRobo for comparison and is optional at runtime. `legacy` is deprecated and kept to reproduce old results. Any other value returns 422. |
| `verify` | boolean | `true` | | Run the verifier on M5 and M6 output |
| `verify_margin_mm` | number, mm | 2.0 | 0–50 | The verifier's clearance margin |
| `order_seams` | boolean | `false` | | Reorder welds to shorten the transits. Off, the program's own weld order is kept. |
| `dense` | boolean | `false` | | Build, check and store the `.rdt`. Needs `run_weld_trajopt` and `run_connecting_trajopt`. |
| `program_id` | string | `""` | up to 120 characters | Stamped into the plan identity. A dense trajectory needs a plain identifier (letters, digits, `.`, `_`, `:`, `-`). |
| `manifest_revision` | integer | 1 | ≥ 1 | Stamped into the plan identity |
| `plan_revision` | integer | 1 | ≥ 1 | Stamped into the plan identity |

The OLP server always sends `run_weld_trajopt=true`, `run_connecting_trajopt=true`, `verify=true` and `dense=true`, plus the free-space backend and the program identity.

### How a request runs

- **One plan at a time.** A second request waits for the first. It can still be cancelled while it waits.
- **One process per plan.** Each admitted request runs in a fresh Python process that owns the GPU. This costs interpreter, model and CUDA start-up on every plan.
- **Cancellation.** If the client disconnects, the server sends SIGTERM to the planning process group, then SIGKILL after 2 s, and answers 499. A cancelled plan never stores a `.rdt`.
- **Atomic publication.** The `.rdt` is written to the store only after the planning process succeeded and the client is still connected.

### Response

`200 OK` with the result document, schema `amr-weld-planner-v1.motion-plan-result.v1`. On the wire, angles are in **degrees** and lengths in **mm**; times are in s. A trimmed example:

200 OK:

```json
{
  "schema": "amr-weld-planner-v1.motion-plan-result.v1",
  "cell_id": "rosie_1400_v3",
  "screened": true,
  "k_best": 5,
  "request": {"request_sha256": "3f1c0a9d2b7e…", "program_id": "bracket_a_2x", "cell_id": "rosie_1400_v3", "…": "…"},
  "producer": {"module": "amr-weld-planner/v1", "source_revision": "0123456789abcdef0123456789abcdef01234567", "source_revision_state": "bound"},
  "coverage": {"candidate_collision_screening": "sampled_lattice_nodes",
               "weld_trajectory_continuous_verification": "evaluated_for_all_emitted_trajectories",
               "connecting_trajectory_continuous_verification": "evaluated_for_all_emitted_trajectories",
               "canonical_motion_server": "not_evaluated", "physical_motion": "not_evaluated",
               "execution_authority": "none", "candidate_limit_checks": "reported_per_candidate"},
  "seams": [{
    "id": "seam_0001", "length_mm": 118.0, "crossed": true,
    "axis_names": ["J1", "J2", "J3", "J4", "J5", "J6", "J7"], "held": {"J8": 0.0, "J9": 0.0},
    "candidates": [{"cost_deg": 41.8, "joints_deg": [[…]], "times_s": […]}],
    "trajectory": {
      "knots_deg": [[…]], "knot_velocity_deg_s": [[…]], "times_s": […],
      "sampled_tracking_mm": {"knots": 0.02, "midpoints": 0.07},
      "tracking": {"verdict": "PASS", "tolerance_mm": 0.5, "bound_mm": 0.21, "worst_seen_mm": 0.08, "segments_out": 0, "undecided": 0, "unverifiable": []},
      "verdict": {"verdict": "PASS", "collisions": 0, "penetrating": 0, "limit_violations": 0, "unverifiable": [], "worst_clearance_mm": 6.412}
    }
  }],
  "connecting_trajectories": [{
    "kind": "approach", "from": null, "to": "seam_0001", "duration_s": 4.2,
    "knots_deg": [[…]], "knot_velocity_deg_s": [[…]], "times_s": […],
    "clearance_mm": {"env": 38.5, "self": 61.2},
    "verdict": {"verdict": "PASS", "collisions": 0, "penetrating": 0, "limit_violations": 0, "unverifiable": []}
  }],
  "dense": {
    "trajectory_digest": "sha256:9b1e4f07a2c3…", "plan_id": "bracket_a_2x:3f1c0a9d2b7e",
    "program_digest": "sha256:3f1c0a9d2b7e…", "dt_s": 0.01, "total_sample_count": 5230,
    "total_duration_s": 52.29, "robot_cell": {"model_id": "rosie_1400_v3", "…": "…"},
    "segments": [{"index": 0, "kind": "freespace", "source_id": "home->seam_0001", "sample_count": 421, "duration_s": 4.2}]
  },
  "options": {"k_best": 5, "verify": true, "verify_margin_mm": 2.0, "…": "…"},
  "timings_ms": {"…": 0}
}
```

The values above are illustrative. The fields that matter most:

| Field | Description |
|---|---|
| `seams[].crossed` | Whether the M4 search found a path across the seam. When `false`, `frontier` and `frontier_causes` say where and why it stopped. |
| `seams[].trajectory` | The M5 curve: `knots_deg`, `knot_velocity_deg_s` and `times_s` form a cubic Hermite, with its `verdict` and `tracking` reports |
| `connecting_trajectories[]` | The M6 moves, in execution order: `kind` (`approach`, `transit` or `retract`), `from` and `to` seam ids (`null` at the home end), and a `node_id` for moves tied to a program node |
| `connecting_trajectories_error` | Present when M6 failed. The welds may still be good. |
| `program_order` | Present when the program has taught moves: the enabled nodes in order |
| `speed_scale` | Present when the program's plan speed is below 1. `times_s` are already stretched and `knot_velocity_deg_s` already scaled. |
| `dense` | With `dense=true`: the `.rdt` summary, including `trajectory_digest`, `plan_id` (`<program_id>:<first 12 hex of the request digest>`), `program_digest` (`sha256:` plus the request digest), `dt_s`, `total_sample_count`, `total_duration_s`, `max_abs_qd_rad_s` (rad/s), `robot_cell`, `bytes` and `segments[]` |
| `dense_error` | With `dense=true`, when no `.rdt` was written: `{reason, detail, segment_index}` |

See [The plan result](https://advancedmetalresearch.com/docs/concepts/weld-planning-and-verification#result) for every block, and [Verdicts](https://advancedmetalresearch.com/docs/concepts/weld-planning-and-verification#verdicts) for what `PASS`, `REFUSED` and `FAIL` mean.

### A plan without a trajectory

A plan can answer `200 OK` and still have no `.rdt`. Then `dense_error` says why, and the server keeps the request and result under `<dense store>/refused/` (newest 5) for diagnosis. The reasons are admission's and the encoder's:

| `reason` | Meaning |
|---|---|
| `result_empty` | `dense=true` without both trajectory stages, or nothing playable in the result |
| `identity_invalid` | `program_id` is empty or not a plain identifier |
| `seam_not_planned` | A seam was not crossed by the search |
| `motion_not_verified` | A seam or move has no `PASS`, a non-zero collision, penetration or limit count, an unverifiable check, or a tracking certificate out of tolerance |
| `motion_join_failed` | The connecting moves could not be planned |
| `motion_result_invalid` | The result is malformed, for example duplicate seam ids or an unknown move kind |
| `motion_not_planned` | A program node has no planned motion |
| `qd_limit_exceeded` | A sample is faster than the cell's per-axis velocity ceiling |
| `q_step_exceeded`, `boundary_qd_nonzero`, `boundary_q_discontinuity`, `time_grid_invalid`, `sample_count_overflow`, … | The resampled trajectory broke an `.rdt` format rule; see the [`.rdt` format](/docs/reference/rdt-format#validation) |

`segment_index` names the failing segment when there is one.

### Errors

| Status | Body | When |
|---|---|---|
| 400 | `{"error": "an empty body is not a .weldplan"}` | Empty body |
| 422 | `{"detail": "unknown free_space_backend …"}` | Unknown `free_space_backend` |
| 422 | `{"detail": [ … ]}` | A query parameter is out of range or the wrong type |
| 422 | `{"error": "<type>: <message>"}` | Planning failed, including a `.weldplan` the planner refused to open (bad zip, digest mismatch, wrong program schema) or missing cell meshes |
| 499 | `{"error": "Planning cancelled"}` | The client disconnected |

## Fetch a trajectory

**Endpoint: `GET /api/motion/dense/{trajectory_digest}`**

Returns the stored `.rdt` bytes for a digest.

```bash
curl -s -o plan.rdt http://localhost:8796/api/motion/dense/sha256:9b1e4f07a2c3…
```

| Name | In | Type | Description |
|---|---|---|---|
| `trajectory_digest` | path | string | `sha256:` and 64 lowercase hex characters, from `dense.trajectory_digest` |

The response is `application/octet-stream` with `Cache-Control: no-store`. A malformed digest returns 422 (`expected sha256:<64 hex>`). An unknown digest returns 404.

The store only holds trajectories that passed admission. It lives in `--dense-store-dir`, else `WELD_PLANNER_DENSE_STORE_DIR`, else `~/.cache/rosieos-olp/weld-planner-dense`, as `sha256-<hex>.rdt`. It is a cache: if a file is gone, plan again. OLP's Load fetches from this route and answers `dense_blob_not_found` when the planner no longer has the file. See the [`.rdt` format](/docs/reference/rdt-format) for the file layout.

## Related pages

- [Run the weld planner](https://advancedmetalresearch.com/docs/guides/run-the-weld-planner)
- [Weld planning and verification](https://advancedmetalresearch.com/docs/concepts/weld-planning-and-verification)
- [Weld program and `.weldplan` container](/docs/reference/weld-program-format)
- [Offline programming HTTP API](https://advancedmetalresearch.com/docs/apis/olp-http)
- [Ports, sockets and environment variables](https://advancedmetalresearch.com/docs/reference/ports-and-environment)

## Sources

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

- `weld_planner/v1/python/weld_motion_planner/server/motion_planner_server.py:57,78-82,85-185,196-317,379-553,556-571`
- `weld_planner/v1/python/weld_motion_planner/planner/dp_seam_search/options.py:17-31`
- `weld_planner/v1/python/weld_motion_planner/planner/planner_main.py:64-96,235,252`
- `weld_planner/v1/python/weld_motion_planner/planner/trajectory_optimization/options.py:21-90`
- `weld_planner/v1/python/weld_motion_planner/planner/trajectory_optimization/engine/freespace.py:2035,2168,2435-2464`
- `weld_planner/v1/python/weld_motion_planner/planner/trajectory_optimization/connecting_trajopt_main.py:79`
- `weld_planner/v1/python/weld_motion_planner/planner/trajectory_optimization/engine/weld_trajopt.py:245-294`
- `weld_planner/v1/python/weld_motion_planner/planner/trajectory_optimization/engine/taught.py:78-80`
- `weld_planner/v1/python/weld_motion_planner/io/wire.py:69-121`
- `weld_planner/v1/python/weld_motion_planner/io/native_result.py:17-164`
- `weld_planner/v1/python/weld_motion_planner/io/dense_output.py:39-139`
- `weld_planner/v1/python/weld_motion_planner/verifier/verify.py:552-606,1640-1687`
- `weld_planner/v1/python/weldplan/native_admission.py:204-250`
- `weld_planner/v1/python/weldplan/dense_joint_trajectory.py:255-365,655-797`
- `offline-programming/v1/weld_plan.go:424-438`
- `offline-programming/v1/internal/denseexec/session.go:236-262`
