# Aperta Agent API — error catalogue

> **Generated file — do not edit.** Written by `npm run skill:export` (`scripts/export-agent-skill.ts` → `src/lib/agent/skillExport.ts`) from the catalogue `AGENT_ERRORS` in `src/lib/agent/errors.ts`. A test fails when this file and the code disagree.
> This is a snapshot at contract version `2026-09` (API `v1`). The live values are `GET /api/agent/v1/capabilities` — fetch it first; it wins over this file.

41 codes, UPPER_SNAKE, in catalogue order. The same list is `capabilities.errorCodes` and the
`ErrorCode` enum of `/openapi/agent-v1.json`.

## How errors arrive

- **As the whole response** — every non-2xx answer the API sends, except the `422` of a submission:

  ```json
  { "error": { "code": "…", "message": "…", "agent_instruction": "…", "retryable": true } }
  ```

  `agent_instruction` and `retryable` are omitted when the code has none.
- **Not from the API** — a request the hosting platform cuts off (for example at its CPU limit, Cloudflare error 1102)
  can end in a `5xx` that is not JSON: an HTML page with no envelope, no code and no `Retry-After`. Treat it like a
  lost response: wait about 30 seconds, read `GET /jobs/{jobId}`, and resend the same bytes once if
  `latestSubmission` does not show the upload.
- **As findings of a submission** — `submission.validation.errors[]` (the submission is `failed_validation`, HTTP
  `422`) and `submission.validation.warnings[]` (what the engine noticed or changed — on a passing design, HTTP `200`, and
  next to the errors of a failed one, HTTP `422`, where they often explain the error). Each finding
  is `{ code, severity, message, agent_instruction, locations?, details?, retryable? }`; `locations` are millimetres on
  the framed strip (`x` along the length from the left end, `y` across the width from the top edge, `r` a radius).

**What to do:** follow `agent_instruction` — it is written for you, with the live numbers filled in. Errors are the
to-do list: fix every one in a new drawing and submit it to the same job. Warnings did not stop the design: look at
`preview.svg` and decide. `retryable: true` means wait `Retry-After` seconds, then do what its instruction says —
usually resend the **same** request (the same bytes to the same job); for `SUBMISSION_IN_PROGRESS`, read the running
submission instead of uploading again. Anything without it is not fixed by retrying.

Below, messages are shown as templates (`{n}` and the like are filled per response) and instructions are rendered with
today's defaults; in a live response `<no>`-style placeholders carry real values. `Retry-After` defaults:
60 s for `RATE_LIMITED` (per bucket, see its note), 10 s for `SUBMISSION_IN_PROGRESS`,
30 s for the technical codes.

## Summary

| Code | Severity | Arrives as | Retryable | Retry-After (s) | Source |
| --- | --- | --- | --- | --- | --- |
| [`INVALID_FILE`](#invalid_file) | error | 400 envelope | — | — | worker |
| [`UNSUPPORTED_FORMAT`](#unsupported_format) | error | 415 envelope | — | — | worker |
| [`FILE_TOO_LARGE`](#file_too_large) | error | 413 envelope | — | — | worker |
| [`IMAGE_TOO_SMALL`](#image_too_small) | error | 400 envelope | — | — | worker |
| [`IMAGE_TOO_LARGE`](#image_too_large) | error | 400 envelope | — | — | worker |
| [`NO_DESIGN_FOUND`](#no_design_found) | error | 422 finding | — | — | box |
| [`NOT_TWO_TONE`](#not_two_tone) | warning | 200/422 warning | — | — | box |
| [`DESIGN_TOUCHES_EDGE`](#design_touches_edge) | warning | 200/422 warning | — | — | box |
| [`DISCONNECTED_REGIONS`](#disconnected_regions) | warning | 200/422 warning | — | — | box |
| [`FEATURES_TOO_FINE`](#features_too_fine) | error | 422 finding | — | — | box |
| [`TRACE_REJECTED`](#trace_rejected) | error | 422 finding | — | — | box |
| [`MATERIAL_DISCONNECTED`](#material_disconnected) | error | 422 finding | — | — | validation |
| [`TRAPPED_ISLAND`](#trapped_island) | error | 422 finding | — | — | validation |
| [`NECK_TOO_THIN`](#neck_too_thin) | error | 422 finding | — | — | validation |
| [`OPENING_TOO_SMALL`](#opening_too_small) | error | 422 finding | — | — | validation |
| [`NECK_CHECK_SKIPPED`](#neck_check_skipped) | warning | 200/422 warning | — | — | validation |
| [`PROPORTION_MISMATCH`](#proportion_mismatch) | warning | 200/422 warning | — | — | frame |
| [`ISLAND_BRIDGED`](#island_bridged) | warning | 200/422 warning | — | — | frame |
| [`ISLAND_REMOVED`](#island_removed) | warning | 200/422 warning | — | — | frame |
| [`OPENING_REMOVED`](#opening_removed) | warning | 200/422 warning | — | — | box |
| [`SPURS_SHAVED`](#spurs_shaved) | warning | 200/422 warning | — | — | frame |
| [`NECK_THICKENED`](#neck_thickened) | warning | 200/422 warning | — | — | frame |
| [`LENGTH_OUT_OF_RANGE`](#length_out_of_range) | error | 400 envelope | — | — | validation |
| [`WIDTH_OUT_OF_RANGE`](#width_out_of_range) | error | 400 envelope | — | — | validation |
| [`JOB_LOCKED`](#job_locked) | error | 409 envelope | — | — | state |
| [`JOB_EXPIRED`](#job_expired) | error | 409 envelope | — | — | state |
| [`JOB_CANCELLED`](#job_cancelled) | error | 409 envelope | — | — | state |
| [`TOO_MANY_SUBMISSIONS`](#too_many_submissions) | error | 409 envelope | — | — | state |
| [`IDEMPOTENCY_CONFLICT`](#idempotency_conflict) | error | 409 envelope | — | — | state |
| [`ALREADY_APPROVED`](#already_approved) | error | 409 envelope | — | — | state |
| [`SUBMISSION_IN_PROGRESS`](#submission_in_progress) | error | 409 envelope | true | 10 | state |
| [`SUBMISSION_SUPERSEDED`](#submission_superseded) | error | 409 envelope | — | — | state |
| [`VECTORIZER_UNAVAILABLE`](#vectorizer_unavailable) | error | 502 envelope | true | 30 | infra |
| [`PROCESSING_FAILED`](#processing_failed) | error | 500 envelope | true | 30 | infra |
| [`PROCESSING_INTERRUPTED`](#processing_interrupted) | error | stored finding | true | — | infra |
| [`RATE_LIMITED`](#rate_limited) | error | 429 envelope | true | 60 | rate |
| [`INVALID_REQUEST`](#invalid_request) | error | 400 envelope | — | — | transport |
| [`METHOD_NOT_ALLOWED`](#method_not_allowed) | error | 405 envelope | — | — | transport |
| [`NOT_FOUND`](#not_found) | error | 404 envelope | — | — | transport |
| [`ACCOUNT_REQUIRED`](#account_required) | error | 401 envelope | — | — | transport |
| [`AUTH_UNAVAILABLE`](#auth_unavailable) | error | 503 envelope | true | 30 | transport |

## The upload (checked before the bytes leave Aperta's edge worker) — `worker`

### INVALID_FILE

severity **error** · the whole response, HTTP **400**

- **Message:** `The file is not a valid PNG.`
- **Instruction:** Send a well-formed PNG file: the 8-byte PNG signature followed by an IHDR chunk. Re-export the image from your image model or converter as PNG; do not rename a JPEG and do not send a data URL.

Usually the envelope of `POST …/submissions`; also a finding when the tracer itself cannot open the bytes.

### UNSUPPORTED_FORMAT

severity **error** · the whole response, HTTP **415**

- **Message:** `Only PNG is accepted.`
- **Instruction:** Send the image as image/png: either a raw body with Content-Type: image/png, or multipart/form-data with an `image` field. Do not send JPEG or WebP; lossy ringing corrupts the black/white boundary the tracer relies on.

### FILE_TOO_LARGE

severity **error** · the whole response, HTTP **413**

- **Message:** `The file exceeds {maxMb} MB.`
- **Instruction:** Re-export the PNG under 6 MB. A flat black-on-white 1536x1024 8-bit PNG is about 200 KB: drop the alpha channel and 16-bit depth, and do not embed colour profiles.

Checked on `Content-Length`, while reading the body (the read stops at the limit), and on a multipart file.

### IMAGE_TOO_SMALL

severity **error** · the whole response, HTTP **400**

- **Message:** `The image is too small to trace.`
- **Instruction:** Export at least 256 px on the short side; 1536x1024 landscape is recommended.

About the canvas (the PNG header). A piece that is too thin *inside* a valid canvas is `PROPORTION_MISMATCH`.

### IMAGE_TOO_LARGE

severity **error** · the whole response, HTTP **400**

- **Message:** `The image is too large.`
- **Instruction:** Keep the long side at or below 8192 px and the total at or below 24 megapixels; 1536x1024 is plenty.

About the canvas (the PNG header). A piece that is too tall *inside* a valid canvas is `PROPORTION_MISMATCH`.

## The tracer's reading of the image — `box`

### NO_DESIGN_FOUND

severity **error** · a finding in `validation.errors` — the submission is `failed_validation`, HTTP **422**

- **Message:** `No design separates from the background.`
- **Instruction:** Draw the piece as solid black (#000000) on pure white (#FFFFFF), with the black region clearly inside the canvas. An all-white, all-black or full-bleed image cannot be traced.

### NOT_TWO_TONE

severity **warning** · a finding in `validation.warnings` — of a passing submission (HTTP **200**), or next to the errors of a failed one (HTTP **422**)

- **Message:** `The image contains grey or gradient pixels.`
- **Instruction:** Export flat black and white only: no shading, gradients, shadows, anti-aliased grey or transparency. Threshold the image at 50% before submitting.

A warning when 5% or more of the ink pixels are mid-grey.

### DESIGN_TOUCHES_EDGE

severity **warning** · a finding in `validation.warnings` — of a passing submission (HTTP **200**), or next to the errors of a failed one (HTTP **422**)

- **Message:** `The design touches the image border.`
- **Instruction:** Leave at least 5% white margin on all four sides. Pixels on the border are read as background, so anything touching it may be cut off or inverted.

A warning when 50% or more of one border is covered.

### DISCONNECTED_REGIONS

severity **warning** · a finding in `validation.warnings` — of a passing submission (HTTP **200**), or next to the errors of a failed one (HTTP **422**)

- **Message:** `The design has more than one separate black region.`
- **Instruction:** Connect every black region to the main body with a bridge of metal, or remove the stragglers. Only the largest region is traced; the rest vanish.

A warning when the regions other than the largest hold 0.5% or more of the ink; they were dropped.

### FEATURES_TOO_FINE

severity **error** · a finding in `validation.errors` — the submission is `failed_validation`, HTTP **422**

- **Message:** `{count} of {total} openings are finer than the cutter can make.`
- **Instruction:** Enlarge every opening to at least 0.2 mm at its narrowest point, or remove the finest ones. Prefer fewer, bolder openings over many hairline ones.

### TRACE_REJECTED

severity **error** · a finding in `validation.errors` — the submission is `failed_validation`, HTTP **422**

- **Message:** `The design could not be traced into a manufacturable shape.`
- **Instruction:** Simplify the drawing: fewer and larger openings, clean edges, no hairlines, no texture, no dotted or dashed strokes. Then regenerate and resubmit.

### OPENING_REMOVED

severity **warning** · a finding in `validation.warnings` — of a passing submission (HTTP **200**), or next to the errors of a failed one (HTTP **422**)

- **Message:** `The engine filled {n} opening(s) that were too small to cut.`
- **Instruction:** Openings narrower than 0.2 mm, or smaller than 0.04% of the piece's area, are filled before tracing. Enlarge them if they matter to the design.

## Manufacturing and size checks — `validation`

### MATERIAL_DISCONNECTED

severity **error** · a finding in `validation.errors` — the submission is `failed_validation`, HTTP **422**

- **Message:** `The metal splits into {n} disconnected pieces.`
- **Instruction:** Join every piece of metal to the main body with a bridge at least 0.6 mm wide at the marked spots. The whole piece must be one connected body.

### TRAPPED_ISLAND

severity **error** · a finding in `validation.errors` — the submission is `failed_validation`, HTTP **422**

- **Message:** `{n} metal island(s) are fully surrounded by cutouts.`
- **Instruction:** Connect each marked island to the surrounding metal with a bridge at least 0.6 mm wide, or remove the island. Metal enclosed by a cutout falls out when cut.

### NECK_TOO_THIN

severity **error** · a finding in `validation.errors` — the submission is `failed_validation`, HTTP **422**

- **Message:** `The metal is narrower than {minMetalMm} mm at {n} spot(s).`
- **Instruction:** Widen the metal at the marked spots to at least 0.6 mm. Nothing that holds the piece together may be narrower than that.

### OPENING_TOO_SMALL

severity **error** · a finding in `validation.errors` — the submission is `failed_validation`, HTTP **422**

- **Message:** `{n} opening(s) are narrower than {minOpeningMm} mm.`
- **Instruction:** Widen each marked opening to at least 0.2 mm at its narrowest point, or remove it. The cutter cannot open anything finer.

### NECK_CHECK_SKIPPED

severity **warning** · a finding in `validation.warnings` — of a passing submission (HTTP **200**), or next to the errors of a failed one (HTTP **422**)

- **Message:** `The neck-width check was skipped: the design is too complex to measure.`
- **Instruction:** Verify yourself that no metal is narrower than 0.6 mm, or simplify the design so the check can run: smoother outlines and fewer, larger openings.

### LENGTH_OUT_OF_RANGE

severity **error** · the whole response, HTTP **400**

- **Message:** `Blank length {lengthMm} mm is outside {lengthMin}–{lengthMax} mm for a {product}.`
- **Instruction:** Check the measurement: wristMm is the wrist circumference in millimetres (not centimetres), usRingSize is a US ring size, lengthMm is the flat blank length. Send a value whose blank falls between 70 and 280 mm.

The envelope of `POST /jobs` (and of the approval, if the job's size is no longer buildable).

### WIDTH_OUT_OF_RANGE

severity **error** · the whole response, HTTP **400**

- **Message:** `Width {widthMm} mm is outside {widthMin}–{widthMax} mm for a {product}.`
- **Instruction:** Send widthMm between 5 and 80 mm. widthMm is required: to let the width follow your drawing, compute it yourself as the blank length divided by the drawn length-to-width ratio (capabilities lists defaultWidthMm per product) and send that number.

The envelope of `POST /jobs`: `widthMm` outside the product's range, or narrower than the blank length ÷ 46.15 (`image.cropAspect.max` — a thinner piece cannot be traced); the message then names the minimum for that length.

## What framing changed — `frame`

### PROPORTION_MISMATCH

severity **warning** · a finding in `validation.warnings` — of a passing submission (HTTP **200**), or next to the errors of a failed one (HTTP **422**)

- **Message:** `The piece is drawn at ratio {drawnRatio}; the job expects {expectedRatio}.`
- **Instruction:** Redraw the piece at a length-to-width ratio of the job's ratio: landscape, longer than wide, with the piece itself between 0.439:1 and 46.15:1. As a warning (stretch within ×1.5 of 1) you may instead accept the horizontal stretch reported in geometry.stretch; as an error the stretch would distort the design and the submission is rejected.

Two severities. A warning when the drawn ratio is more than 5% off the job's and the pattern was stretched along the length (see `geometry.stretch`); an error when the stretch would reach ×1.5 (or ×1/1.5), or when the piece itself is taller than 1:2.28 or thinner than 46:1.

### ISLAND_BRIDGED

severity **warning** · a finding in `validation.warnings` — of a passing submission (HTTP **200**), or next to the errors of a failed one (HTTP **422**)

- **Message:** `The engine bridged {n} detached island(s) to the nearest metal.`
- **Instruction:** Check the marked spots in preview.svg. If a bridge spoils the design, redraw with that island connected where you want the bridge to be.

### ISLAND_REMOVED

severity **warning** · a finding in `validation.warnings` — of a passing submission (HTTP **200**), or next to the errors of a failed one (HTTP **422**)

- **Message:** `The engine removed {n} detached island(s) that could not be bridged.`
- **Instruction:** If the missing pieces matter, redraw with each island connected to the body by a bridge at least 0.6 mm wide.

### SPURS_SHAVED

severity **warning** · a finding in `validation.warnings` — of a passing submission (HTTP **200**), or next to the errors of a failed one (HTTP **422**)

- **Message:** `The engine shaved {n} hairline spur(s) off the outline.`
- **Instruction:** Draw clean edges without hairline protrusions. If a shaved spot mattered, thicken it to at least 0.6 mm.

### NECK_THICKENED

severity **warning** · a finding in `validation.warnings` — of a passing submission (HTTP **200**), or next to the errors of a failed one (HTTP **422**)

- **Message:** `The engine widened a neck from {fromMm} mm to {toMm} mm.`
- **Instruction:** Necks thinner than 0.6 mm are widened to that floor. If the added metal spoils the design, draw the neck at least 0.6 mm wide yourself.

## Job state — `state`

### JOB_LOCKED

severity **error** · the whole response, HTTP **409**

- **Message:** `The job is approved and locked.`
- **Instruction:** Do not resubmit to this job: the person already approved a design and it now lives in their account. Open a new job for a new design.

### JOB_EXPIRED

severity **error** · the whole response, HTTP **409**

- **Message:** `The job has expired.`
- **Instruction:** Open a new job and resubmit the image. Unapproved jobs expire 7 days after they were created (submissions do not extend this), or 24 hours after the person declined, whichever comes first.

### JOB_CANCELLED

severity **error** · the whole response, HTTP **409**

- **Message:** `The job was cancelled.`
- **Instruction:** Open a new job: this one was cancelled with DELETE and accepts no submissions. Nothing submitted to it can be recovered: the cancel deleted its submissions and images, and GET with its secret only shows status cancelled.

### TOO_MANY_SUBMISSIONS

severity **error** · the whole response, HTTP **409**

- **Message:** `This job used all {submissionsPerJob} submissions.`
- **Instruction:** Open a new job. Before submitting again, fix every error from the last response instead of retrying the same image.

### IDEMPOTENCY_CONFLICT

severity **error** · the whole response, HTTP **409**

- **Message:** `This Idempotency-Key was already used with a different request body.`
- **Instruction:** Use a fresh Idempotency-Key for a new request, or resend exactly the same body to replay the original response.

### ALREADY_APPROVED

severity **error** · the whole response, HTTP **409**

- **Message:** `The design was already approved by another account.`
- **Instruction:** Nothing to do: the design belongs to the person who approved it. If this is the same person, ask them to sign in with the account they used the first time.

Answered to the approval page, not to agents.

### SUBMISSION_IN_PROGRESS

severity **error** · the whole response, HTTP **409** · retryable, `Retry-After` 10 s

- **Message:** `The same image is still being processed in this job.`
- **Instruction:** Do not resubmit these bytes: an earlier upload of the same image is still running as submission <no>. Wait 10 seconds, then read GET /api/agent/v1/jobs/<jobId>/submissions/<no> (the number in this message) until its status leaves processing; that response is this image's result. The running attempt counts once, not twice.

The message names the running submission's number; read that submission instead of uploading again.

### SUBMISSION_SUPERSEDED

severity **error** · the whole response, HTTP **409**

- **Message:** `The submission on the approval page is no longer the job's current design.`
- **Instruction:** This answer goes to the approval page, not to the agent: a newer passing submission replaced the design the person was looking at. Reload the page so the person sees and decides on the current design; nothing was approved.

Answered to the approval page, not to agents.

## Infrastructure (technical — not your fault, not counted) — `infra`

### VECTORIZER_UNAVAILABLE

severity **error** · the whole response, HTTP **502** · retryable, `Retry-After` 30 s

- **Message:** `The tracing service is unavailable.`
- **Instruction:** Wait 30 seconds and resubmit the same bytes to the same job. Attempts that fail this way do not count against the job's submissions.

The tracer was unreachable or timed out. The attempt is stored as a `failed_processing` submission carrying this code and is not counted.

### PROCESSING_FAILED

severity **error** · the whole response, HTTP **500** · retryable, `Retry-After` 30 s

- **Message:** `Processing failed on our side.`
- **Instruction:** Retry once with the same bytes after 30 seconds. If it fails again, re-export the PNG (flat black on white, 8-bit, no alpha) and submit that; do not loop.

HTTP 502 when a submission's processing failed (retry once with the same bytes; the attempt is stored as a `failed_processing` submission and not counted); HTTP 500 for an unexpected failure on any route, with a generic instruction (retry the same request once, then stop).

### PROCESSING_INTERRUPTED

severity **error** · a finding in `validation.errors` of a `failed_processing` submission, read with `GET` · retryable

- **Message:** `The previous submission was interrupted before it finished.`
- **Instruction:** Resubmit the same bytes to the same job; the interrupted attempt is not counted and the image is processed again from scratch. Only an image that already reached ready or failed_validation is answered from its stored result instead of being processed again.

Never a response of its own: the stored outcome of an upload whose processing was cut off, shown by `GET …/submissions/{no}` and `GET /jobs/{jobId}` once it has been `processing` for 3 minutes. Resending the same bytes processes them again and is not counted.

## Rate limits — `rate`

### RATE_LIMITED

severity **error** · the whole response, HTTP **429** · retryable, `Retry-After` 60 s

- **Message:** `Too many requests: {window} was reached.`
- **Instruction:** Wait 60 seconds (the Retry-After header carries the same number), then retry. Space submissions out and never resubmit an unchanged image.

`Retry-After` is the blocking bucket's window: 60 for the per-minute bucket, 3600 for an hourly one, the seconds left until midnight UTC for a daily one — or 30 when the limiter itself was unavailable (the message says so).

## Transport and access — `transport`

### INVALID_REQUEST

severity **error** · the whole response, HTTP **400**

- **Message:** `The request is malformed.`
- **Instruction:** Fix the request to match the OpenAPI document: a JSON body with the documented fields and types, or a PNG upload with the documented content type. The message names the offending field.

### METHOD_NOT_ALLOWED

severity **error** · the whole response, HTTP **405**

- **Message:** `The HTTP method is not allowed on this path; allowed: {allow}.`
- **Instruction:** Use one of the methods in the Allow header (see the Allow header). The OpenAPI document lists the method for every path; do not retry with the same method.

Comes with an `Allow` header listing the supported methods (`HEAD` wherever `GET` is).

### NOT_FOUND

severity **error** · the whole response, HTTP **404**

- **Message:** `No such job.`
- **Instruction:** Check the job id and the Authorization: Bearer secret from the job's creation response. Unknown ids and wrong secrets are indistinguishable by design; if the secret is lost, open a new job.

An unknown job id and a wrong secret are the same answer on purpose. After the secret is verified, a missing submission, a file the submission does not have, or a path that is not an endpoint each carry their own message and instruction.

### ACCOUNT_REQUIRED

severity **error** · the whole response, HTTP **401**

- **Message:** `A signed-in person is required.`
- **Instruction:** This endpoint is for the person, not the agent: send them the approval URL and let them sign in on Aperta.

Answered to the approval page, not to agents.

### AUTH_UNAVAILABLE

severity **error** · the whole response, HTTP **503** · retryable, `Retry-After` 30 s

- **Message:** `The sign-in service is temporarily unavailable.`
- **Instruction:** Wait 30 seconds and retry the same request.

Answered to the approval page, not to agents.
