Build on Lornos
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: if you sign in at https://lornos.example/login, the base URL is https://lornos.example. 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. 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.
Request
The body holds the conversation in messages, oldest first. Each message has an id, a role (user or assistant) and parts, a list of { "type": "text", "text": "…" } objects. The last user message is the question.
{
"messages": [
{
"id": "q1",
"role": "user",
"parts": [
{
"type": "text",
"text": "How do I record a sale?"
}
]
}
]
}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?"}]}]}'Streamed response
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. Other event types (start, start-step, text-start, text-end, finish-step, finish, and others) mark the stream’s progress; ignore the ones you don’t use. The finish event may carry diagnostic metadata that can change without notice, so don’t build on it.
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]type UIMessage = {
id: string
role: 'user' | 'assistant'
parts: { type: 'text'; text: string }[]
}
// Sends the conversation and returns the answer text and the chunk IDs it cites.
export async function ask(messages: UIMessage[]) {
const response = await fetch(`${process.env.LORNOS_URL}/api/chat`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LORNOS_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ messages }),
})
if (!response.ok) throw new Error(`Lornos answered ${response.status}: ${await response.text()}`)
const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ''
let answer = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buffer += value
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue
const event = JSON.parse(line.slice('data: '.length))
if (event.type === 'text-delta') answer += event.delta
if (event.type === 'error') throw new Error(event.errorText)
}
}
const citations = [...new Set(answer.match(/[A-Za-z0-9][A-Za-z0-9._-]*#[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?/g) ?? [])]
return { answer, citations }
}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. Earlier turns only tell Lornos what the new question refers to (“it”, “that step”). Each question gets fresh retrieval, and earlier answers are never used as evidence: their citations don’t carry over, and the new answer cites only passages retrieved for it.
{
"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?"
}
]
}
]
}Chat errors
| Status | When |
|---|---|
401 | The key is missing, invalid, revoked, expired, or lacks the chat scope. The body is { "error": { "code": "unauthorized", ... } }. |
429 | The workspace reached its question limit. Wait the number of seconds in Retry-After. See Limits. |
Upload a document
An upload takes three requests: create the source, send the file to the URL you get back, then complete it.
1. Create the source
POST/api/v1/sources
Scope: sources. Returns 201.
| Field | Type | Notes |
|---|---|---|
filename | string | Required, up to 255 characters. Its extension sets the file type. |
size | integer | Required. The file’s size in bytes, up to 25 MB. |
contentType | string | Optional. When you send it, it must be the type listed for the extension under Limits. |
{
"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. Upload the file
PUT the file’s bytes to upload.url with exactly the headers in upload.headers; the Content-Type is part of the signature, so a different one is refused. The URL works until expiresAt (10 minutes). Send the upload from your server.
3. Complete the upload
POST/api/v1/sources/{id}/complete
Scope: sources. Returns 202 with { "id", "status": "uploaded" }.
Lornos checks that the file arrived with the size you declared, then starts indexing in the background. If the file is missing or its size differs, you get 409 and the source is marked failed; create a new source and upload again. Completing a source twice gets 409 already_completed.
# 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"import { readFile, stat } from 'node:fs/promises'
import path from 'node:path'
const base = process.env.LORNOS_URL
const auth = { Authorization: `Bearer ${process.env.LORNOS_KEY}` }
async function lornos(pathname: string, init: RequestInit = {}) {
const response = await fetch(`${base}${pathname}`, { ...init, headers: { ...auth, ...init.headers } })
if (!response.ok) {
const { error } = await response.json()
throw new Error(`${response.status} ${error.code}: ${error.message}`)
}
return response.status === 204 ? null : response.json()
}
export async function uploadDocument(file: string, contentType: string) {
const { size } = await stat(file)
// 1. Create the source.
const source = await lornos('/api/v1/sources', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: path.basename(file), contentType, size }),
})
// 2. Upload the bytes to the signed URL (valid until source.expiresAt).
const put = await fetch(source.upload.url, {
method: source.upload.method,
headers: source.upload.headers,
body: await readFile(file),
})
if (!put.ok) throw new Error(`Upload failed with ${put.status}`)
// 3. Complete: Lornos checks the file and starts indexing.
await lornos(`/api/v1/sources/${source.id}/complete`, { method: 'POST' })
return source.id as string
}
// Poll until the source is searchable or has failed.
export async function waitForIndexing(id: string) {
for (;;) {
const source = await lornos(`/api/v1/sources/${id}`)
if (source.status === 'indexed') return source
if (source.status === 'failed') throw new Error(source.error ?? 'Indexing failed')
await new Promise((resolve) => setTimeout(resolve, 3000))
}
}Status, re-index and delete
GET/api/v1/sources
Scope: sources. Returns { "data": [source, …] }, newest first, up to 200.
GET/api/v1/sources/{id}
Scope: sources. Returns one source.
{
"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. |
passages is how many citable passages the document produced. embedded is how many of them also have semantic search ready; until they all do, the rest are found by keyword search. Poll every few seconds until the status is indexed or failed; large PDFs take longer than text files.
Re-index
POST/api/v1/sources/{id}/reindex
Scope: sources. Returns 202 with { "id", "status" }.
Indexes an uploaded source again, for example after it failed or stayed in indexing. A source still in pending_upload gets 409 upload_incomplete; a type that can’t be indexed right now gets 409 cannot_index.
Delete
DELETE/api/v1/sources/{id}
Scope: sources. Returns 204 with no body.
Removes the stored file and all of its passages at once. A deleted source is never cited again.
# 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.
{
"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, chat is limited to 30 questions per minute and 500 per day, counting the playground and all API keys together. Over the limit, POST /api/chat returns 429 with a Retry-After header (in seconds) and this body:
{ "error": { "code": "rate_limited", "message": "…" } }Uploads
Files can be up to 25 MB. Each API source holds one file; the upload URL is valid for 10 minutes. These types are accepted:
| Extensions | contentType |
|---|---|
| .md, .markdown, .mdx | text/markdown |
| .txt | text/plain |
| .html, .htm | text/html |
application/pdf | |
| .docx | application/vnd.openxmlformats-officedocument.wordprocessingml.document |
Answers
An answer cites at most five distinct passages. The source list returns the 200 most recent sources.