# 05 — Agent-Facing REST API

The HTTP endpoints LabCore exposes under `/api/lab/*`. These are consumed
by the LabBridge Agent (and the simulator in `--mode=api`). All routes are
defined in [Modules/LabCore/Routes/api.php](../../Modules/LabCore/Routes/api.php)
and handled by [Api/AgentController.php](../../Modules/LabCore/Http/Controllers/Api/AgentController.php).

## Authentication

Every request carries:

```
Authorization: Bearer <api_token>
```

Where `api_token` is the agent's `lab_agents.api_token`, issued in **Lab →
Agents → Add** (or rotated via the key icon).

Invalid / missing token → `401 {"error":"invalid_agent_token"}`.

## Content type

All requests + responses are JSON unless noted. Send:

```
Accept: application/json
Content-Type: application/json
```

## Base URL

`{cloud_url}/api/lab/...` where `cloud_url` is the cloud ERP's public base
(e.g. `https://erp.example.com`).

---

## `POST /api/lab/agent/register`

Called once by the agent on startup. Returns the device list the agent
should configure listeners for.

**Request:**

```json
{
  "agent_version": "1.0.0"
}
```

**Response 200:**

```json
{
  "agent": {
    "id": 4,
    "name": "Main Clinic",
    "code": "main-clinic"
  },
  "devices": [
    {
      "id": 5,
      "vendor": "abaxis",
      "model": "VS2",
      "name": "VetScan VS2 - Bench 1",
      "connection_type": "tcp",
      "comm_protocol": "astm-e1394",
      "host": "192.168.1.50",
      "port": 9600,
      "com_port": null,
      "baud": null,
      "parity": null,
      "data_bits": null,
      "stop_bits": null,
      "status": "offline",
      "settings": null
    }
  ]
}
```

Side effects: stamps `lab_agents.last_heartbeat_at`, `last_ip`, and writes
an `info` row to `lab_communication_logs`.

---

## `POST /api/lab/agent/heartbeat`

Periodic ping. Reports the agent's current queue depth (so the dashboard
can show it) and bumps `last_heartbeat_at`.

**Request:**

```json
{
  "agent_version": "1.0.0",
  "queue_depth": 3
}
```

**Response 200:**

```json
{
  "ok": true,
  "server_time": "2026-05-23T10:30:00+00:00"
}
```

Recommended cadence: every `LABCORE_HEARTBEAT_TIMEOUT / 2` seconds
(default = every 60s for the 120s timeout). The cloud's **Connectivity**
column flips to *Offline* if no heartbeat lands within
`LABCORE_HEARTBEAT_TIMEOUT`.

---

## `POST /api/lab/results`

The main payload: one raw, transport-stripped ASTM/HL7 message ready for
the cloud to parse.

**Request:**

```json
{
  "uuid": "9f7306c0-12e5-4fad-b5ea-b85de7af59ef",
  "vendor": "abaxis",
  "device_id": 5,
  "raw_message": "H|\\^&|||VetScan VS2^1.0|||||||P|1|20260522103000\rP|1|SIM12345|||Buddy^Rex||20200101|M|Canine\rO|1|SIM12345||^^^CHEM|R|20260522103000\rR|1|^^^GLU|95.2|mg/dL|70^143|N||F||20260522103000\rL|1|N",
  "received_at": "2026-05-23T10:30:00+00:00"
}
```

| Field | Required | Notes |
|---|---|---|
| `uuid` | yes | Agent-generated UUID v4. **The idempotency key** — replays with the same UUID are detected and not duplicated. |
| `vendor` | yes | Matches a registered driver (`abaxis`, `nx600`, …). |
| `device_id` | no | The cloud `lab_devices.id`. Lets the result inherit the device's business / agent context. |
| `raw_message` | yes | Assembled record text. For ASTM: records separated by `\r` (CR), transport framing (STX/ETX/checksum) already stripped. |
| `received_at` | no | ISO-8601 timestamp when the agent received it; defaults to "now". |

**Response 200 — stored:**

```json
{
  "status": "stored",
  "session_id": 4,
  "results": 8
}
```

**Response 200 — duplicate (same UUID seen before, idempotent):**

```json
{
  "status": "duplicate",
  "session_id": 4
}
```

**Response 200 — accepted but not a result payload (e.g. a worklist query):**

```json
{
  "status": "not_a_result",
  "reason": "Message is not a parseable result payload"
}
```

**Response 200 — driver not registered for `vendor`:**

```json
{
  "status": "no_driver",
  "reason": "No driver registered for vendor 'foo'"
}
```

**Response 422 — ingestion threw:**

```json
{
  "status": "error",
  "reason": "DB error or driver crash text"
}
```

Either way, the cloud always writes the raw message into
`lab_communication_logs` for forensic replay.

---

## `POST /api/lab/events`

Batched log + error events. Used by the agent to flush its event log,
including events accumulated while it was offline.

**Request:**

```json
{
  "events": [
    {
      "type": "log",
      "level": "info",
      "category": "connection",
      "vendor": "abaxis",
      "device_id": 5,
      "device_ip": "192.168.1.50",
      "message": "Analyzer connected"
    },
    {
      "type": "error",
      "vendor": "abaxis",
      "device_id": 5,
      "error_code": "E07",
      "error_date": "2026-05-23",
      "error_time": "10:25:00",
      "message": "Slide lot expired",
      "added_info": ["batch 220", "slide ALB"],
      "device_ip": "192.168.1.50"
    }
  ]
}
```

Each event item:

| Field | Notes |
|---|---|
| `type` | `"log"` → written to `lab_communication_logs`; `"error"` → written to `lab_device_errors`. |
| `level` | For logs: `info` / `success` / `warning` / `error`. |
| `category` | For logs: `connection` / `worklist` / `result` / `error` / `sync` / `system`. |
| `error_code` / `error_date` / `error_time` / `added_info` | For errors only. |
| `vendor`, `device_id`, `device_ip`, `sample_no`, `message`, `context` | Common metadata. |

**Response 200:**

```json
{
  "status": "ok",
  "stored": 2
}
```

---

## `GET /api/lab/worklist`

Pending orders for this agent's devices.

**Request:** no body.

**Response 200:**

```json
{
  "worklist": [
    {
      "id": 12,
      "device_id": 5,
      "vendor": "abaxis",
      "sample_no": "WL00042",
      "patient_id": "PET042",
      "patient_name": "Buddy",
      "species": "Canine",
      "species_code": null,
      "sex": "M",
      "age": 5,
      "tests": ["GLU", "BUN", "CRE"]
    }
  ]
}
```

The agent caches this in `worklist_cache` (SQLite) so it can serve
bidirectional analyzers when they query — even when offline.

---

## `POST /api/lab/sync/manifest`

Safety net for the rare case where a `POST /api/lab/results` returned 200
but the row was then lost (DB rollback, mid-request crash). The agent sends
the UUIDs it thinks are synced; the cloud reports which are actually
missing so the agent can re-push them.

**Request:**

```json
{
  "uuids": [
    "9f7306c0-12e5-4fad-b5ea-b85de7af59ef",
    "40ae7d0e-274e-4bf5-a394-9fe8206bfd87"
  ]
}
```

**Response 200:**

```json
{
  "received": 2,
  "missing":  ["40ae7d0e-274e-4bf5-a394-9fe8206bfd87"]
}
```

The agent flips any UUIDs in `missing` back to `status='pending'` in its
local queue; the next sweep re-pushes them.

---

## Calling the API from outside the agent

You can use these endpoints to integrate other systems — a PMS, a script,
another analyzer brand without a dedicated module. Treat the cloud as a
generic results sink:

```bash
TOKEN="..."
URL="https://erp.example.com/api/lab/results"

curl -X POST "$URL" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "uuid": "'"$(uuidgen)"'",
    "vendor": "abaxis",
    "raw_message": "...",
    "received_at": "'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"
  }'
```

For a non-ASTM payload format, write a vendor driver
([08 Developer guide](08-developer-guide.md)) so its `parse()` understands
your raw_message.

## Config knobs

`config/labcore.php` and `.env`:

```
LABCORE_HEARTBEAT_TIMEOUT=120   # seconds; agent flipped to Offline after this
LABCORE_MAX_PAYLOAD=262144      # max raw_message size in bytes (default 256KB)
```
