# API reference

Ask questions of your workspace’s documents and get answers cited to the passages they came from. Add documents and follow their indexing from your own code.

## Overview

The base URL is the address of your Lornos console without the path. The examples read it from `LORNOS_URL` and your key from `LORNOS_KEY`.

| Endpoint | What it does | Scope |
|---|---|---|
| `POST /api/chat` | Ask a question; the answer streams back with citations | chat |
| `POST /api/v1/sources` | Create a source and get an upload URL | sources |
| `POST /api/v1/sources/{id}/complete` | Confirm the upload and start indexing | sources |
| `GET /api/v1/sources` | List sources and their status | sources |
| `GET /api/v1/sources/{id}` | One source’s status | sources |
| `POST /api/v1/sources/{id}/reindex` | Index a source again | sources |
| `DELETE /api/v1/sources/{id}` | Delete a source and its passages | sources |

Requests and responses are JSON, except the chat answer, which streams as server-sent events. Call the API from your server: an API key is a secret and must never reach a browser or a mobile app.

## Authentication and scopes

Send a workspace API key as a Bearer token on every request:

```
Authorization: Bearer lrn_…
```

Owners and admins create keys on the API keys page of the console. The full key is shown once; Lornos keeps only a hash. Each key belongs to one workspace, and every request it makes reads and writes that workspace only. No header, body field or parameter can point a key at another workspace.

| Scope | Allows |
|---|---|
| `chat` | Asking questions with `POST /api/chat`. |
| `sources` | Uploading, listing, re-indexing and deleting sources under `/api/v1/sources`. |

A key can hold both scopes. A missing, malformed, revoked or expired key gets `401`. A valid key without the `sources` scope gets `403` from the sources endpoints; a key without the `chat` scope gets `401` from the chat endpoint.

## Ask a question

`POST /api/chat`. Scope: chat. The answer streams back as it is written.

The body is the conversation: `messages`, each with an `id`, a `role` and `parts`, a list of `{ "type": "text", "text": "…" }` objects. The last user message is the question.

```json
{
  "messages": [
    {
      "id": "q1",
      "role": "user",
      "parts": [
        {
          "type": "text",
          "text": "How do I record a sale?"
        }
      ]
    }
  ]
}
```

```sh
curl -N "$LORNOS_URL/api/chat" \
  -H "Authorization: Bearer $LORNOS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"id":"q1","role":"user","parts":[{"type":"text","text":"How do I record a sale?"}]}]}'
```

A `200` response has the content type `text/event-stream`. Each event is a line `data: {json}` followed by a blank line, and the stream ends with `data: [DONE]`. Join the `delta` of every `text-delta` event to get the answer. An `error` event carries `errorText` when the answer couldn’t be finished. Ignore the event types you don’t use.

```
data: {"type":"start"}

data: {"type":"start-step"}

data: {"type":"text-start","id":"0"}

data: {"type":"text-delta","id":"0","delta":"Open Sales and choose New sale "}

data: {"type":"text-delta","id":"0","delta":"[record-a-sale.md#1.0]."}

data: {"type":"text-end","id":"0"}

data: {"type":"finish-step"}

data: {"type":"finish","finishReason":"stop"}

data: [DONE]
```

### Citations

The answer cites passages by chunk ID in square brackets, for example `[record-a-sale.md#1.0]`. The part before `#` names the document; for a document you uploaded it begins with the first eight characters of its source ID. An answer cites at most five distinct passages.

Every cited ID was retrieved for this request, from this workspace. Lornos checks citations as the answer streams: an ID that wasn’t retrieved in this request is removed, and the answer then ends with a note saying how many were removed. When the workspace has no searchable sources, or nothing in them matches the question, you get a short fixed reply with no citations, in the same stream format.

### Follow-up questions

Send the whole conversation again with the new question last, including the earlier answers as `assistant` messages. Each question gets fresh retrieval, and earlier answers are never used as evidence: their citations don’t carry over.

```json
{
  "messages": [
    {
      "id": "q1",
      "role": "user",
      "parts": [
        {
          "type": "text",
          "text": "How do I record a sale?"
        }
      ]
    },
    {
      "id": "a1",
      "role": "assistant",
      "parts": [
        {
          "type": "text",
          "text": "Open Sales and choose New sale. [record-a-sale.md#1.0]"
        }
      ]
    },
    {
      "id": "q2",
      "role": "user",
      "parts": [
        {
          "type": "text",
          "text": "Can I undo it afterwards?"
        }
      ]
    }
  ]
}
```

## Upload a document

An upload takes three requests: create the source, send the file to the URL you get back, then complete it.

1. `POST /api/v1/sources` (scope: sources, returns 201) with `filename` (required, up to 255 characters; its extension sets the file type), `size` (required, bytes, up to 25 MB) and optionally `contentType` (must be the type listed for the extension under Limits).

```json
{
  "id": "6f1c2d3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
  "filename": "returns-policy.pdf",
  "status": "pending_upload",
  "upload": {
    "url": "https://…signed upload URL…",
    "method": "PUT",
    "headers": { "Content-Type": "application/pdf" }
  },
  "expiresAt": "2026-11-02T18:40:00.000Z"
}
```

2. `PUT` the file’s bytes to `upload.url` with exactly the headers in `upload.headers`. The URL works until `expiresAt` (10 minutes).
3. `POST /api/v1/sources/{id}/complete` (scope: sources, returns 202). Lornos checks that the file arrived with the size you declared, then starts indexing in the background. A missing file or a different size gets `409` and the source is marked `failed`.

```sh
# 1. Create the source and get an upload URL
curl "$LORNOS_URL/api/v1/sources" \
  -H "Authorization: Bearer $LORNOS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"filename":"returns-policy.pdf","contentType":"application/pdf","size":482113}'

# 2. PUT the file to upload.url, with exactly the headers in upload.headers
curl -X PUT "<upload.url>" \
  -H "Content-Type: application/pdf" \
  --data-binary @returns-policy.pdf

# 3. Tell Lornos the upload finished; indexing starts
curl -X POST "$LORNOS_URL/api/v1/sources/<id>/complete" \
  -H "Authorization: Bearer $LORNOS_KEY"
```

## Status, re-index and delete

- `GET /api/v1/sources`: every source, newest first, up to 200.
- `GET /api/v1/sources/{id}`: one source.

```json
{
  "id": "6f1c2d3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
  "filename": "returns-policy.pdf",
  "title": "Returns policy",
  "status": "indexed",
  "error": null,
  "passages": 14,
  "embedded": 14,
  "createdAt": "2026-11-02T18:30:00.000Z",
  "indexedAt": "2026-11-02T18:30:21.000Z"
}
```

| status | Meaning |
|---|---|
| `pending_upload` | Created; waiting for the file and the complete call. |
| `uploaded` | The file is stored; indexing is about to start. |
| `indexing` | The file is being read and split into passages. |
| `indexed` | Searchable. Questions can now cite it. |
| `failed` | Indexing or the upload failed; `error` says why. |

`POST /api/v1/sources/{id}/reindex` indexes an uploaded source again (returns 202). `DELETE /api/v1/sources/{id}` removes the stored file and all of its passages at once (returns 204); a deleted source is never cited again.

```sh
# All sources (newest first)
curl "$LORNOS_URL/api/v1/sources" -H "Authorization: Bearer $LORNOS_KEY"

# One source
curl "$LORNOS_URL/api/v1/sources/<id>" -H "Authorization: Bearer $LORNOS_KEY"

# Index again
curl -X POST "$LORNOS_URL/api/v1/sources/<id>/reindex" -H "Authorization: Bearer $LORNOS_KEY"

# Delete the file and its passages
curl -X DELETE "$LORNOS_URL/api/v1/sources/<id>" -H "Authorization: Bearer $LORNOS_KEY"
```

## Errors

The sources endpoints answer errors with a JSON body. `code` is stable, so branch on it; `message` is written for people and may change. A source ID from another workspace gets the same `404` as one that doesn’t exist.

```json
{
  "error": {
    "code": "file_too_large",
    "message": "Files can be up to 25 MB."
  }
}
```

| Status | code | When |
|---|---|---|
| 400 | `invalid_request` | The body isn’t JSON, a field is missing or invalid, or the file is empty. |
| 401 | `unauthorized` | The key is missing, invalid, revoked or expired. |
| 403 | `forbidden` | The key doesn’t have the sources scope. |
| 404 | `not_found` | No source with that ID in this key’s workspace. |
| 409 | `upload_missing` | Complete was called but the file never arrived. |
| 409 | `size_mismatch` | The stored file’s size differs from size. |
| 409 | `already_completed` | The source was already completed. |
| 409 | `upload_incomplete` | Re-index of a source whose upload isn’t complete. |
| 409 | `cannot_index` | This file type can’t be indexed right now. |
| 413 | `file_too_large` | The file is over 25 MB. |
| 415 | `unsupported_type` | The extension isn’t supported, or contentType doesn’t match it. |
| 429 | `rate_limited` | Too many requests; retry after Retry-After seconds. |
| 500 | `internal_error` | Something went wrong on our side. Retry later. |
| 503 | `unavailable` | Uploads aren’t available right now. Retry later. |

## Limits

- **Questions:** per workspace, 30 a minute and 500 a day, counting the playground and all API keys together. Over the limit, `POST /api/chat` returns `429` with a `Retry-After` header in seconds.
- **Uploads:** files up to 25 MB; each API source holds one file; the upload URL is valid for 10 minutes.
- **Answers:** at most five distinct cited passages. The source list returns the 200 most recent sources.

| Extensions | contentType |
|---|---|
| .md, .markdown, .mdx | `text/markdown` |
| .txt | `text/plain` |
| .html, .htm | `text/html` |
| .pdf | `application/pdf` |
| .docx | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` |
