Skip to content
Helpfeel

Keyline API Quickstart: Your First Call

View as Markdown

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.

ValueWhere it comes from
Base URLProvided with your Public Preview access
Project IDCopied from the project settings page in the Keyline app
TokenIssued 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 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 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"}]}
EventWhat to do with it
chunk, phase: infoThe search scope for this run. Label your view with it, or drop it
chunk, phase: process, type: tool_callThe loop narrating its own steps. Surface it, because a long query should not be silent
chunk, phase: process, type: tool_resultIntermediate analysis, useful for a live view. Buffer by unit identifier, because units interleave
chunk, phase: finalThe answer, streamed token by token, carrying inline [1] and [2] markers. Append it
doneEnds 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 3

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.
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 knowUseShape
Nothing. You have a library and a questionAgentic searchStreamed. A reasoning loop that searches, resolves, reads, and decides when it has enough
Which file or bundle holds the answerDeep readStreamed. A fixed pipeline that reads inside that scope in depth
You only want to know which files mention somethingVector searchOne 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.

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.

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 formatsPDF, PNG, JPEG, and Office: DOCX, XLSX, PPTX, DOC, XLS, PPT
File size and pagesPreliminary, and adjusted to fit customer needs
ThroughputUploads queue and index in the background
Rate limits and quotasBe 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.