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 is what that looks like when we render it.
If you are still deciding whether to bother, Keyline for developers carries that argument, and the rates live on the pricing page.
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 |
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 accessA 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 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.
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?"}'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.
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.
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 3The 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.
POST .../upload/initwith{ fileName, size, mimeType, fileHash }, wherefileHashis 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 toPOST .../upload/queuewith a multipart body.- 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-rangeis always one of them. The URL expires, so do not hold it. - Poll
GET .../files/:fileHashevery few seconds. There are no webhooks. - The status ladder runs
uploading,converting,converted,processing_snippets,processing_index,processing_index_flow,processing_virtual_pages,indexing,completed, pluserror.
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"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.
- 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.
- A file never becomes ready. Check the format and the size and page limits. A rejected format fails at upload, before any processing starts.
- 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.
- Interleaved progress looks like nonsense. It is parallel analysis. Buffer by unit identifier before you display anything.
- The conversation was not saved. Deep read does not persist. Accumulate the final text and save it in a second call.
- 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.
- 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.
Next
The agent runbook is this page written for a coding agent to read in one pass: the whole contract, no code blocks.
Keyline covers what the retrieval engine does and how it is built.
Contact us when a call does not behave the way this page says it will.
Ready for a key? Sign up for the Public Preview.