All pages

Agents & API

API

The HTTP surface under the CLI — /api/v1, bearer auth and API keys, the error body, the OpenAPI document, and the public feed, résumé and apply endpoints.

krinoa calls a plain HTTP API, and so can you. It is complete, and it changes between contract versions — if you can shell out, prefer the CLI. The field-by-field reference is the OpenAPI document below, not this page.

Base URL and contract version#

Everything lives under /api/v1 on https://app.krinoa.com, in REST shapes: GET /jobs, GET /applications/{ref}, POST /applications/{ref}/stage. Jobs are addressed by their public id (ENG7K2), applications by A-142 or their UUID; both are case-insensitive. An unknown path answers 404 with the same error body every other failure uses, so a probe learns nothing about what exists.

GET /api/v1/version answers {"server":"0.1.0","contract":7,"minClientContract":1} without a credential. That contract number is the only compatibility signal (package versions are not compared), and every response carries it back as x-krinoa-contract. A client that names itself with x-krinoa-client: krinoa/<semver> contract/<n> below the server's minimum is refused with 426 rather than half-served; a request that sends no x-krinoa-client is not refused on version grounds.

Authenticate with a bearer token#

Every credentialed request carries Authorization: Bearer <credential>. Two kinds of credential ride the same header, and the server tells them apart by prefix:

  • An API key starts with krinoa_. It is a workspace credential and always acts at admin level: full workspace reads and writes, but not owner.
  • A session token does not. It comes from the device-authorization grant that krinoa login runs, and acts as the person who approved it, with their role. An interviewer's scoping applies.

With no credential, or an invalid one, the answer is 401. The browser app authenticates with a cookie against the same routes, and a cross-site request that carries a cookie and a mismatched Origin is refused on any non-GET method. Requests with no cookie and no Origin — which is what an agent, a script and curl all send — pass untouched.

Where keys come from#

Keys are created in the app under Settings › CLI access, which is visible to owners and admins only, or through the API from a signed-in session — krinoa keys create ci. There are no per-key scopes: a key is workspace-wide admin until it is revoked, expires, or its issuer leaves. Expiry is optional and the issuer's choice: pass expiresInDays (1 to 365) on create, or leave it out for a key that lives until revoked. Two rules matter:

  • Keys cannot create keys. POST /keys, GET /keys and DELETE /keys/{id} all require a session token. A request authenticated with a krinoa_ key gets 403.
  • The secret is returned once, in the key field of the create response. The list endpoint returns id, name, start, createdAt, lastUsedAt and expiresAt only, expiresAt being null when the key has no horizon.

Read a success body#

A success body is the model itself: no { "data": … } envelope, camelCase field names, ISO 8601 timestamps. Lists are cursor-paginated into a { items, nextCursor } page object — 50 items by default, 200 at most — and you pass nextCursor back as the cursor query parameter until it comes back null. Credentialed responses carry cache-control: private, no-store and vary: authorization, cookie, so no shared cache may keep them.

Branch on the error code#

json
{"defined":true,"code":"NOT_FOUND","status":404,"message":"No such endpoint"}

Every non-2xx answer — from a handler, from the router, from the transport — has that shape. code is the thing to branch on: UNAUTHORIZED (401), PERMISSION_DENIED (403), NOT_FOUND (404), CONFLICT (409), VALIDATION (422), UPGRADE_REQUIRED (426), RATE_LIMITED (429) and INTERNAL (500). status mirrors the HTTP status, defined says whether the error came from the contract's own vocabulary, and data is present on VALIDATION only, carrying an issues array of field-level problems with the failing path. The CLI maps these codes onto exit codes one for one, and The CLI has that table.

RATE_LIMITED has two budgets behind it, both rolling 60-second windows: per workspace, 600 reads, 120 writes and 20 expensive writes — the endpoints that cost a model call, an email or a file; and per API key, 120 requests whatever their class, which is the effective ceiling for an agent. Request bodies are capped at 8 MiB and a résumé inside one at 5 MB.

Fetch the OpenAPI document#

The full contract is served as OpenAPI 3.1, without a credential, and it is the reference for every path, parameter and field. The CLI wraps it as krinoa schema, and krinoa schema --local prints the copy bundled with your CLI build instead of asking the server.

bash
curl https://app.krinoa.com/api/v1/openapi.json | jq '.info'

The public surface#

Four GET endpoints answer with no credential at all and with Access-Control-Allow-Origin: *, because the embed script fetches them from a customer's own domain:

EndpointReturns
GET /api/v1/public/{orgSlug}Careers page header: name, logo, blurb, accent colour
GET /api/v1/public/{orgSlug}/jobsAn array of published jobs
GET /api/v1/public/{orgSlug}/jobs/{jobPublicId}One published job with its application form
GET /api/v1/public/{orgSlug}/referrals/{token}Who a referral invite is for, and for which role

This is the feed behind the embed. Cross-origin access stops there: OPTIONS on any other path answers the ordinary 404 body — no preflight, and no hint that an authenticated route exists. The candidate scheduling endpoints under /api/v1/public/interviews/{token} are unauthenticated too — the token in the URL is the credential — and are likewise same-origin.

Three unauthenticated writes sit under the same prefix, all same-origin:

EndpointDoes
POST /api/v1/public/{orgSlug}/jobs/{jobPublicId}/applySubmits an application (201)
POST /api/v1/public/{orgSlug}/jobs/{jobPublicId}/resumeStages a résumé ahead of that submission (200)
POST /api/v1/public/{orgSlug}/referrals/{token}/decline"Don't invite me again" (200)

apply takes the application itself: multipart/form-data keyed by the job form's own field keys and question ids, resume as the file field, 201 on success. The reserved names are turnstileToken, via, ref, src, embed and resumeKey, and which fields are required is decided by that job's form, not by the API — read it from the job detail endpoint above. It is not CORS-enabled, which is why the embedded apply form is an iframe of our page rather than a fetch from yours; a server-side caller is unaffected.

resume is the upload-first step behind that form: multipart/form-data with one file (PDF or DOCX, 5 MB), answering { "key": "orgs/…/staged/…", "suggested": { … } }. suggested carries whatever a regular-expression pass found in the first 4,000 characters — name, email, phone, linkedin_url, github_url, portfolio_url, each present only when found. No model runs. Send the key back as apply's resumeKey and it becomes that candidate's résumé; it is spent by the first application that uses it, and an unclaimed upload is deleted an hour later. Ten uploads per network per ten minutes.

Referrals#

A referral invite is a referrals record and a single-use token, not an application: the application appears only if the person follows the link and applies. POST /referrals sends the invitation (a résumé must already be uploaded through POST /uploads/resume with folder=referrals), GET /referrals lists your own newest first, and GET /referrals?scope=all is the whole workspace and needs owner or admin. GET /referrals/{id} is one of them. Every role may create; the raw token is not returned by any of these. It exists in the mail and in the link and nowhere else.

The two public endpoints above are the other side of that link. The token in the path is the credential, the orgSlug is checked against it, and both answer the same incurious 404 for a token that is unknown, spent, declined or expired. decline is idempotent and is a POST: the link travels in email, where scanners fetch what they find, so the page behind it asks before it acts.

What is not here#

  • No webhooks. Nothing calls you. Poll GET /activity, which is the ledger, or run a scheduled job.
  • No MCP server, bundled or hosted. See Agents.
  • No stability promise beyond the contract version. The API changes with the contract version, which x-krinoa-contract carries on every response; the CLI is kept stable across releases.

Updated 2026-09-16