
# Keyline API Quickstart: Your First Call

You have a key. This page takes you from an empty shell to a cited answer over HTTP, then to
putting your own documents in.

The API ships no interface of its own. It answers a question and hands back text, page numbers,
and image crops for your agent, your application, or your internal tool to render, so what a user
sees is the surface you already ship. The
[Keyline app](/docs/keyline/getting-started) is what that looks like when we render it.

If you are still deciding whether to bother, [Keyline for developers](/keyline) carries that
argument, and the rates live on the [pricing page](/keyline/pricing).

## Three things you need

Every call is scoped to a project and authorized by one header. A token cannot discover the base
URL or the project id for you, so all three arrive out of band with your Public Preview access.

| Value      | Where it comes from                                      |
| ---------- | -------------------------------------------------------- |
| Base URL   | Provided with your Public Preview access                 |
| Project ID | Copied from the project settings page in the Keyline app |
| Token      | Issued with your Public Preview access                   |

```bash
export KEYLINE_API_URL="..."      # provided with your Public Preview access
export KEYLINE_PROJECT_ID="..."   # project settings page in the Keyline app
export KEYLINE_TOKEN="plms_..."   # issued with your access
```

A token is the prefix `plms_` followed by 43 URL-safe characters. It is shown once, at creation
and at reset, so store it as a secret; regenerating one invalidates the old one immediately, with
no overlap window. Calls are server to server, with no CORS and no browser SDK.

Without a key yet? Sign up for the [Public Preview](/keyline#signup) and we will send one with the
base URL and your project id.

## Your first call

Agentic search against a project that already has documents in it. `query` is the only required
field: the project is the corpus, with no per-call file filter, no `topK`, and no depth cap, so
cost scales with library size. Scope a project to the documents that matter rather than holding
everything in one.

For multi-turn conversations, pass `history` and the `lastRefNumber` you used, so citation
numbering continues across turns.

```bash
curl -N -X POST \
  "$KEYLINE_API_URL/api/projects/$KEYLINE_PROJECT_ID/search/agentic-search" \
  -H "Authorization: Bearer $KEYLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"query": "What is the maximum operating pressure?"}'
```

```ts
const base = process.env.KEYLINE_API_URL;
const project = process.env.KEYLINE_PROJECT_ID;

const res = await fetch(base + "/api/projects/" + project + "/search/agentic-search", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + process.env.KEYLINE_TOKEN,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ query: "What is the maximum operating pressure?" }),
});

const decoder = new TextDecoder();
for await (const bytes of res.body) {
  process.stdout.write(decoder.decode(bytes, { stream: true }));
}
```

```python
import os
import httpx

url = (
    os.environ["KEYLINE_API_URL"]
    + "/api/projects/" + os.environ["KEYLINE_PROJECT_ID"]
    + "/search/agentic-search"
)
headers = {"Authorization": "Bearer " + os.environ["KEYLINE_TOKEN"]}
body = {"query": "What is the maximum operating pressure?"}

with httpx.stream("POST", url, headers=headers, json=body, timeout=None) as res:
    for line in res.iter_lines():
        print(line)
```

## Reading the stream

The response is Server-Sent Events with two event names on the wire, `chunk` and `done`, and
`chunk` carries a `phase` discriminator. This is where most of your integration time goes.

```text
event: chunk
data: {"phase":"info","searchScope":{"projectId":"...","libName":"Acme Docs"}}

event: chunk
data: {"phase":"process","type":"tool_call","toolName":"vector_search","toolArgs":{...},"text":"..."}

event: chunk
data: {"phase":"process","type":"tool_result","toolName":"resolve_search_hit","toolResponse":{...}}

event: chunk
data: {"phase":"final","text":"... [1] ... [2]"}

event: done
data: {"success":true,"refs":[{"refNumber":1,"fileHash":"4c8ae...","vpId":"p12_vp00042",
       "hitId":"Financial Report\tRevenue\t[Quarterly Trends]","fileName":"FY26-Q3.pdf"}]}
```

| Event                                      | What to do with it                                                                                 |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------- |
| `chunk, phase: info`                       | The search scope for this run. Label your view with it, or drop it                                 |
| `chunk, phase: process, type: tool_call`   | The loop narrating its own steps. Surface it, because a long query should not be silent            |
| `chunk, phase: process, type: tool_result` | Intermediate analysis, useful for a live view. Buffer by unit identifier, because units interleave |
| `chunk, phase: final`                      | The answer, streamed token by token, carrying inline `[1]` and `[2]` markers. Append it            |
| `done`                                     | Ends the stream and carries `refs`, the structured reference list. Resolve citations here          |

Disconnecting aborts the work server side, so abandoning a stream stops the spend. When nothing
relevant is found the answer says so, and that is a valid outcome to pass through honestly.
Internally the run is a Gemini tool loop of up to fifty turns; you consume its output rather than
driving it.

## Getting a citation on screen

The `final` text carries inline `[1]` and `[2]` markers, and the `done` event's `refs` array
resolves each number to a `fileHash`, a `vpId`, and a `fileName`.

The page number comes from the `vpId` string convention rather than a field. Ids are built as
`p<page>_vp<counter padded to five>`, so `p12_vp00042` is physical page 12, and snippet ids follow
the same shape, with `p12_s3` being page 12, snippet 3.

```ts
const ID = /^p(\d+)_(?:vp|s)(\d+)$/;

function pageOf(id) {
  const match = ID.exec(id);
  if (!match) throw new Error("unrecognized identifier: " + id);
  return Number(match[1]);
}

pageOf("p12_vp00042"); // 12, physical page 12
pageOf("p12_s3");      // 12, page 12 snippet 3
```

```python
import re

ID = re.compile(r"^p(\d+)_(?:vp|s)(\d+)$")

def page_of(identifier):
    match = ID.match(identifier)
    if not match:
        raise ValueError("unrecognized identifier: " + identifier)
    return int(match.group(1))

page_of("p12_vp00042")  # 12, physical page 12
page_of("p12_s3")       # 12, page 12 snippet 3
```

```bash
# FILE_HASH and the snippet id both come from the done event's refs array.
curl -L -o snippet.png \
  "$KEYLINE_API_URL/api/projects/$KEYLINE_PROJECT_ID/files/$FILE_HASH/snippets/p12_s3.png" \
  -H "Authorization: Bearer $KEYLINE_TOKEN"
```

The snippet filename is validated against `/^p\d{1,4}_s\d{1,4}\.png$/`, so you build the URL from
a snippet id the server already gave you. The response is the cropped region, or a redirect to a
signed GCS URL. You get the page and the figure it came from, so you can show the crop; drawing a
highlight box on the full page arrives with the Public Preview.

### Never fabricate a citation

Cite only identifiers the server returned. Do not construct, guess, adjust, or interpolate one,
and do not carry one over from a previous answer. A citation that does not resolve is worse than
no citation, because the whole value here is that a claim can be checked.

## Putting documents in

This comes after your first query on purpose: a preview key arrives with a project already
indexed. Upload returns immediately and everything after it is asynchronous, in four steps.

1. `POST .../upload/init` with `{ fileName, size, mimeType, fileHash }`, where `fileHash` is a
   client-computed SHA-256 matching `/^[a-f0-9]{64}$/`. You get back a GCS V4 signed PUT URL, or
   `{ "mode": "legacy" }`, which sends you to `POST .../upload/queue` with a multipart body.
2. PUT the bytes to the signed URL, sending exactly the extension headers you were given. They are
   part of the signature, and `x-goog-content-length-range` is always one of them. The URL expires,
   so do not hold it.
3. Poll `GET .../files/:fileHash` every few seconds. There are no webhooks.
4. The status ladder runs `uploading`, `converting`, `converted`, `processing_snippets`,
   `processing_index`, `processing_index_flow`, `processing_virtual_pages`, `indexing`,
   `completed`, plus `error`.

```bash
FILE_HASH=$(shasum -a 256 manual.pdf | cut -d' ' -f1)

# 1. Ask for an upload target.
curl -X POST "$KEYLINE_API_URL/api/projects/$KEYLINE_PROJECT_ID/upload/init" \
  -H "Authorization: Bearer $KEYLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"fileName\":\"manual.pdf\",\"size\":$(wc -c <manual.pdf),\"mimeType\":\"application/pdf\",\"fileHash\":\"$FILE_HASH\"}"

# 2. PUT the bytes to the signed target you were handed, with exactly the headers it was
#    signed with. One of them is always x-goog-content-length-range.
curl -X PUT "$SIGNED_URL" -H "$SIGNED_HEADER" --upload-file manual.pdf

# 3. Poll every few seconds. There are no webhooks.
curl "$KEYLINE_API_URL/api/projects/$KEYLINE_PROJECT_ID/files/$FILE_HASH" \
  -H "Authorization: Bearer $KEYLINE_TOKEN"
```

```ts
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";

const base = process.env.KEYLINE_API_URL;
const project = process.env.KEYLINE_PROJECT_ID;
const auth = { Authorization: "Bearer " + process.env.KEYLINE_TOKEN };

const bytes = await readFile("manual.pdf");
const fileHash = createHash("sha256").update(bytes).digest("hex");

const init = await fetch(base + "/api/projects/" + project + "/upload/init", {
  method: "POST",
  headers: { ...auth, "Content-Type": "application/json" },
  body: JSON.stringify({
    fileName: "manual.pdf",
    size: bytes.length,
    mimeType: "application/pdf",
    fileHash,
  }),
}).then((r) => r.json());

// PUT the bytes to the signed target in init, echoing back exactly the headers it was
// signed with, then poll. Stop at indexing.
const file = await fetch(base + "/api/projects/" + project + "/files/" + fileHash, {
  headers: auth,
}).then((r) => r.json());
```

```python
import hashlib, os
import httpx

base = os.environ["KEYLINE_API_URL"] + "/api/projects/" + os.environ["KEYLINE_PROJECT_ID"]
auth = {"Authorization": "Bearer " + os.environ["KEYLINE_TOKEN"]}

data = open("manual.pdf", "rb").read()
file_hash = hashlib.sha256(data).hexdigest()

init = httpx.post(base + "/upload/init", headers=auth, json={
    "fileName": "manual.pdf",
    "size": len(data),
    "mimeType": "application/pdf",
    "fileHash": file_hash,
}).json()

# PUT the bytes to the signed target in init, echoing back exactly the headers it was
# signed with, then poll. Stop at indexing.
status = httpx.get(base + "/files/" + file_hash, headers=auth).json()
```

### Two statuses mean usable.

`indexing` already means analysis is done and the document is deep-readable. The worker is still
building the library-wide hierarchy and title index, which is what `completed` marks. Waiting for
`completed` leaves the document unavailable longer than it needs to be.

A duplicate name in the processing queue is rejected, and a file already being processed is locked
with a 409. Treat both as already in flight.

## The other two retrieval calls

Pick by what you know before you ask.

| You know                                            | Use            | Shape                                                                                     |
| --------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------- |
| Nothing. You have a library and a question          | Agentic search | Streamed. A reasoning loop that searches, resolves, reads, and decides when it has enough |
| Which file or bundle holds the answer               | Deep read      | Streamed. A fixed pipeline that reads inside that scope in depth                          |
| You only want to know which files mention something | Vector search  | One JSON response, no reasoning, cheapest by a wide margin                                |

### Deep read

`POST .../search/deep-read`, streamed, with a body of
`{ message, fileHash | (bundleId + fileHashes), isFollowUp, conversationId }`. `message` is
required, and one of `fileHash` or `bundleId` is required. In bundle mode `fileHashes` is
required, non-empty, and a subset of the bundle.

### Deep read cites differently from agentic search.

Its citations are inline `[[p1_s3]]` markers embedded in the markdown, and its `done` event
carries only `{ success, timestamp, message, chatLink }`, so a consumer regexes the answer text to
recover them. Its progress analyzes several units in parallel, so per-unit output interleaves and
has to be buffered by unit identifier. Deep read also leaves persistence to you: inference and
saving are separate calls.

### Vector search

`GET .../search/vector-search?q=`, plain JSON, no reasoning, cheapest by a wide margin. Hits come
back grouped by file, each a topic path through that document's hierarchy with a raw LanceDB
`_distance`, top 2 per table and capped at 20 tables.

It returns no page numbers, and `hierarchy/resolve_hit` is off the token allowlist, so reach for
agentic search when you need the page and let the server run the loop.

## Errors you will actually hit

In roughly the order you will meet them.

1. **Everything returns 401.** Every auth failure returns a byte-identical 401, and invalid,
   revoked, never-issued, and not-permitted are deliberately indistinguishable. Do not write logic
   that discriminates them, and do not retry hoping for a different error. Check the token prefix
   and length, then the project id, then whether the endpoint is one a token may reach at all.
2. **A file never becomes ready.** Check the format and the size and page limits. A rejected
   format fails at upload, before any processing starts.
3. **You uploaded the same document twice.** A duplicate name in the processing queue is rejected,
   and a file already being processed is locked with a 409. Both mean already in flight.
4. **Interleaved progress looks like nonsense.** It is parallel analysis. Buffer by unit
   identifier before you display anything.
5. **The conversation was not saved.** Deep read does not persist. Accumulate the final text and
   save it in a second call.
6. **The answer says nothing relevant was found.** Often correct, and a valid outcome to pass
   through. Architectural, electrical, and wiring drawings are a confirmed failure case.
7. **Error text arrives in Japanese.** Some server messages are not fully translated yet. Match on
   status codes rather than on message strings.

## Limits, and what a token cannot do

Published, so you can plan against them.

|                        |                                                             |
| ---------------------- | ----------------------------------------------------------- |
| Accepted formats       | PDF, PNG, JPEG, and Office: DOCX, XLSX, PPTX, DOC, XLS, PPT |
| File size and pages    | Preliminary, and adjusted to fit customer needs             |
| Throughput             | Uploads queue and index in the background                   |
| Rate limits and quotas | Be a considerate caller and back off on errors              |

### In this preview a token cannot:

- Delete anything: not files, not bundles, not sessions.
- Rename a file.
- Save a conversation attached to a file. Bundle and saved-session conversations can be saved.
- Read text-extraction output, page dimensions, or region coordinates.
- Resolve a vector-search hit to page content.
- Manage members, or grant anyone any access.
- Discover which organization or project it belongs to.

Every one of those returns the same opaque 401, so probing teaches you nothing. Design around
them. Rates and the published limits live on the [pricing page](/keyline/pricing).

## Next

[The agent runbook](/docs/keyline/agent-runbook) is this page written for a coding agent to read
in one pass: the whole contract, no code blocks.

[Keyline](/keyline) covers what the retrieval engine does and how it is built.

[Contact us](/contact) when a call does not behave the way this page says it will.

Ready for a key? [Sign up for the Public Preview](/keyline#signup).
