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 | Created 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 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 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"}]}| 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. 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.
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. Current source at commitd6217b923ec8ebcf8b22d9e95e6ee6df45b305fedeliberately excludes the local-development multipartPOST .../upload/queueroute from bearer access. Ifupload/initreturns{ "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.- 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 file-status ladder runs
uploading,converting,processing_snippets,processing_index,processing_index_flow,processing_virtual_pages,indexing,indexing_failed,completed, pluserror. A streaming upload path can also emit a transientconvertedprogress 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 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. 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.
- 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.
- 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 bearer permissions
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 |
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/projectsGETandPOST /api/projects/:projectId/service-accountsPOST /api/projects/:projectId/service-accounts/:saUserId/resetDELETE /api/projects/:projectId/service-accounts/:saUserId
The current handlers and tests confirm the following rules:
- Project creation requires organization membership. The creator gets a
managegrant on the new project. - Project service-account list, create, reset, and revoke require
manageproject access. - Create can issue
view,edit, ormanage. 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.