# Otto Report — Public API

The agent-facing HTTP API. Record a report once in the browser extension; every
recorded **recipe** becomes a programmable endpoint you can list, trigger, and
read back as structured JSON plus downloaded files.

Looking for task-by-task guides instead of a reference? Read the agent skills at
**[/.well-known/skills](https://ottohunt.com/.well-known/skills)**.

---

## Base URL

```
https://standing-chihuahua-31.eu-west-1.convex.site
```

The HTTP API is served from this fixed host (the same for every tenant — your
API key, not the URL, scopes you to your data). Mint an API key at
<https://ottohunt.com/settings>.

## Authentication

Every request carries a Bearer token:

```
Authorization: Bearer otto_sk_<secret>
```

- Keys are shown **once** at creation and stored only as a SHA-256 hash.
- Every read and write is **owner-scoped**: a key can only ever touch its own
  tenant's recipes, runs, and artifacts.
- Missing/blank header → `401`. Invalid or revoked key → `401`.

## Conventions

- JSON in, JSON out. `Content-Type: application/json` for request bodies.
- Timestamps are ISO-8601 UTC strings.
- Errors are `{ "error": "<message>" }` with a `4xx` status.
- IDs are opaque strings; pass them back verbatim.

---

## Endpoints

| Method | Path | Purpose |
|--------|------|---------|
| `GET`  | `/v1/recipes` | List the owner's recipes. |
| `GET`  | `/v1/recipes/{recipeId}` | One recipe's summary + ordered steps. |
| `GET`  | `/v1/recipes/{recipeId}/runs` | Runs of a recipe (newest first). |
| `POST` | `/v1/recipes/{recipeId}/runs` | Trigger a run → `{ run_id, status }` (`202`). |
| `GET`  | `/v1/runs/{runId}` | A finished run's structured `data` + artifact URLs. |

---

### `GET /v1/recipes`

Lists every recipe available to the key.

```bash
curl -s "$BASE/v1/recipes" -H "Authorization: Bearer $KEY"
```

```json
{
  "recipes": [
    {
      "id": "k17...",
      "name": "Monthly revenue",
      "goal": "Last month's revenue PDF from the ERP",
      "cadence": "monthly",
      "status": "active",
      "tags": ["finance", "erp"],
      "params": [
        { "name": "period", "label": "Period", "type": "date",
          "default": "lastMonth", "options": [], "description": "Reporting month" }
      ]
    }
  ]
}
```

`goal` is the human-written, agent-facing description — use it to match a
natural-language request to the right recipe (there is no separate search
endpoint).

---

### `GET /v1/recipes/{recipeId}`

One recipe's summary plus its ordered steps.

```json
{
  "recipe": { "id": "k17...", "name": "Monthly revenue", "goal": "...",
              "cadence": "monthly", "status": "active", "tags": ["finance"],
              "params": [ { "name": "period", "type": "date", "default": "lastMonth" } ] },
  "steps": [
    { "order": 0, "type": "navigate", "goal": "Open the ERP reports page" },
    { "order": 1, "type": "select",   "goal": "Choose the reporting period" },
    { "order": 2, "type": "click",    "goal": "Export to PDF" },
    { "order": 3, "type": "extract",  "goal": "Total revenue" }
  ]
}
```

`extract` steps define the keys of a run's `data` (snake_cased from the goal:
`"Total revenue"` → `data.total_revenue`).

**`404`** if the recipe is unknown or belongs to another tenant.

---

### `GET /v1/recipes/{recipeId}/runs`

All runs of the recipe, newest first.

```json
{
  "runs": [
    { "id": "j92...", "status": "success", "triggered_by": "api",
      "started_at": "2026-06-22T09:00:00.000Z",
      "finished_at": "2026-06-22T09:00:42.000Z" }
  ]
}
```

---

### `POST /v1/recipes/{recipeId}/runs`

Triggers a run. Returns `202 Accepted` immediately; the run completes
asynchronously — poll `GET /v1/runs/{runId}` for the result.

```bash
# Defaults (e.g. a date param resolves to "last month")
curl -s -X POST "$BASE/v1/recipes/$RECIPE_ID/runs" \
  -H "Authorization: Bearer $KEY"

# With parameters
curl -s -X POST "$BASE/v1/recipes/$RECIPE_ID/runs" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "params": { "period": "2026-04" } }'
```

```json
{ "run_id": "j92...", "status": "running" }
```

**Request body** (optional): `{ "params": { "<name>": "<value>", ... } }`. Every
param is optional — omitted params use the recipe's default. A `date` default
may be a relative expression (`lastMonth`, `today`, …) resolved at run time. A
bare `POST` with no body runs entirely on defaults. **`404`** if the recipe is
unknown.

---

### `GET /v1/runs/{runId}`

The interesting part of a run: structured field values and downloaded files.

```json
{
  "run_id": "j92...",
  "recipe": { "id": "k17...", "name": "Monthly revenue" },
  "status": "success",
  "started_at": "2026-06-22T09:00:00.000Z",
  "finished_at": "2026-06-22T09:00:42.000Z",
  "data": { "total_revenue": "1284200.00", "currency": "USD" },
  "artifacts": [
    { "label": "revenue-2026-05.pdf", "mime": "application/pdf",
      "size": 81234, "url": "https://<bucket>.r2.../signed?...(≈15 min expiry)" }
  ]
}
```

- **`status`**: `running` → still replaying; `success` → done; `failed` → could
  not complete.
- **`data`**: flat field → value map from `extract` steps; `{}` if none.
- **`artifacts`**: each `url` is a short-lived signed link (~15 min). Download
  promptly; re-fetch the run for a fresh URL if it expires. The download needs no
  auth header (the signature is in the URL).

**`404`** if the run is unknown or belongs to another tenant.

---

## Full example

```bash
BASE="https://standing-chihuahua-31.eu-west-1.convex.site"
KEY="otto_sk_..."   # from https://ottohunt.com/settings

# 1. Pick a recipe by its goal.
curl -s "$BASE/v1/recipes" -H "Authorization: Bearer $KEY"

# 2. Trigger it.
RUN=$(curl -s -X POST "$BASE/v1/recipes/$RECIPE_ID/runs" \
  -H "Authorization: Bearer $KEY")
RUN_ID=$(echo "$RUN" | python3 -c 'import sys,json;print(json.load(sys.stdin)["run_id"])')

# 3. Poll until terminal, then read data + artifacts.
curl -s "$BASE/v1/runs/$RUN_ID" -H "Authorization: Bearer $KEY"
```

## Status & error codes

| Code | Meaning |
|------|---------|
| `200` | OK (GET). |
| `202` | Run accepted and queued (POST trigger). |
| `401` | Missing, malformed, invalid, or revoked API key. |
| `404` | Recipe or run not found (or not yours). |
