# Async jobs

No expensive call is synchronous. Uploading + parsing a plan, rendering a branded
PDF, generating a visualization, and generating a tour all return a **Job handle**
immediately — you then poll the job or receive a webhook. (A Marble tour can take
~10 minutes; nothing blocks on it.)

## The Job object

```json
{
  "object": "job",
  "id": "job_gj_1270…",
  "type": "plan.parse | plan.render | visualization | tour | panorama | object_mesh",
  "status": "queued | running | succeeded | failed | canceled",
  "progress": 0.42,
  "result": {
    "assets": [
      { "id": "asset_a1c2…", "kind": "jpg", "url": "https://…r2…", "expires_at": "2026-06-18T10:05:00Z" }
    ]
  },
  "cost_usd_cents": 51,
  "provider": "gemini",
  "seed": "1270677270",
  "error": null,
  "metadata": { "agent_run": "abc" },
  "created_at": "…", "updated_at": "…",
  "urls": { "self": "/api/v1/jobs/job_gj_1270…", "cancel": null }
}
```

Notes:

- **`status`** is a canonical string — pattern-match it, don't infer from HTTP codes.
- **`progress`** is an optional float 0..1, best-effort, nullable (null where unknown,
  e.g. plan parse).
- **`result`** is null until the job succeeds; then `result.assets[]` holds the
  signed, expiring asset URLs. Every `expires_at` has a hard TTL (≤ 300s) — if a URL
  is past it, re-sign via `GET /api/v1/assets/{id}/url`.
- **`cost_usd_cents`**, **`provider`**, and **`seed`** are surfaced on the job (null
  where not applicable).
- **`error`** is null unless the job failed, then it is `{ code, message }`.
- **`urls{}`** is self-describing — never template URLs by hand. `cancel` is reserved
  (null today; no cancel path yet).

## Status mapping

| Internal source | Public `status` |
|-----------------|-----------------|
| `queued` | `queued` |
| `running` / `parsing` | `running` |
| `done` / `ready` | `succeeded` |
| `failed` | `failed` |

## Two completion mechanisms

1. **Polling** — `GET /api/v1/jobs/{id}`. Optional fast-path header
   `Prefer: wait=<seconds≤30>` holds the connection until the job is terminal or the
   budget elapses, then returns the current state — saves a tight poll loop for short
   jobs.
2. **Signed webhooks** — subscribe to `job.succeeded` / `job.failed` (see
   /docs/api/webhooks).

## The canonical agent loop

```
1. POST the work with an Idempotency-Key   -> 202 { Job: status=queued, id }
2. EITHER poll GET /api/v1/jobs/{id} (respect Retry-After / pollInterval)
   OR      await webhook job.succeeded|job.failed for {id}
3. on succeeded -> read Job.result.assets[].url
                   (re-fetch if past expires_at via GET /api/v1/assets/{id}/url)
   on failed    -> read Job.error.{code,message}; act unaided
```

See /docs/api/example for this loop end to end in REST and MCP.
