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
TokenCreated once in Project Settings, or provided with 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 access yet? Sign up for the Public Preview. The current source uses Google sign-in for human access. A deployed beta email-domain allowlist and rolling new-user cap can restrict first login. The cap does not block an existing user from signing in.

After an accepted first login, the server creates or repairs a personal organization. If that organization has no project, the client tries to create My Project. A project manager can then create a project-scoped service account in Project Settings. The token appears only in the create or reset response. These are source-backed behaviors at the pinned commit. Confirm the deployed revision and access settings before you depend on them.

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. To draw the cited region on the full page, send the returned snippet ids to POST .../files/:fileHash/page_image_snippets. The response groups the page number, snippet number, and bounding box by page.

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

Start with an existing indexed project when one was provided. For an empty self-service project, 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. Current source at commit d6217b923ec8ebcf8b22d9e95e6ee6df45b305fe deliberately excludes the local-development multipart POST .../upload/queue route from bearer access. If upload/init returns { "mode": "multipart" }, stop. That bearer token cannot use the fallback route. The source-defined production configuration uses the direct GCS path. That configuration is evidence of intent, not proof of the deployed revision.
  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 file-status ladder runs uploading, converting, processing_snippets, processing_index, processing_index_flow, processing_virtual_pages, indexing, indexing_failed, completed, plus error. A streaming upload path can also emit a transient converted progress event. Do not wait for that event when you use direct upload and polling.
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"

Three terminal states can be readable.

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. indexing_failed means the document is still deep-readable, but its project-wide hierarchy or title index is missing. Resume analysis before you rely on project-wide vector or agentic search to find that file. Waiting for completed is unnecessary for deep read, but treating indexing_failed as full success hides a real search gap.

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. A malformed, unknown, or revoked token returns the same 401 as a request to a path outside the bearer allowlist. 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 valid token gets 404. Route-level project permissions are separate from the bearer allowlist. Keyline can conceal a missing resource and missing access with the same not-found response. For example, a view-only service account cannot delete a file. Do not treat 404 as proof that the file does not exist.
  3. A file never becomes ready. Check the format and the size and page limits. A rejected format fails at upload, before any processing starts.
  4. 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.
  5. Interleaved progress looks like nonsense. It is parallel analysis. Buffer by unit identifier before you display anything.
  6. The conversation was not saved. Deep read does not persist. Accumulate the final text and save it in a second call.
  7. 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.
  8. Error text arrives in Japanese. Some server messages are not fully translated yet. Match on status codes rather than on message strings.

Limits and bearer permissions

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

Bearer access has two gates. The bearer allowlist decides which routes a token can reach. The live project grant decides what the service account can do on each route.

Current source reviewed for this page at commit d6217b923ec8ebcf8b22d9e95e6ee6df45b305fe includes this route:

DELETE /api/projects/:projectId/files/:fileHash

The route requires edit project access. A service account with only view access gets a concealed not-found response. A queued or actively processed file returns 409 instead of being deleted. A successful delete removes the file directory, including its saved conversations, and attempts to remove its vector tables and file-list marker. Keep view-only service accounts separate from service accounts that can delete.

The same source places these provisioning routes on the bearer allowlist:

  • POST /api/orgs/:orgId/projects
  • GET and POST /api/projects/:projectId/service-accounts
  • POST /api/projects/:projectId/service-accounts/:saUserId/reset
  • DELETE /api/projects/:projectId/service-accounts/:saUserId

The current handlers and tests confirm the following rules:

  • Project creation requires organization membership. The creator gets a manage grant on the new project.
  • Project service-account list, create, reset, and revoke require manage project access.
  • Create can issue view, edit, or manage. The plaintext token appears only in the create or reset response.
  • Project-scoped reset or revoke is refused when that service account has grants to more than one project. Shared service accounts must be managed from organization settings through a user session, not through these bearer routes.

This surface lets one token mint another token. Do not give a retrieval-only agent project manage access.

This is implementation and test evidence for the pinned source commit. It does not prove that the same revision is deployed on the base URL you received. Before you automate deletion, test the route with a synthetic file in an authorized environment and confirm that the file no longer appears in file access or search results.

A token still cannot:

  • Delete bundles or saved sessions.
  • Rename a file.
  • Save a conversation attached to a file. Bundle and saved-session conversations can be saved.
  • Read text-extraction output or page dimensions.
  • Resolve a vector-search hit to page content.
  • Manage human members. Service-account provisioning is the separate source-bounded surface above.
  • Discover which organization or project it belongs to.

Paths outside the bearer allowlist return the same opaque 401. Route-level permission checks can instead return a concealed 404. Do not probe either response to discover access or resources. 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.